@remnic/core 9.33.0 → 9.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/access-admin-ops-surface.js +15 -15
  2. package/dist/access-authorization-probe.js +16 -16
  3. package/dist/access-boundary.js +15 -15
  4. package/dist/access-cli.js +35 -34
  5. package/dist/access-cli.js.map +1 -1
  6. package/dist/access-extraction-force-flush.js +15 -15
  7. package/dist/access-http.js +25 -25
  8. package/dist/access-identity-continuity-surface.js +15 -15
  9. package/dist/access-lcm-surface.js +15 -15
  10. package/dist/access-mcp.js +20 -20
  11. package/dist/access-namespace-preflight.js +15 -15
  12. package/dist/access-observe-write-surface.js +15 -15
  13. package/dist/access-operations-batch.js +17 -17
  14. package/dist/access-operations.js +19 -19
  15. package/dist/access-recall-concurrency.js +15 -15
  16. package/dist/access-recall-response.js +15 -15
  17. package/dist/access-recall-surface.js +15 -15
  18. package/dist/access-schema.js +3 -3
  19. package/dist/access-service.js +15 -15
  20. package/dist/adapters/index.js +4 -4
  21. package/dist/adapters/registry.js +2 -2
  22. package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-GWVG7LGC.js} +2 -2
  23. package/dist/{chunk-VDX2J7OX.js → chunk-5W7CTZ2E.js} +4 -4
  24. package/dist/{chunk-J6BYDVPL.js → chunk-6HXQ2YO5.js} +2 -2
  25. package/dist/{chunk-6NPZ3FP2.js → chunk-7CCVK67W.js} +5 -5
  26. package/dist/{chunk-77CNV7DY.js → chunk-BLAKS3CP.js} +4 -4
  27. package/dist/{chunk-YFQMW2TH.js → chunk-DG2R7M7K.js} +11 -11
  28. package/dist/{chunk-PTN6UAUA.js → chunk-ES36HTSC.js} +4 -4
  29. package/dist/{chunk-ATUURSST.js → chunk-FL3AM5KE.js} +3 -3
  30. package/dist/{chunk-I5DQ6KZK.js → chunk-FYO2GAZJ.js} +2 -2
  31. package/dist/{chunk-MPH263X2.js → chunk-HULSU7PS.js} +2 -2
  32. package/dist/{chunk-N2B56SIS.js → chunk-KJT3PZAR.js} +2 -2
  33. package/dist/chunk-KWMXR7XS.js +138 -0
  34. package/dist/chunk-KWMXR7XS.js.map +1 -0
  35. package/dist/{chunk-7RZJWZ46.js → chunk-LEPOP46W.js} +5 -5
  36. package/dist/{chunk-TITQYVKD.js → chunk-LRYAQGDR.js} +11 -11
  37. package/dist/{chunk-BXLOS5AJ.js → chunk-OWHERGF2.js} +2 -2
  38. package/dist/{chunk-6JE3HGS4.js → chunk-OXBPHIKH.js} +5 -5
  39. package/dist/{chunk-SIKCTPRK.js → chunk-QEPNZBBC.js} +3 -3
  40. package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
  41. package/dist/{chunk-C4PEMCP4.js → chunk-RLOVRJZN.js} +7 -7
  42. package/dist/{chunk-EHNENZHZ.js → chunk-RQBF54FC.js} +3 -3
  43. package/dist/{chunk-SPPL77NI.js → chunk-RSFWAI4D.js} +49 -29
  44. package/dist/chunk-RSFWAI4D.js.map +1 -0
  45. package/dist/{chunk-RYHWZ52A.js → chunk-S2BBISHU.js} +4 -4
  46. package/dist/{chunk-V2MVDDJF.js → chunk-SUZI6BHQ.js} +2 -2
  47. package/dist/{chunk-DQEMWVMT.js → chunk-UVYI6VIX.js} +1 -1
  48. package/dist/{chunk-YJ7PWEWI.js → chunk-WFYWGDM6.js} +2 -2
  49. package/dist/{chunk-SFW2NABJ.js → chunk-Y6XRTQMV.js} +5 -5
  50. package/dist/{chunk-6NNNURLK.js → chunk-Z54EM5YZ.js} +4 -4
  51. package/dist/cli.js +29 -29
  52. package/dist/contradiction/index.js +4 -4
  53. package/dist/conversation-index/backend.js +2 -2
  54. package/dist/corpus-watermark.js +10 -10
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.js +51 -50
  57. package/dist/lcm/engine.js +2 -2
  58. package/dist/lcm/index.js +2 -2
  59. package/dist/namespaces/migrate.js +9 -9
  60. package/dist/namespaces/search.js +8 -8
  61. package/dist/operator-doctor-corpus.js +11 -11
  62. package/dist/operator-toolkit.js +12 -12
  63. package/dist/orchestrator.js +35 -34
  64. package/dist/search/factory.js +7 -7
  65. package/dist/search/index.js +7 -7
  66. package/dist/search/lancedb-backend.js +2 -2
  67. package/dist/search/meilisearch-backend.js +2 -2
  68. package/dist/search/orama-backend.js +2 -2
  69. package/dist/tool-scoped-memory.d.ts +47 -0
  70. package/dist/tool-scoped-memory.js +12 -0
  71. package/dist/tool-scoped-memory.js.map +1 -0
  72. package/dist/transfer/backup.js +2 -2
  73. package/dist/transfer/capsule-export.js +2 -2
  74. package/dist/transfer/capsule-import.js +2 -2
  75. package/package.json +2 -2
  76. package/src/extraction-redaction-rules.test.ts +53 -0
  77. package/src/index.ts +1 -1
  78. package/src/orchestration/extraction-persist.ts +31 -32
  79. package/src/tool-scoped-memory.test.ts +199 -0
  80. package/src/tool-scoped-memory.ts +190 -0
  81. package/dist/chunk-SPPL77NI.js.map +0 -1
  82. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  83. /package/dist/{chunk-VDX2J7OX.js.map → chunk-5W7CTZ2E.js.map} +0 -0
  84. /package/dist/{chunk-J6BYDVPL.js.map → chunk-6HXQ2YO5.js.map} +0 -0
  85. /package/dist/{chunk-6NPZ3FP2.js.map → chunk-7CCVK67W.js.map} +0 -0
  86. /package/dist/{chunk-77CNV7DY.js.map → chunk-BLAKS3CP.js.map} +0 -0
  87. /package/dist/{chunk-YFQMW2TH.js.map → chunk-DG2R7M7K.js.map} +0 -0
  88. /package/dist/{chunk-PTN6UAUA.js.map → chunk-ES36HTSC.js.map} +0 -0
  89. /package/dist/{chunk-ATUURSST.js.map → chunk-FL3AM5KE.js.map} +0 -0
  90. /package/dist/{chunk-I5DQ6KZK.js.map → chunk-FYO2GAZJ.js.map} +0 -0
  91. /package/dist/{chunk-MPH263X2.js.map → chunk-HULSU7PS.js.map} +0 -0
  92. /package/dist/{chunk-N2B56SIS.js.map → chunk-KJT3PZAR.js.map} +0 -0
  93. /package/dist/{chunk-7RZJWZ46.js.map → chunk-LEPOP46W.js.map} +0 -0
  94. /package/dist/{chunk-TITQYVKD.js.map → chunk-LRYAQGDR.js.map} +0 -0
  95. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-OWHERGF2.js.map} +0 -0
  96. /package/dist/{chunk-6JE3HGS4.js.map → chunk-OXBPHIKH.js.map} +0 -0
  97. /package/dist/{chunk-SIKCTPRK.js.map → chunk-QEPNZBBC.js.map} +0 -0
  98. /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
  99. /package/dist/{chunk-C4PEMCP4.js.map → chunk-RLOVRJZN.js.map} +0 -0
  100. /package/dist/{chunk-EHNENZHZ.js.map → chunk-RQBF54FC.js.map} +0 -0
  101. /package/dist/{chunk-RYHWZ52A.js.map → chunk-S2BBISHU.js.map} +0 -0
  102. /package/dist/{chunk-V2MVDDJF.js.map → chunk-SUZI6BHQ.js.map} +0 -0
  103. /package/dist/{chunk-DQEMWVMT.js.map → chunk-UVYI6VIX.js.map} +0 -0
  104. /package/dist/{chunk-YJ7PWEWI.js.map → chunk-WFYWGDM6.js.map} +0 -0
  105. /package/dist/{chunk-SFW2NABJ.js.map → chunk-Y6XRTQMV.js.map} +0 -0
  106. /package/dist/{chunk-6NNNURLK.js.map → chunk-Z54EM5YZ.js.map} +0 -0
