@@ -313,72 +313,80 @@ export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type"
313313 return `${ entry . type } :${ canonicalMemoryText ( entry . text ) } ` ;
314314}
315315
316- /** Extract entity/destination keys for project and reference dedup */
317- function extractEntityKey ( text : string ) : string | null {
318- const normalized = canonicalMemoryText ( text ) ;
319- // Check known key phrases (bilingual-friendly)
320- // opencode + agenthub plugin system
321- if ( / o p e n c o d e .* a g e n t h u b / i. test ( normalized ) ) {
322- return "opencode-agenthub plugin system" ;
316+ function normalizeUrlIdentity ( raw : string ) : string | null {
317+ const cleaned = raw . replace ( / [ ) , . ; : ! ? ] + $ / g, "" ) ;
318+ try {
319+ const url = new URL ( cleaned ) ;
320+ if ( url . protocol !== "http:" && url . protocol !== "https:" ) return null ;
321+ url . protocol = url . protocol . toLowerCase ( ) ;
322+ url . hostname = url . hostname . toLowerCase ( ) ;
323+ url . hash = "" ;
324+ if ( url . pathname . length > 1 ) {
325+ url . pathname = url . pathname . replace ( / \/ + $ / g, "" ) ;
326+ }
327+ return `url:${ url . toString ( ) } ` ;
328+ } catch {
329+ return null ;
323330 }
324- // For generic config references, fall back to canonical text dedup — no entity key
325- return null ;
326331}
327332
328- /** Extract decision topic key for supersession detection */
329- function decisionTopicKey ( text : string ) : string | null {
330- const normalized = text . toLowerCase ( ) ;
331- // Parser format versions
332- if ( / p a r s e r .* f o r m a t s ? | s u p p o r t s ? \s * \d + \s * f o r m a t / i. test ( normalized ) ) {
333- return "parser-supported-formats" ;
334- }
335- // Compaction template replacement
336- if ( / c o m p a c t i o n .* t e m p l a t e | o u t p u t \. p r o m p t | t e m p l a t e .* r e p l a c e / i. test ( normalized ) ) {
337- return "compaction-template-replacement" ;
338- }
339- // Plugin loading
340- if ( / p l u g i n .* l o a d | n p m .* c a c h e | p l u g i n .* c o n f i g / i. test ( normalized ) ) {
341- return "plugin-loading-config" ;
342- }
343- // Output format changes (purple/italic, YAML frontmatter, etc)
344- if ( / p u r p l e .* i t a l i c | m a r k u p | m a r k d o w n .* r e n d e r | f r o n t m a t t e r / i. test ( normalized ) ) {
345- return "output-format-rendering" ;
346- }
347- return null ;
333+ function normalizePathIdentity ( raw : string ) : string | null {
334+ const unwrapped = raw
335+ . trim ( )
336+ . replace ( / ^ [ ` " ' ] + | [ ` " ' ] + $ / g, "" )
337+ . replace ( / [ ) , . ; : ! ? ] + $ / g, "" )
338+ . replace ( / \\ + / g, "/" ) ;
339+
340+ if ( ! unwrapped ) return null ;
341+ const collapsed = unwrapped . startsWith ( "/" )
342+ ? `/${ unwrapped . slice ( 1 ) . replace ( / \/ + $ / g, "/" ) . replace ( / \/ + / g, "/" ) } `
343+ : unwrapped . replace ( / \/ + / g, "/" ) ;
344+ const withoutTrailingSlash = collapsed . length > 1 ? collapsed . replace ( / \/ + $ / g, "" ) : collapsed ;
345+ return `path:${ withoutTrailingSlash } ` ;
348346}
349347
350- /** Extract feedback topic key for supersession detection */
351- function feedbackTopicKey ( text : string ) : string | null {
352- const normalized = text . toLowerCase ( ) ;
353- // Purple/italic rendering issue
354- if ( / p u r p l e .* i t a l i c / i. test ( normalized ) ) {
355- return "purple-italic-rendering" ;
356- }
357- // Browser login/server errors (500 internal_error)
358- if ( / l o g i n .* 5 0 0 | 5 0 0 .* i n t e r n a l | i n t e r n a l _ e r r o r | s e r v e r .* e r r o r / i. test ( normalized ) ) {
359- return "server-error" ;
360- }
361- // Port occupied / environment issues
362- if ( / p o r t .* o c c u p | 9 4 7 3 | 端 口 | 舊 進 程 | 旧 进 程 / i. test ( normalized ) ) {
363- return "port-occupied-environment" ;
348+ function isConcretePathIdentity ( pathIdentity : string ) : boolean {
349+ const path = pathIdentity . slice ( "path:" . length ) ;
350+ if ( ! path || path === "." || path === ".." ) return false ;
351+
352+ if ( path . startsWith ( "/" ) ) return true ;
353+ if ( / ^ \. \. ? \/ / . test ( path ) ) return true ;
354+ if ( / ^ \. [ A - Z a - z 0 - 9 _ . - ] + \/ / . test ( path ) ) return true ;
355+ if ( / ^ [ A - Z a - z 0 - 9 _ . - ] + \/ / . test ( path ) ) return true ;
356+ return / \. (?: j s o n | j s o n c | t s | t s x | j s | j s x | m j s | c j s | m d | y a m l | y m l | t o m l | l o c k | c o n f i g ) $ / i. test ( path ) ;
357+ }
358+
359+ function normalizeConcretePathIdentity ( raw : string ) : string | null {
360+ const pathIdentity = normalizePathIdentity ( raw ) ;
361+ if ( ! pathIdentity ) return null ;
362+ return isConcretePathIdentity ( pathIdentity ) ? pathIdentity : null ;
363+ }
364+
365+ function extractConcreteIdentityKey ( text : string ) : string | null {
366+ const urlMatch = text . match ( / h t t p s ? : \/ \/ [ ^ \s ` " ' < > ] + / i) ;
367+ if ( urlMatch ) {
368+ const urlIdentity = normalizeUrlIdentity ( urlMatch [ 0 ] ) ;
369+ if ( urlIdentity ) return urlIdentity ;
364370 }
365- // Theme preferences
366- if ( / t h e m e | d a r k .* l i g h t | p r e f e r .* t h e m e / i. test ( normalized ) ) {
367- return "theme-preference" ;
371+
372+ const wrappedPathPattern = / [ ` " ' ] ( [ ^ ` " ' ] + ) [ ` " ' ] / g;
373+ for ( const match of text . matchAll ( wrappedPathPattern ) ) {
374+ const pathIdentity = normalizeConcretePathIdentity ( match [ 1 ] ) ;
375+ if ( pathIdentity ) return pathIdentity ;
368376 }
369- return null ;
377+
378+ const pathMatch = text . match ( / (?: \/ [ ^