@@ -82,6 +82,7 @@ import {
82
82
  stripCitationForTemplate,
83
83
  } from "../source-attribution.js";
84
84
  import { classifyMemoryKind } from "../himem.js";
85
+ import { shouldPromoteGlobalFactToShared, withholdToolScopedFromSharedNamespace } from "../tool-scoped-memory.js";
85
86
  import {
86
87
  buildBehaviorSignalsForMemory,
87
88
  dedupeBehaviorSignalsByMemoryAndHash,
@@ -394,6 +395,7 @@ export class ExtractionPersistCoordinator {
394
395
  // (pre-judge + write-loop) so no new scattered config.*Enabled read is
395
396
  // introduced (ratchet scatteredConfigFlagReads; see #1523).
396
397
  const namespacesEnabled = resolveNamespaceCapabilities(this.deps.config).namespaces;
398
+ const sourceConnector = sourceContext?.sourceConnector; // #2183 tool-scope guard input
397
399
  const promoteMemoryToProfileTargets = async (options: {
398
400
  sourceStorage: StorageManager;
399
401
  category: string;
@@ -607,11 +609,14 @@ export class ExtractionPersistCoordinator {
607
609
  observedAt?: string;
608
610
  eventTimeSource?: "extracted" | "assumed";
609
611
  source: string;
612
+ sourceConnector?: string;
613
+ procedureSteps?: ReadonlyArray<{ toolCall?: { kind?: string } }>;
610
614
  /** Claim-level provenance spans (issue #1575 PR 2). */
611
615
  sources?: ProvenanceSource[];
612
616
  provenance?: "verified" | "unverified" | "none";
613
617
  }): Promise<void> => {
614
618
  await promoteMemoryToProfileTargets(options);
619
+ if (lifecycleCaps.extractionScopeClassification && withholdToolScopedFromSharedNamespace(options)) return;
615
620
  if (
616
621
  !shouldPromoteToShared(
617
622
  this.deps.config,
@@ -629,7 +634,6 @@ export class ExtractionPersistCoordinator {
629
634
  this.deps.config.sharedNamespace,
630
635
  );
631
636
  // Dedup gate: canonicalize content before hashing.
632
- //
633
637
  // Issue #369 (PR #401): When inline attribution is enabled,
634
638
  // `applyInlineCitation` appends a timestamp-bearing marker (e.g.
635
639
  // `[Source: ..., ts=2026-04-11T...]`). Because the timestamp changes
@@ -639,7 +643,6 @@ export class ExtractionPersistCoordinator {
639
643
  // `fact.content`, which can already carry an inline citation (e.g. a
640
644
  // relayed or reprocessed fact). Strip any pre-existing citation so the
641
645
  // dedup key matches the hash stored from the original un-cited write.
642
- //
643
646
  // PR #402 round-6 (Fix #2 / chatgpt-codex P1 PRRT_kwDORJXyws56U74n):
644
647
  // Compute the enriched content before the hash-dedup check so the
645
648
  // lookup uses the same content that writeMemory will actually store.
@@ -652,7 +655,6 @@ export class ExtractionPersistCoordinator {
652
655
  // key order and casing are canonical — identical to the enrichment
653
656
  // applied by storage.writeMemory — preventing spurious hash misses
654
657
  // when attribute maps arrive with different insertion orders or casing.
655
- //
656
658
  // Fix #4 (Low PRRT_kwDORJXyws56VHth): sanitize the base content before
657
659
  // building dedupContent. writeMemory runs sanitizeMemoryContent on the
658
660
  // enriched body before hashing; if sanitization redacts the content to
@@ -660,7 +662,6 @@ export class ExtractionPersistCoordinator {
660
662
  // the raw form. Computing dedupContent from sanitized.text here ensures
661
663
  // the hash lookup and the normalizedIncoming comparison both use the
662
664
  // same content that writeMemory will actually store.
663
- //
664
665
  // Combined fix: strip any pre-existing citation FIRST to obtain
665
666
  // rawContent (the canonical body), then sanitize rawContent (not
666
667
  // options.content) when building dedupContent, so that citation
@@ -1350,20 +1351,18 @@ export class ExtractionPersistCoordinator {
1350
1351
  // + PBJEe/PBKAj).
1351
1352
  let factRedactionRules = preJudgeRedactionRules;
1352
1353
  let factNs = preRoutedNamespaceByFact[fi];
1353
- // #1713 (codex PRRT_kwDORJXyws6PBj5X): scope classification can route
1354
- // a scope=global fact to the shared namespace independent of routing
1355
- // rules. If so, the pre-judge filter must consult the shared
1356
- // namespace's rules too, or a never-store pattern under shared is
1357
- // missed at pre-judge and only caught at the write gate — letting the
1358
- // content reach the extraction judge/training path. Mirror the write
1359
- // loop's scope-routing conditions exactly.
1354
+ // #1713: predict the same target namespace the write loop will route
1355
+ // this global fact to (shared, via shouldPromoteGlobalFactToShared) so
1356
+ // the pre-judge redaction filter consults that namespace's rules.
1360
1357
  if (
1361
1358
  !factNs &&
1362
1359
  lifecycleCaps.extractionScopeClassification &&
1363
1360
  namespacesEnabled &&
1364
1361
  f.scope === "global" &&
1365
1362
  profileAllowsSharedWrites &&
1366
- this.deps.storageDirNamespace(storage.dir) !== this.deps.config.sharedNamespace
1363
+ this.deps.storageDirNamespace(storage.dir) !== this.deps.config.sharedNamespace &&
1364
+ shouldPromoteGlobalFactToShared({ scope: f.scope, content: f.content,
1365
+ sourceConnector, procedureSteps: f.procedureSteps })
1367
1366
  ) {
1368
1367
  factNs = this.deps.config.sharedNamespace;
1369
1368
  }
@@ -1611,14 +1610,9 @@ export class ExtractionPersistCoordinator {
1611
1610
  }
1612
1611
  }
1613
1612
 
1614
- // Scope-based namespace routing: when scope classification is enabled
1615
- // and the LLM tagged this fact as "global", route it to the shared
1616
- // namespace so cross-project knowledge is visible everywhere. Only
1617
- // applies when namespaces are enabled and the fact was not already
1618
- // routed to a specific namespace by a routing rule (routing rules
1619
- // that set an explicit namespace take precedence; category-only rules
1620
- // do not block scope routing). Rule 30: gated by
1621
- // extractionScopeClassificationEnabled.
1613
+ // Scope-based namespace routing: a `global` fact (scope classification
1614
+ // on, no explicit routing-rule namespace) is promoted to the shared
1615
+ // namespace unless the tool-scope guard withholds it (#2183).
1622
1616
  if (
1623
1617
  lifecycleCaps.extractionScopeClassification &&
1624
1618
  namespacesEnabled &&
@@ -1627,18 +1621,21 @@ export class ExtractionPersistCoordinator {
1627
1621
  ) {
1628
1622
  const currentNs = this.deps.storageDirNamespace(targetStorage.dir);
1629
1623
  if (currentNs !== this.deps.config.sharedNamespace && profileAllowsSharedWrites) {
1630
- try {
1631
- targetStorage = await this.deps.getStorageRouter().storageFor(
1632
- this.deps.config.sharedNamespace,
1633
- );
1634
- targetNamespaceName = this.deps.config.sharedNamespace;
1635
- log.debug(
1636
- `scope-routing: fact "${fact.content.slice(0, 60)}…" routed to shared namespace (scope=global)`,
1637
- );
1638
- } catch (scopeRouteErr) {
1639
- log.warn(
1640
- `scope-routing: failed to resolve shared namespace storage; writing to session namespace (fail-open): ${scopeRouteErr}`,
1641
- );
1624
+ if (shouldPromoteGlobalFactToShared({ scope: fact.scope, content: fact.content,
1625
+ sourceConnector, procedureSteps: fact.procedureSteps })) {
1626
+ try {
1627
+ targetStorage = await this.deps.getStorageRouter().storageFor(
1628
+ this.deps.config.sharedNamespace,
1629
+ );
1630
+ targetNamespaceName = this.deps.config.sharedNamespace;
1631
+ log.debug(
1632
+ `scope-routing: fact "${fact.content.slice(0, 60)}…" routed to shared namespace (scope=global)`,
1633
+ );
1634
+ } catch (scopeRouteErr) {
1635
+ log.warn(
1636
+ `scope-routing: failed to resolve shared namespace storage; writing to session namespace (fail-open): ${scopeRouteErr}`,
1637
+ );
1638
+ }
1642
1639
  }
1643
1640
  } else if (currentNs !== this.deps.config.sharedNamespace) {
1644
1641
  log.debug(
@@ -2404,6 +2401,7 @@ export class ExtractionPersistCoordinator {
2404
2401
  : {}),
2405
2402
  source: extractionWriteSource,
2406
2403
  ...(extractionSourceConnector ? { sourceConnector: extractionSourceConnector } : {}),
2404
+ ...(fact.procedureSteps && fact.procedureSteps.length ? { procedureSteps: fact.procedureSteps } : {}),
2407
2405
  ...(fact.sources && fact.sources.length > 0 ? { sources: fact.sources } : {}),
2408
2406
  ...(fact.provenance ? { provenance: fact.provenance } : {}),
2409
2407
  });
@@ -2704,6 +2702,7 @@ export class ExtractionPersistCoordinator {
2704
2702
  : {}),
2705
2703
  source: extractionWriteSource,
2706
2704
  ...(extractionSourceConnector ? { sourceConnector: extractionSourceConnector } : {}),
2705
+ ...(fact.procedureSteps && fact.procedureSteps.length ? { procedureSteps: fact.procedureSteps } : {}),
2707
2706
  ...(fact.sources && fact.sources.length > 0 ? { sources: fact.sources } : {}),
2708
2707
  ...(fact.provenance ? { provenance: fact.provenance } : {}),
2709
2708
  });
@@ -0,0 +1,199 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ referencesAgentSpecificTool,
6
+ shouldPromoteGlobalFactToShared,
7
+ withholdToolScopedFromSharedNamespace,
8
+ } from "./tool-scoped-memory.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // referencesAgentSpecificTool — table-driven, both polarities.
12
+ //
13
+ // Tuning bias (issue #2183): a FALSE POSITIVE is cheap (the fact stays in the
14
+ // narrower namespace — the documented safe default); a FALSE NEGATIVE is the
15
+ // cross-integration tool-collision bug. Detection leans permissive but must not
16
+ // fire on ordinary prose. Keywords and generic names are word-anchored so
17
+ // "tooling"/"commander" and the "search" inside "research" do not match.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const REF_CASES: Array<{ content: string; expected: boolean; note: string }> = [
21
+ // ---- MUST match --------------------------------------------------------
22
+ { content: "Prefer the search tool and provide a path when locating code.", expected: true, note: "generic name adjacent to 'tool'" },
23
+ { content: "Use the `read` tool before editing a file.", expected: true, note: "backticked token adjacent to 'tool'" },
24
+ { content: "The exec tool requires an absolute cwd.", expected: true, note: "generic name 'exec' adjacent to 'tool'" },
25
+ { content: "Use `search` when locating code.", expected: true, note: "imperative invocation + backticked identifier" },
26
+ { content: "Run `rg` before editing.", expected: true, note: "imperative invocation + backticked identifier" },
27
+ { content: "Use /search with a path.", expected: true, note: "slash-command syntax" },
28
+ // ---- MUST NOT match ----------------------------------------------------
29
+ { content: "User prefers dark mode in all editors", expected: false, note: "portable preference; no tool/command context" },
30
+ { content: "PostgreSQL 15 requires the uuid-ossp extension for gen_random_uuid()", expected: false, note: "portable framework knowledge" },
31
+ { content: "Magento 2.4.8 has a race condition in checkout", expected: false, note: "'condition' is not a keyword; 'checkout' is not generic" },
32
+ { content: "The user reads documentation before starting a task", expected: false, note: "the bare verb 'reads' is not a tool reference" },
33
+ { content: "Search tooling improves developer productivity", expected: false, note: "'tool' must not match the prefix of 'tooling'" },
34
+ { content: "Research tool improves accuracy", expected: false, note: "'search' must not match inside 'research'" },
35
+ ];
36
+
37
+ for (const { content, expected, note } of REF_CASES) {
38
+ test(`referencesAgentSpecificTool: ${expected ? "detects" : "ignores"} "${content.slice(0, 44)}…" (${note})`, () => {
39
+ assert.equal(referencesAgentSpecificTool(content), expected, note);
40
+ });
41
+ }
42
+
43
+ const EXTRA_POSITIVE: string[] = [
44
+ "The `checkout` command switches branches.",
45
+ "The browser tool navigates to the URL first.",
46
+ "Always run the memory tool after a context switch.",
47
+ "the tool named search returns repo hits",
48
+ "Pi exposes a search tool; OpenClaw exposes a same-named web tool.",
49
+ "Invoke `memory_store` to persist the reflection.",
50
+ "Use search when locating implementation",
51
+ "Run rg before editing",
52
+ "Call memory_store to persist the result",
53
+ "Run grep before editing",
54
+ "Use curl to fetch the URL",
55
+ "The memory_store tool persists results",
56
+ "Use the grep command to search files",
57
+ "Call memory_store() to persist the result",
58
+ "Use search() when locating code",
59
+ "Run rg() to find symbols",
60
+ "Use --force when reinstalling",
61
+ "The --format cli flag selects JSON",
62
+ "Run rg --files before editing",
63
+ "Use grep -n TODO when reviewing",
64
+ "Run `rg --files` before editing",
65
+ "The Bash tool executes commands",
66
+ "Use the Edit tool before saving",
67
+ "The WebFetch tool retrieves pages",
68
+ "Run git status before committing",
69
+ "Use npm install to add dependencies",
70
+ "The Bash Tool executes commands",
71
+ "Run git checkout -b feature before editing",
72
+ "Run `git status` before committing",
73
+ "Use `npm install` to add dependencies",
74
+ "Run `git checkout -b feature` before editing",
75
+ "Use edit before saving",
76
+ "Use bashExecution before editing",
77
+ "The bashExecution tool executes commands",
78
+ "Call bashExecution() to persist data",
79
+ "Use /remnic flush before reset",
80
+ "The /remnic command controls memory",
81
+ "Use `/remnic flush` before reset",
82
+ "Use `search --path src` when locating code",
83
+ ];
84
+ for (const content of EXTRA_POSITIVE) {
85
+ test(`referencesAgentSpecificTool detects: "${content.slice(0, 44)}…"`, () => {
86
+ assert.equal(referencesAgentSpecificTool(content), true);
87
+ });
88
+ }
89
+
90
+ const EXTRA_NEGATIVE: string[] = [
91
+ "Git is a useful tool for version control.",
92
+ "The command line is faster than the GUI for this workflow.",
93
+ "She has a good command of the deployment process.",
94
+ "Communication is the main tool of a leader.",
95
+ "PostgreSQL read replicas lag under heavy load.",
96
+ "Memory usage spikes during compaction.",
97
+ "The browser caches static assets aggressively.",
98
+ "Reading long files is slow.",
99
+ "He writes detailed notes after every meeting.",
100
+ "Leadership requires command of the subject matter.",
101
+ "Writers use memory and emotion to craft stories.",
102
+ "I use search engines daily for research.",
103
+ "Use memory wisely and rest often.",
104
+ "Visit https://example.com/search for docs.",
105
+ "The service reads configuration from /etc/remnic/config.json",
106
+ "Logs live under /var/log/remnic",
107
+ "Restore the backup from /tmp/dump.sql",
108
+ "Use /etc/remnic/config.json for configuration",
109
+ "The service should use /health for readiness checks",
110
+ "Use PostgreSQL for durable storage",
111
+ "Use TypeScript in new packages",
112
+ "Run migrations after deployment",
113
+ "Use Redis for caching",
114
+ "Use 'least privilege' when designing authorization",
115
+ 'Run "database migrations" after deployment',
116
+ "Use SQL for analytics",
117
+ "Use API for integration",
118
+ "Use Go for backend services",
119
+ "Use `/etc/remnic/config.json` for configuration",
120
+ "Run `/var/log/remnic` cleanup",
121
+ "Call me before you leave",
122
+ "Use it to simplify the workflow",
123
+ "Run in before deploying",
124
+ "Use 2024-2025 data for the baseline",
125
+ "The tool — a recent addition — is optional",
126
+ "Use `TypeScript` for new packages",
127
+ "Use `config.json` for configuration",
128
+ "Use `/health` for readiness checks",
129
+ "Writers use edit passes",
130
+ "Use macOS for development",
131
+ "Use iOS for mobile apps",
132
+ "Use eBay for purchases",
133
+ "Automation tool reduces repetitive work",
134
+ "Encryption tool selection should follow the threat model",
135
+ "Monitoring tool availability affects reliability",
136
+ "Use user_id in API responses",
137
+ "Use database_name for the connection",
138
+ "Use feature_flag when rolling out changes",
139
+ "Use least-privilege when designing authorization",
140
+ "Use TypeScript-first for new packages",
141
+ ];
142
+ for (const content of EXTRA_NEGATIVE) {
143
+ test(`referencesAgentSpecificTool ignores prose: "${content.slice(0, 44)}…"`, () => {
144
+ assert.equal(referencesAgentSpecificTool(content), false);
145
+ });
146
+ }
147
+
148
+ test("referencesAgentSpecificTool: empty/non-string input is false (no throw)", () => {
149
+ assert.equal(referencesAgentSpecificTool(""), false);
150
+ assert.equal(referencesAgentSpecificTool(" "), false);
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // shouldPromoteGlobalFactToShared — composed scope-routing predicate.
155
+ // ---------------------------------------------------------------------------
156
+
157
+ const PROMOTION_CASES: Array<{ name: string; args: { scope: string | null | undefined; content: string; sourceConnector?: string; procedureSteps?: ReadonlyArray<{ intent?: string; toolCall?: { kind?: string; signature?: string } }> }; expected: boolean }> = [
158
+ { name: "global portable fact, no connector -> promote", args: { scope: "global", content: "User prefers dark mode in all editors" }, expected: true },
159
+ { name: "global tool-scoped fact WITH connector -> withhold (#2183)", args: { scope: "global", content: "Prefer the search tool when locating code.", sourceConnector: "pi" }, expected: false },
160
+ { name: "global tool-scoped fact, no knob -> withheld when connector known", args: { scope: "global", content: "The exec tool requires an absolute cwd.", sourceConnector: "openclaw" }, expected: false },
161
+ { name: "global tool-scoped fact WITHOUT connector -> promote (unattributed)", args: { scope: "global", content: "Prefer the search tool when locating code." }, expected: true },
162
+ { name: "project-scoped fact -> never promote via this path", args: { scope: "project", content: "Prefer the search tool when locating code.", sourceConnector: "pi" }, expected: false },
163
+ { name: "empty connector string is treated as unknown -> promote", args: { scope: "global", content: "Prefer the search tool when locating code.", sourceConnector: "" }, expected: true },
164
+ { name: "global portable-title procedure with tool-bearing steps + connector -> withhold (#2183 P2, scope-routing)", args: { scope: "global", content: "Workflow for locating implementation", sourceConnector: "pi", procedureSteps: [{ intent: "find the symbol", toolCall: { kind: "search", signature: "search('foo')" } }, { intent: "open the file", toolCall: { kind: "read", signature: "read('bar')" } }] }, expected: false },
165
+ ];
166
+ for (const { name, args, expected } of PROMOTION_CASES) {
167
+ test(`shouldPromoteGlobalFactToShared: ${name}`, () => {
168
+ assert.equal(shouldPromoteGlobalFactToShared(args), expected);
169
+ });
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // withholdToolScopedFromSharedNamespace — the SINGLE primitive every shared-
174
+ // namespace promotion path consults. Includes structured procedure tool
175
+ // identity (issue #2183 P2): a portable title + tool-bearing steps withholds.
176
+ // ---------------------------------------------------------------------------
177
+
178
+ const WITHHOLD_CASES: Array<{
179
+ name: string;
180
+ args: { content: string; sourceConnector?: string; procedureSteps?: ReadonlyArray<{ intent?: string; toolCall?: { kind?: string; signature?: string } }> };
181
+ expected: boolean;
182
+ }> = [
183
+ { name: "tool-scoped + connector -> withhold", args: { content: "Prefer the search tool when locating code.", sourceConnector: "pi" }, expected: true },
184
+ { name: "tool-scoped + empty connector -> do not withhold", args: { content: "Prefer the search tool when locating code.", sourceConnector: "" }, expected: false },
185
+ { name: "tool-scoped + whitespace-only connector -> do not withhold", args: { content: "Prefer the search tool when locating code.", sourceConnector: " " }, expected: false },
186
+ { name: "tool-scoped + no connector -> do not withhold", args: { content: "Prefer the search tool when locating code." }, expected: false },
187
+ { name: "portable + connector -> do not withhold", args: { content: "User prefers dark mode in all editors", sourceConnector: "pi" }, expected: false },
188
+ { name: "portable title + connector + tool-bearing steps -> withhold (#2183 P2)", args: { content: "When locating implementation", sourceConnector: "pi", procedureSteps: [{ intent: "find", toolCall: { kind: "search", signature: "search('foo')" } }] }, expected: true },
189
+ { name: "portable title + connector + steps without toolCall -> do not withhold", args: { content: "When locating implementation", sourceConnector: "pi", procedureSteps: [{ intent: "read the docs" }] }, expected: false },
190
+ { name: "portable title + connector + steps with empty kind -> do not withhold", args: { content: "When locating implementation", sourceConnector: "pi", procedureSteps: [{ intent: "x", toolCall: { kind: " ", signature: "y" } }] }, expected: false },
191
+ { name: "tool-bearing steps but NO connector -> do not withhold (unattributed)", args: { content: "When locating implementation", procedureSteps: [{ intent: "find", toolCall: { kind: "search", signature: "search('foo')" } }] }, expected: false },
192
+ { name: "portable title + intent-only tool step + connector -> withhold (#2183 intent text)", args: { content: "Workflow for locating implementation", sourceConnector: "pi", procedureSteps: [{ intent: "Run grep before editing" }] }, expected: true },
193
+ { name: "portable title + all-portable steps + connector -> do not withhold", args: { content: "Workflow for code review", sourceConnector: "pi", procedureSteps: [{ intent: "Read the changes carefully" }, { intent: "Approve the work" }] }, expected: false },
194
+ ];
195
+ for (const { name, args, expected } of WITHHOLD_CASES) {
196
+ test(`withholdToolScopedFromSharedNamespace: ${name}`, () => {
197
+ assert.equal(withholdToolScopedFromSharedNamespace(args), expected);
198
+ });
199
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Tool-scoped memory guard (issue #2183).
3
+ *
4
+ * A tool-related fact extracted from a specific agent integration carries an
5
+ * UNQUALIFIED tool name — "use the search tool" — with no agent identity
6
+ * attached (the conversation handed to the extraction LLM is rendered as
7
+ * `[role] content` only). Promoting such a fact to the shared namespace lets a
8
+ * DIFFERENT integration that exposes a same-named but incompatible tool (Pi
9
+ * `search` = repo code search vs OpenClaw `search` = web search) consume it.
10
+ *
11
+ * `referencesAgentSpecificTool` flags fact text that references a specific
12
+ * tool/command invocation rather than portable knowledge. The promotion guard
13
+ * in `orchestration/extraction-persist.ts` uses it — when the producing
14
+ * integration is known (`sourceConnector`) — to keep such a fact in that
15
+ * integration's own namespace.
16
+ *
17
+ * Tuning bias: a FALSE POSITIVE is cheap — the fact stays in the narrower
18
+ * namespace, the documented safe default. A FALSE NEGATIVE is the actual
19
+ * cross-integration collision bug, so detection leans permissive — but it must
20
+ * not fire on ordinary prose.
21
+ */
22
+
23
+ const BT = "`";
24
+
25
+ // Generic tool names + host-tool names emitted by the agent adapters.
26
+ // Evidence: "edit" — Pi messages.test.ts:43 ({ type: "toolCall", name: "edit" }).
27
+ // "bash" — Pi messages.test.ts:70 (bashExecution tool rendering). "glob",
28
+ // "webfetch", "task" — standard Claude Code tools (Pi wraps Claude Code).
29
+ const GENERIC_TOOL_NAMES = [
30
+ "read", "write", "search", "fetch", "browser", "exec", "shell", "memory",
31
+ "edit", "bash", "glob", "webfetch", "task",
32
+ "remnic",
33
+ ];
34
+ const KNOWN_CLI_NAMES = [
35
+ "grep", "curl", "git", "npm", "pnpm", "sed", "awk", "jq", "cat", "ssh",
36
+ "docker", "kubectl", "make", "rg", "ls", "cd",
37
+ ];
38
+ const GENERIC_NAMES_ALT = GENERIC_TOOL_NAMES.join("|");
39
+ const KNOWN_NAMES_ALT = [...GENERIC_TOOL_NAMES, ...KNOWN_CLI_NAMES].join("|");
40
+ const CLI_NAMES_ALT = KNOWN_CLI_NAMES.join("|");
41
+
42
+ const FS_ROOTS = "(?:etc|var|usr|tmp|home|opt|srv|proc|dev)";
43
+ const FLAG = "(?:--|-)[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*\\b";
44
+ const FLAG_NAKED = "(?:--|-)[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*";
45
+ const OPCODE = "[A-Za-z0-9_./-]{1,20}";
46
+
47
+ const NO_SLASH_TOKEN = "[A-Za-z0-9_.-]{1,60}";
48
+ const SLASH_CMD_TOKEN = "/(?:" + GENERIC_NAMES_ALT + ")\\b";
49
+ const QUOTED_CONTENT = "(?:" + NO_SLASH_TOKEN + "|" + SLASH_CMD_TOKEN + ")";
50
+ const QUOTED_PERMISSIVE =
51
+ "(?:" + BT + QUOTED_CONTENT + BT + "|\"" + QUOTED_CONTENT + "\"|'" + QUOTED_CONTENT + "')";
52
+
53
+ const SNAKE = "(?:[a-z][a-z0-9]*(?:_[a-z0-9]+)+)\\b";
54
+ const SNAKE_NON_DATA = "(?:[a-z][a-z0-9]*(?:_[a-z0-9]+)*(?:_(?!id\\b|name\\b|flag\\b|count\\b|at\\b|url\\b|key\\b|type\\b)[a-z0-9]+))\\b";
55
+ const SNAKE_KEBAB = "(?:[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+)\\b";
56
+
57
+ const KNOWN_NAMES_CI = "\\b(?:" + KNOWN_NAMES_ALT + ")\\b";
58
+ const CLI_NAMES_CI = "\\b(?:" + CLI_NAMES_ALT + ")\\b";
59
+ const KEYWORD = "\\b(?:mcp[ _]tool|slash[ _]command|cli[ _]flag|subcommand|tool|command)\\b";
60
+
61
+ const IDENT = "(?:" + QUOTED_PERMISSIVE + "|" + KNOWN_NAMES_CI + "|" + SNAKE_KEBAB + "|" + FLAG + ")";
62
+
63
+ const IMM = "[\\s'\"" + BT + "]{1,4}";
64
+ const CONN = "\\s+(?:named|called|aka)\\s+";
65
+ const VERB = "\\b(?:use|run|call|invoke)\\b";
66
+ const SLASH_GENERIC = "/(?:" + GENERIC_NAMES_ALT + ")\\b";
67
+ const CLAUSE_BOUNDARY = "(?:$|[.,;(:]|(?:when|before|after|to|with|for|on|in|if|unless|whenever)\\b)";
68
+ const ARGS = "(?:\\s+" + FLAG + "(?:\\s+" + OPCODE + "){0,4})?";
69
+ const CLI_ARG_TOKEN = "(?:[a-z][a-z0-9-]{0,15}|" + FLAG_NAKED + "|" + OPCODE + ")";
70
+ const CLI_ARG_RUN = "(?:\\s+" + CLI_ARG_TOKEN + "){1,4}";
71
+
72
+ // Shared: a CLI tool or slash command optionally followed by a bounded
73
+ // argument run. Used by BOTH the quoted and unquoted invocation arms so
74
+ // quoting cannot diverge from unquoted again (#2183 round 20).
75
+ const CMD_WITH_ARGS = "(?:(?:" + CLI_NAMES_CI + "|" + SLASH_GENERIC + ")" + CLI_ARG_RUN + ")";
76
+ const QSTRICT_CONTENT =
77
+ "(?:" + KNOWN_NAMES_ALT + "|[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+" + "|" + FLAG_NAKED + "|" + SLASH_CMD_TOKEN +
78
+ "|" + CMD_WITH_ARGS + "|(?:" + KNOWN_NAMES_ALT + ")" + CLI_ARG_RUN + ")";
79
+ const QUOTED_STRICT =
80
+ "(?:" + BT + QSTRICT_CONTENT + BT + "|\"" + QSTRICT_CONTENT + "\"|'" + QSTRICT_CONTENT + "')";
81
+
82
+ const INVOCATION_FLAG_ARGS =
83
+ VERB + "\\s+(?:" + QUOTED_STRICT + "|" + KNOWN_NAMES_CI + "|" + SNAKE_NON_DATA + "|" + SLASH_GENERIC + "|" + FLAG + ")" + ARGS + "(?=\\s*" + CLAUSE_BOUNDARY + ")";
84
+ const INVOCATION_CLI_ARGS =
85
+ VERB + "\\s+" + CMD_WITH_ARGS + "(?=\\s*" + CLAUSE_BOUNDARY + ")";
86
+ const INVOCATION = "(?:" + INVOCATION_FLAG_ARGS + "|" + INVOCATION_CLI_ARGS + ")";
87
+
88
+ const TOOL_REFERENCE_CI = new RegExp(
89
+ "(?:" +
90
+ IDENT + IMM + KEYWORD + "|" + KEYWORD + IMM + IDENT + "|" +
91
+ KEYWORD + CONN + IDENT + "|" + IDENT + CONN + KEYWORD + "|" +
92
+ INVOCATION +
93
+ ")",
94
+ "i",
95
+ );
96
+
97
+ function hasCapitalisedToolName(content: string): boolean {
98
+ // Structural discriminator: a capitalised identifier beside a tool/command
99
+ // keyword qualifies ONLY when preceded by an article or invocation verb (not
100
+ // sentence-initial), or connected via named/called/aka. This replaces the
101
+ // blocklist approach — a structural rule beats a growing STOP_WORDS set.
102
+ for (const re of [
103
+ /(?:the|a|an|this|that|these|those|use|run|call|invoke)\s+([A-Za-z][A-Za-z0-9]+)\b[\s'`<>]{0,4}\b(?:tool|command|subcommand)\b/gi,
104
+ /\b(?:tool|command|subcommand)\b\s+(?:named|called|aka)\s+([A-Za-z][A-Za-z0-9]+)\b/gi,
105
+ ]) {
106
+ re.lastIndex = 0;
107
+ let match;
108
+ while ((match = re.exec(content)) !== null) {
109
+ const w = match[1];
110
+ if (w && w[0] >= "A" && w[0] <= "Z") return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function hasCamelCaseToolName(content: string): boolean {
117
+ // Case-sensitive: camelCase (lowercase start, interior uppercase) is a common
118
+ // host-tool naming convention (bashExecution, memoryStore, webFetch). Ordinary
119
+ // English words are never camelCase, and PascalCase (PostgreSQL, TypeScript)
120
+ // starts uppercase so is handled by hasCapitalisedToolName instead.
121
+ const camelRe = /\b([a-z]{3,}[a-z0-9]*[A-Z][a-z][A-Za-z0-9]*)\b/g;
122
+ let m;
123
+ while ((m = camelRe.exec(content)) !== null) {
124
+ const token = m[1];
125
+ const start = m.index!;
126
+ const end = start + token.length;
127
+ const before = content.slice(Math.max(0, start - 20), start);
128
+ const after = content.slice(end);
129
+ // Invocation: preceded by a verb, followed by a clause boundary or call syntax.
130
+ if (/(?:^|\s)(?:use|run|call|invoke)\s*$/i.test(before) &&
131
+ /^(?:\s*(?:$|[.,;(:]|(?:when|before|after|to|with|for|on|in|if|unless|whenever)\b))/i.test(after)) {
132
+ return true;
133
+ }
134
+ // Keyword-adjacent: camelCase beside a tool/command keyword.
135
+ if (/^(?:\s*(?:tool|command|subcommand)\b)/i.test(after) ||
136
+ /(?:tool|command|subcommand)\s*$/i.test(before)) {
137
+ return true;
138
+ }
139
+ }
140
+ return false;
141
+ }
142
+
143
+ export function referencesAgentSpecificTool(content: string): boolean {
144
+ if (typeof content !== "string" || content.length === 0) return false;
145
+ return TOOL_REFERENCE_CI.test(content) || hasCapitalisedToolName(content) || hasCamelCaseToolName(content);
146
+ }
147
+
148
+ export interface ToolScopeWithholdInputs {
149
+ content: string;
150
+ sourceConnector?: string;
151
+ procedureSteps?: ReadonlyArray<{ intent?: string; expectedOutcome?: string; toolCall?: { kind?: string } }>;
152
+ }
153
+
154
+ export function withholdToolScopedFromSharedNamespace({
155
+ content,
156
+ sourceConnector,
157
+ procedureSteps,
158
+ }: ToolScopeWithholdInputs): boolean {
159
+ if (typeof sourceConnector !== "string" || sourceConnector.trim().length === 0) return false;
160
+ if (referencesAgentSpecificTool(content)) return true;
161
+ if (
162
+ procedureSteps?.some((s) => {
163
+ const kind = s.toolCall?.kind;
164
+ if (typeof kind === "string" && kind.trim().length > 0) return true;
165
+ const stepText = [s.intent, s.expectedOutcome]
166
+ .filter((t): t is string => typeof t === "string" && t.length > 0)
167
+ .join(" ");
168
+ return stepText.length > 0 && referencesAgentSpecificTool(stepText);
169
+ })
170
+ ) {
171
+ return true;
172
+ }
173
+ return false;
174
+ }
175
+
176
+ export interface GlobalFactPromotionInputs {
177
+ scope: string | null | undefined;
178
+ content: string;
179
+ sourceConnector?: string;
180
+ procedureSteps?: ReadonlyArray<{ toolCall?: { kind?: string } }>;
181
+ }
182
+
183
+ export function shouldPromoteGlobalFactToShared(inputs: GlobalFactPromotionInputs): boolean {
184
+ if (inputs.scope !== "global") return false;
185
+ return !withholdToolScopedFromSharedNamespace({
186
+ content: inputs.content,
187
+ sourceConnector: inputs.sourceConnector,
188
+ procedureSteps: inputs.procedureSteps,
189
+ });
190
+ }