total-recall-brain 3.41.4 → 3.41.6

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 (147) hide show
  1. package/bin/total-recall.mjs +10 -6
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/index-4tGQfTa2.js +5605 -0
  4. package/frontend/dist/assets/index-DtwsDyFM.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/package.json +11 -0
  7. package/frontend/src/App.tsx +47 -122
  8. package/frontend/src/components/plugins/plugin-frame.ts +1 -0
  9. package/frontend/src/index.css +152 -86
  10. package/frontend/src/pages/HelpPage.tsx +13 -10
  11. package/frontend/src/pages/MemoryPage.tsx +28 -30
  12. package/frontend/src/pages/NetworkPage.tsx +3 -3
  13. package/frontend/src/pages/PluginsPage.tsx +6 -5
  14. package/frontend/src/pages/RulesPage.tsx +16 -12
  15. package/package.json +4 -3
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
  17. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +4 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
  19. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +4 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3121 -33
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +4 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3122 -34
  23. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +4 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3127 -49
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -2
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +1 -1
  27. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +136 -7
  28. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +135 -26
  29. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +3121 -33
  30. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +5 -2
  31. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +228 -9
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +24 -30
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +29 -16
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +73 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +182 -365
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +33 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +40 -14
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +4 -4
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/agy.mjs +187 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/claude.mjs +275 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cline.mjs +132 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/codex.mjs +187 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cursor.mjs +158 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/devin.mjs +157 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/dsh.mjs +183 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/grok.mjs +132 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +173 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +87 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/openclaw.mjs +173 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/pi.mjs +129 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +12 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/json-reply.mjs +22 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +72 -70
  54. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-catalog.mjs +195 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +11 -0
  56. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +36 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +169 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +136 -346
  59. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +1 -1
  60. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/ssss-store.mjs +20 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/telemetry.mjs +43 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +156 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +117 -35
  64. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +51 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +398 -57
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +304 -46
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +367 -41
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +5 -2
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +33 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +2 -2
  71. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  74. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  75. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +151 -4
  76. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  77. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  78. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +899 -662
  79. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  80. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/mermaid-diagrams/SKILL.md +44 -0
  81. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/write-connector/SKILL.md +56 -0
  82. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3097 -81
  83. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +4 -1
  84. package/src/cli/agent.mjs +12 -1
  85. package/src/cli/compact.mjs +108 -0
  86. package/src/cli/connect.mjs +56 -1
  87. package/src/cli/context.mjs +55 -23
  88. package/src/cli/doctor.mjs +5 -2
  89. package/src/cli/forget.mjs +2 -0
  90. package/src/cli/harness.mjs +62 -50
  91. package/src/cli/memory-entry.mjs +2 -2
  92. package/src/cli/preflight.mjs +152 -16
  93. package/src/cli/release.mjs +386 -0
  94. package/src/cli/remember.mjs +19 -1
  95. package/src/cli/rules.mjs +54 -12
  96. package/src/cli/startup.mjs +1 -1
  97. package/src/cli/status.mjs +12 -2
  98. package/src/core/claude-hooks.mjs +41 -3
  99. package/src/core/cli-commands.mjs +2 -0
  100. package/src/core/cli-semantic-index.mjs +3 -2
  101. package/src/core/conflict-detector.mjs +23 -12
  102. package/src/core/context-compactor.mjs +145 -0
  103. package/src/core/context-policy.mjs +20 -0
  104. package/src/core/duplicate-guard.mjs +350 -0
  105. package/src/core/meta-harness.mjs +31 -11
  106. package/src/core/preflight-gate.mjs +2 -2
  107. package/src/core/preflight-receipt.mjs +29 -1
  108. package/src/core/scaffold-plugins.mjs +7 -0
  109. package/src/core/stdio-flush.mjs +23 -0
  110. package/src/core/surface.mjs +33 -10
  111. package/src/server/invoke-durability.mjs +39 -0
  112. package/src/server/invoke-write-child.mjs +25 -0
  113. package/src/server/routes/invoke.mjs +7 -0
  114. package/src/server/routes/plugins.mjs +2 -2
  115. package/frontend/dist/assets/index-BIQmqDKP.css +0 -1
  116. package/frontend/dist/assets/index-CRzjQLu7.js +0 -5631
  117. package/frontend/src/components/DaemonLogsTab.tsx +0 -54
  118. package/frontend/src/components/ResearchAgendaTab.tsx +0 -712
  119. package/frontend/src/components/TaskQueueTab.tsx +0 -193
  120. package/frontend/src/components/VoiceInput.tsx +0 -112
  121. package/frontend/src/pages/ChatPage.tsx +0 -1059
  122. package/frontend/src/pages/CollabPage.tsx +0 -470
  123. package/frontend/src/pages/DesignDocsPage.tsx +0 -621
  124. package/frontend/src/pages/FilesPage.tsx +0 -393
  125. package/frontend/src/pages/HealthPage.tsx +0 -359
  126. package/frontend/src/pages/InboxPage.tsx +0 -83
  127. package/frontend/src/pages/InstructionsPage.tsx +0 -583
  128. package/frontend/src/pages/IntegrationsPage.tsx +0 -761
  129. package/frontend/src/pages/MeshPage.css +0 -58
  130. package/frontend/src/pages/MeshPage.tsx +0 -1219
  131. package/frontend/src/pages/OkfPage.tsx +0 -535
  132. package/frontend/src/pages/SandboxPage.tsx +0 -113
  133. package/frontend/src/pages/SkillsPage.tsx +0 -1994
  134. package/frontend/src/pages/TasksPage.tsx +0 -427
  135. package/frontend/src/pages/UsagePage.tsx +0 -702
  136. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/README.md +0 -65
  137. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/cli.mjs +0 -76
  138. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/package.json +0 -19
  139. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -266
  140. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/adapters/dsh.mjs +0 -446
  141. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/documentation.mjs +0 -152
  142. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/executor.mjs +0 -71
  143. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/runtime.mjs +0 -36
  144. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/setup.mjs +0 -35
  145. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/worker.mjs +0 -175
  146. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +0 -32
  147. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +0 -575
@@ -14,6 +14,7 @@ export const CLI_COMMANDS = {
14
14
  restore: 'restore.mjs',
15
15
  upgrade: 'upgrade.mjs',
16
16
  update: 'update.mjs',
17
+ release: 'release.mjs',
17
18
  friction: 'friction.mjs',
18
19
  chat: 'chat.mjs',
19
20
  status: 'status.mjs',
@@ -22,6 +23,7 @@ export const CLI_COMMANDS = {
22
23
  edit: 'edit.mjs',
23
24
  recall: 'recall.mjs',
24
25
  context: 'context.mjs',
26
+ compact: 'compact.mjs',
25
27
  preflight: 'preflight.mjs',
26
28
  'tr-preflight': 'preflight.mjs',
27
29
  'context-stats': 'context-stats.mjs',
@@ -508,13 +508,14 @@ export async function buildCliSemanticIndex({
508
508
  skillsDir = null,
509
509
  projectRoot = process.cwd(),
510
510
  force = false,
511
- semantic = true
511
+ semantic = true,
512
+ harvest = harvestAllCliVerbs
512
513
  } = {}) {
513
514
  if (!derivedDir) {
514
515
  throw new Error('derivedDir is required to build CLI semantic index');
515
516
  }
516
517
 
517
- const harvested = harvestAllCliVerbs({ projectRoot, skillsDir });
518
+ const harvested = harvest({ projectRoot, skillsDir });
518
519
  const existing = force ? null : loadCliSemanticIndex(derivedDir);
519
520
  const existingMap = new Map();
520
521
 
@@ -291,17 +291,6 @@ export function autoResolveConflict(conflict) {
291
291
  const newIsProtected = isProtectedInvariant(newNode);
292
292
  const existingIsProtected = isProtectedInvariant(existingNode);
293
293
 
294
- // Tier 0: New user-created node always wins and auto-supersedes
295
- if (newAuth === 'user') {
296
- return {
297
- resolved: true,
298
- action: 'supersede',
299
- winner: newNode.slug,
300
- loser: existingNode.slug,
301
- reason: `User-created candidate node '${newNode.slug}' automatically supersedes existing node '${existingNode.slug}' to keep the developer in the loop within the IDE context.`,
302
- };
303
- }
304
-
305
294
  // Tier 1: Both are protected invariants — require human review
306
295
  if (newIsProtected && existingIsProtected) {
307
296
  return {
@@ -318,7 +307,7 @@ export function autoResolveConflict(conflict) {
318
307
  action: 'keep',
319
308
  winner: existingNode.slug,
320
309
  loser: newNode.slug,
321
- reason: `Existing node '${existingNode.slug}' is a protected invariant. New node auto-rejected.`,
310
+ reason: `Existing node '${existingNode.slug}' is a protected invariant. Non-protected node rejected.`,
322
311
  };
323
312
  }
324
313
  if (newIsProtected && !existingIsProtected) {
@@ -331,6 +320,28 @@ export function autoResolveConflict(conflict) {
331
320
  };
332
321
  }
333
322
 
323
+ // Tier 0: New user-created node always wins against machine-generated node
324
+ if (newAuth === 'user' && existingAuth === 'machine') {
325
+ return {
326
+ resolved: true,
327
+ action: 'supersede',
328
+ winner: newNode.slug,
329
+ loser: existingNode.slug,
330
+ reason: `User-created candidate node '${newNode.slug}' automatically supersedes existing node '${existingNode.slug}' to keep the developer in the loop within the IDE context.`,
331
+ };
332
+ }
333
+
334
+ // Tier 0b: New user-created node against other user node (non-protected)
335
+ if (newAuth === 'user' && existingAuth === 'user') {
336
+ return {
337
+ resolved: true,
338
+ action: 'supersede',
339
+ winner: newNode.slug,
340
+ loser: existingNode.slug,
341
+ reason: `User-created candidate node '${newNode.slug}' automatically supersedes existing user node '${existingNode.slug}' to reflect latest intent.`,
342
+ };
343
+ }
344
+
334
345
  // Tier 3: User vs machine — user intent always wins
335
346
  if (newAuth === 'user' && existingAuth === 'machine') {
336
347
  return {
@@ -0,0 +1,145 @@
1
+ /** Budgeted task capsules: rank rules, keep verbatim text, and account for every rule left out. */
2
+
3
+ import { estimateTokens } from './context-policy.mjs';
4
+ import { cosineSimilarity } from './embeddings.mjs';
5
+
6
+ const DUPLICATE_SIMILARITY = 0.92;
7
+ const REFERENCE_SHARE = 0.4;
8
+ const TOP_FULL_SHARE = 0.3;
9
+ const SUMMARY_MAX_CHARS = 200;
10
+ const SUMMARY_MIN_CHARS = 80;
11
+ // A period here does not end a sentence.
12
+ const SENTENCE_END = /(?<!\be\.g)(?<!\bi\.e)(?<!\bvs)[.!?](?=\s|$)/g;
13
+
14
+ /** The leading sentences of a rule, verbatim. Never rewords, because rewording can change a constraint. */
15
+ export function summarizeRule(text) {
16
+ const flat = String(text || '').replace(/^#+\s.*$/gm, ' ').replace(/\s+/g, ' ').trim();
17
+ if (flat.length <= SUMMARY_MAX_CHARS) return flat;
18
+ let end = 0;
19
+ for (const match of flat.matchAll(SENTENCE_END)) {
20
+ const candidate = match.index + 1;
21
+ if (candidate > SUMMARY_MAX_CHARS) break;
22
+ end = candidate;
23
+ if (end >= SUMMARY_MIN_CHARS) return flat.slice(0, end);
24
+ }
25
+ if (end) return flat.slice(0, end);
26
+ const cut = flat.lastIndexOf(' ', SUMMARY_MAX_CHARS);
27
+ return `${flat.slice(0, cut > 0 ? cut : SUMMARY_MAX_CHARS)}…`;
28
+ }
29
+
30
+ /** Relevance alone orders the text; ties fall to importance, then slug, so output is stable. */
31
+ function rank(items) {
32
+ return [...items].sort((a, b) =>
33
+ (b.score || 0) - (a.score || 0) ||
34
+ (b.importance || 3) - (a.importance || 3) ||
35
+ a.slug.localeCompare(b.slug));
36
+ }
37
+
38
+ function findDuplicates(ranked) {
39
+ const duplicateOf = new Map();
40
+ const normalize = text => String(text).toLowerCase().replace(/\s+/g, ' ').trim();
41
+ ranked.forEach((item, i) => {
42
+ if (item.priority === 'absolute') return;
43
+ for (const earlier of ranked.slice(0, i)) {
44
+ if (duplicateOf.has(earlier.slug)) continue;
45
+ const same = normalize(item.body) === normalize(earlier.body) ||
46
+ (item.vector && earlier.vector && cosineSimilarity(item.vector, earlier.vector) >= DUPLICATE_SIMILARITY);
47
+ if (same) { duplicateOf.set(item.slug, earlier.slug); return; }
48
+ }
49
+ });
50
+ return duplicateOf;
51
+ }
52
+
53
+ /**
54
+ * Assign each rule a tier so the printed output fits `budget`, measuring the real
55
+ * render after every change. `header(counts, tokens)` supplies the status lines.
56
+ */
57
+ export function compactRules(items, { budget, expand = [], header, complete = false }) {
58
+ if (!Number.isFinite(budget) || budget < 1) throw new Error('Compact budget must be a positive token estimate');
59
+ const ranked = rank(items);
60
+ const known = new Set(ranked.map(item => item.slug));
61
+ const unknown = expand.filter(slug => !known.has(slug));
62
+ if (unknown.length) throw new Error(`Cannot expand rules not in this capsule: ${unknown.join(', ')}`);
63
+ const duplicateOf = findDuplicates(ranked.filter(item => !expand.includes(item.slug)));
64
+ const tiers = new Map(ranked.map(item => [item.slug, 'omitted']));
65
+
66
+ const render = () => {
67
+ const of = tier => ranked.filter(item => tiers.get(item.slug) === tier);
68
+ const full = of('full'), summary = of('summary'), reference = of('reference'), duplicate = of('duplicate');
69
+ const counts = { rules: ranked.length, full: full.length, summarized: summary.length, referenced: reference.length,
70
+ duplicate: duplicate.length, omitted: of('omitted').length };
71
+ const blocks = [
72
+ ...full.map(item => item.text.trim()),
73
+ ...summary.map(item => `[${item.slug}] ${summarizeRule(item.body)}`),
74
+ ];
75
+ if (reference.length) blocks.push(`Referenced: ${reference.map(item => item.slug).join(', ')}`);
76
+ if (duplicate.length) blocks.push(`Duplicates: ${duplicate.map(item => `${item.slug} (of ${duplicateOf.get(item.slug)})`).join(', ')}`);
77
+ const body = blocks.join('\n\n---\n\n');
78
+ // The token count sits inside the text it counts, so iterate to a stable value.
79
+ let tokens = estimateTokens(body);
80
+ let context = '';
81
+ for (let i = 0; i < 5; i++) {
82
+ context = `${header(counts, tokens)}${body ? `\n${body}` : ''}`;
83
+ const measured = estimateTokens(`${context}\n`);
84
+ if (measured === tokens) break;
85
+ tokens = measured;
86
+ }
87
+ return { context, counts, tokens };
88
+ };
89
+ const used = () => render().tokens;
90
+ const tryTier = (item, tier, limit = budget) => {
91
+ const previous = tiers.get(item.slug);
92
+ tiers.set(item.slug, tier);
93
+ if (used() <= limit) return true;
94
+ tiers.set(item.slug, previous);
95
+ return false;
96
+ };
97
+
98
+ // A complete capsule measures its own, higher floor below, so a retry at the reported number succeeds.
99
+ const floor = used();
100
+ if (!complete && floor > budget) {
101
+ const error = new Error(`Budget ${budget} cannot hold the capsule header; the minimum is ${floor}`);
102
+ error.requiredBudget = floor;
103
+ error.ruleCount = items.length;
104
+ throw error;
105
+ }
106
+
107
+ for (const slug of expand) {
108
+ if (!tryTier(ranked.find(item => item.slug === slug), 'full')) {
109
+ const error = new Error(`Budget ${budget} cannot hold expanded rule ${slug}; raise --budget or expand fewer rules`);
110
+ throw error;
111
+ }
112
+ }
113
+ // A complete capsule accounts for every rule before ranking spends any budget, so
114
+ // ranking decides how much text a rule gets and never whether it applies.
115
+ if (complete) {
116
+ for (const item of ranked) {
117
+ if (tiers.get(item.slug) !== 'omitted') continue;
118
+ tiers.set(item.slug, duplicateOf.has(item.slug) ? 'duplicate' : item.priority === 'absolute' ? 'summary' : 'reference');
119
+ }
120
+ const completeFloor = used();
121
+ if (completeFloor > budget) {
122
+ const error = new Error(`Budget ${budget} cannot hold every rule; the minimum is ${completeFloor}`);
123
+ error.requiredBudget = completeFloor;
124
+ error.ruleCount = items.length;
125
+ throw error;
126
+ }
127
+ }
128
+ const candidates = ranked.filter(item => !duplicateOf.has(item.slug) && tiers.get(item.slug) !== 'full');
129
+
130
+ // Slugs alone must not consume a small budget: the top rules need their text.
131
+ const referenceLimit = used() + Math.floor((budget - used()) * REFERENCE_SHARE);
132
+ // Absolute rules are named first, so a small budget still shows they exist.
133
+ const unplaced = candidates.filter(item => tiers.get(item.slug) === 'omitted');
134
+ const namedFirst = [...unplaced.filter(item => item.priority === 'absolute'), ...unplaced.filter(item => item.priority !== 'absolute')];
135
+ for (const item of namedFirst) if (!tryTier(item, 'reference', referenceLimit)) break;
136
+ const topLimit = used() + Math.floor((budget - used()) * TOP_FULL_SHARE);
137
+ for (const item of candidates) tryTier(item, 'full', topLimit);
138
+ for (const item of candidates) if (tiers.get(item.slug) !== 'full') tryTier(item, 'summary');
139
+ for (const item of candidates) if (tiers.get(item.slug) !== 'full') tryTier(item, 'full');
140
+ for (const item of namedFirst) if (tiers.get(item.slug) === 'omitted') tryTier(item, 'reference');
141
+ for (const slug of duplicateOf.keys()) tryTier(ranked.find(item => item.slug === slug), 'duplicate');
142
+
143
+ const { context, counts, tokens } = render();
144
+ return { context, counts, tokens, tiers: Object.fromEntries(tiers) };
145
+ }
@@ -40,6 +40,24 @@ export function ruleAppliesToRepo(node, repo) {
40
40
  return true;
41
41
  }
42
42
 
43
+ const CRITICAL_TOP_RULES = new Set([
44
+ 'invariants-8cd098fd',
45
+ 'invariants-dc116371',
46
+ 'never-ask-for-a-secret-before-searching-all-tr-stores',
47
+ 'push-only-on-request',
48
+ 'report-breakage-then-fix-at-the-source',
49
+ 'invariants-bbbbec36',
50
+ 'invariants-5d8df45d',
51
+ 'invariants-7780389f',
52
+ 'rule-total-recall-for-everything-always',
53
+ ]);
54
+
55
+ function rulePrecedence(slug) {
56
+ if (slug === 'invariants-8cd098fd' || slug === 'invariants-dc116371') return 100;
57
+ if (CRITICAL_TOP_RULES.has(slug)) return 50;
58
+ return 0;
59
+ }
60
+
43
61
  /** Applicability is explicit validated memory tags, independent of obligation.
44
62
  * Unknown rules remain required in task capsules. No inference from prose.
45
63
  */
@@ -51,6 +69,7 @@ export function selectRules(nodes, { actions = [], projectRoot = process.cwd(),
51
69
  if (!ruleAppliesToRepo(node, repo)) return false;
52
70
  const tags = Array.isArray(node.tags) ? node.tags : [];
53
71
  if (tags.includes('context:universal')) return true;
72
+ if (node.priority === 'absolute' || node.immutable === true) return true;
54
73
  if (bootstrap) return false;
55
74
  const triggers = tags.filter(t => typeof t === 'string' && t.startsWith('context:action:')).map(t => t.slice(15));
56
75
  // Unknown actions conservatively activate every conditional restriction.
@@ -59,6 +78,7 @@ export function selectRules(nodes, { actions = [], projectRoot = process.cwd(),
59
78
  });
60
79
  return applicable.filter(node => !applicable.some(other => other !== node && other.subject && other.subject === node.subject &&
61
80
  other.supersedes?.includes(node.slug))).sort((a, b) =>
81
+ (rulePrecedence(b.slug) - rulePrecedence(a.slug)) ||
62
82
  Number(b.priority === 'absolute') - Number(a.priority === 'absolute') || (b.importance || 3) - (a.importance || 3) || String(a.slug).localeCompare(String(b.slug)));
63
83
  }
64
84
 
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Hermes-Grade Fail-Closed Duplicate Memory Guard
3
+ *
4
+ * Implements pre-write semantic collision checking co-opted from Nous Hermes / Hindsight
5
+ * (_find_related_observations).
6
+ *
7
+ * Prevents agents from polluting the vault with duplicate rules or disguising
8
+ * rule-violation incidents by re-recording existing invariants.
9
+ *
10
+ * Invariant:
11
+ * If an active rule or invariant with high token similarity or vector semantic proximity
12
+ * already exists, the write MUST FAIL CLOSED (Exit Code 1) with ZERO disk bytes written.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { getNodes } from './vault-cache.mjs';
18
+ import { loadEmbeddingsIndex, cosineSimilarity } from './embeddings.mjs';
19
+ import { logger } from './logger.mjs';
20
+
21
+ const RULE_CATEGORIES = new Set(['invariants', 'anti-patterns', 'preferences', 'rules']);
22
+
23
+ const STOPWORDS = new Set([
24
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'he',
25
+ 'in', 'is', 'it', 'its', 'of', 'on', 'that', 'the', 'to', 'was', 'were',
26
+ 'will', 'with', 'before', 'after', 'using', 'used', 'use', 'any', 'all',
27
+ 'should', 'must', 'each', 'this', 'these', 'those', 'such', 'than', 'then'
28
+ ]);
29
+
30
+ /**
31
+ * Normalize and stem a token to its canonical semantic root.
32
+ */
33
+ export function stemToken(word) {
34
+ let w = word.toLowerCase();
35
+ // Strip trailing punctuation
36
+ w = w.replace(/[^a-z0-9_-]/g, '');
37
+ if (w.length <= 2) return w;
38
+
39
+ // Synonyms and domain stems
40
+ if (w === 'docs' || w === 'documentation') return 'doc';
41
+ if (w === 'apis' || w === 'api') return 'api';
42
+ if (w === 'models' || w === 'model') return 'model';
43
+ if (w === 'names' || w === 'strings' || w === 'string') return 'name';
44
+ if (w.startsWith('search')) return 'search';
45
+ if (w.startsWith('hardcod')) return 'hardcode';
46
+ if (w.startsWith('pretrain')) return 'pretrain';
47
+ if (w.startsWith('reli') || w.startsWith('rely')) return 'rely';
48
+ if (w.startsWith('specif')) return 'spec';
49
+ if (w.startsWith('push')) return 'push';
50
+ if (w.startsWith('commit')) return 'commit';
51
+ if (w.startsWith('verif')) return 'verify';
52
+ if (w.startsWith('check')) return 'check';
53
+
54
+ // Standard suffix stripping
55
+ if (w.endsWith('ing') && w.length > 5) return w.slice(0, -3);
56
+ if (w.endsWith('es') && w.length > 4) return w.slice(0, -2);
57
+ if (w.endsWith('s') && !w.endsWith('ss') && w.length > 3) return w.slice(0, -1);
58
+ if (w.endsWith('ed') && w.length > 4) return w.slice(0, -2);
59
+ if (w.endsWith('ly') && w.length > 4) return w.slice(0, -2);
60
+
61
+ return w;
62
+ }
63
+
64
+ /**
65
+ * Tokenize text into stemmed, stopword-filtered semantic tokens.
66
+ */
67
+ export function tokenize(text) {
68
+ if (!text || typeof text !== 'string') return new Set();
69
+ const rawWords = text
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9\s_-]/g, ' ')
72
+ .split(/\s+/)
73
+ .filter((w) => w.length > 1 && !STOPWORDS.has(w));
74
+
75
+ const stems = new Set();
76
+ for (const word of rawWords) {
77
+ const s = stemToken(word);
78
+ if (s && !STOPWORDS.has(s)) {
79
+ stems.add(s);
80
+ }
81
+ }
82
+ return stems;
83
+ }
84
+
85
+ /**
86
+ * Compute Jaccard similarity between two token sets.
87
+ */
88
+ export function jaccard(setA, setB) {
89
+ if (setA.size === 0 && setB.size === 0) return 1;
90
+ let intersection = 0;
91
+ for (const item of setA) {
92
+ if (setB.has(item)) intersection++;
93
+ }
94
+ const union = setA.size + setB.size - intersection;
95
+ return union === 0 ? 0 : intersection / union;
96
+ }
97
+
98
+ /**
99
+ * Compute containment ratio (how much of candidate's core tokens exist in target).
100
+ */
101
+ export function containment(candidateSet, targetSet) {
102
+ if (candidateSet.size === 0) return 0;
103
+ let matches = 0;
104
+ for (const token of candidateSet) {
105
+ if (targetSet.has(token)) matches++;
106
+ }
107
+ return matches / candidateSet.size;
108
+ }
109
+
110
+ /**
111
+ * Find the location of an existing slug in compiled instruction surfaces.
112
+ */
113
+ export function findSurfaceLocation(existingSlug, projectRoot = process.cwd()) {
114
+ const homeDir = process.env.HOME || '';
115
+ const candidates = [
116
+ path.join(projectRoot, 'AGENTS.md'),
117
+ path.join(projectRoot, 'INSTRUCTIONS.md'),
118
+ path.join(projectRoot, 'CLAUDE.md'),
119
+ path.join(projectRoot, '.cursorrules'),
120
+ path.join(homeDir, '.agent', 'INSTRUCTIONS.md'),
121
+ path.join(homeDir, '.agent', 'AGENTS.md'),
122
+ ];
123
+
124
+ for (const candidatePath of candidates) {
125
+ if (fs.existsSync(candidatePath)) {
126
+ try {
127
+ const content = fs.readFileSync(candidatePath, 'utf8');
128
+ const lines = content.split('\n');
129
+ for (let i = 0; i < lines.length; i++) {
130
+ if (lines[i].includes(existingSlug)) {
131
+ return {
132
+ file: path.basename(candidatePath),
133
+ path: candidatePath,
134
+ line: i + 1,
135
+ snippet: lines[i].trim().slice(0, 100),
136
+ };
137
+ }
138
+ }
139
+ } catch {
140
+ // Ignore read errors
141
+ }
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Record a rule violation incident into the persistent incidents log.
149
+ */
150
+ export function logIncident(incident, derivedDir) {
151
+ try {
152
+ fs.mkdirSync(derivedDir, { recursive: true });
153
+ const logPath = path.join(derivedDir, 'incidents.jsonl');
154
+ fs.appendFileSync(logPath, JSON.stringify(incident) + '\n', 'utf8');
155
+ } catch (err) {
156
+ logger.warn('duplicate-guard', `Failed to record incident to ${derivedDir}: ${err.message}`);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Check if a candidate rule/invariant node is a duplicate of an existing active node.
162
+ *
163
+ * @param {object} candidate - The candidate node to be remembered.
164
+ * @param {object} options
165
+ * @param {string} options.vaultDir - Path to current vault directory.
166
+ * @param {string} [options.derivedDir] - Path to memory-derived directory.
167
+ * @param {string} [options.globalVaultDir] - Global vault, as resolved by the caller's brain owner. Also searched when it differs from vaultDir.
168
+ * @param {string} [options.projectRoot] - Repository root directory.
169
+ * @param {number} [options.jaccardThreshold=0.45] - Threshold for token overlap.
170
+ * @param {number} [options.containmentThreshold=0.60] - Threshold for candidate containment.
171
+ * @param {number} [options.cosineThreshold=0.82] - Threshold for embedding cosine similarity.
172
+ * @returns {object} { isDuplicate: boolean, matchedNode?: object, similarity?: number, surfaceLoc?: object }
173
+ */
174
+ export function checkDuplicateRule(candidate, options = {}) {
175
+ const {
176
+ vaultDir,
177
+ derivedDir,
178
+ globalVaultDir,
179
+ projectRoot = process.cwd(),
180
+ jaccardThreshold = 0.45,
181
+ containmentThreshold = 0.60,
182
+ cosineThreshold = 0.82,
183
+ } = options;
184
+
185
+ if (!candidate || !RULE_CATEGORIES.has(candidate.category)) {
186
+ return { isDuplicate: false };
187
+ }
188
+
189
+ // Load existing active nodes from the target vault
190
+ const vaultNodes = getNodes(vaultDir).filter(
191
+ (n) => n.status === 'active' && RULE_CATEGORIES.has(n.category) && n.slug !== candidate.slug
192
+ );
193
+
194
+ // If in a project brain, also check global vault to prevent duplicating global invariants
195
+ let allNodes = [...vaultNodes];
196
+ if (globalVaultDir && vaultDir !== globalVaultDir && fs.existsSync(globalVaultDir)) {
197
+ try {
198
+ const globalNodes = getNodes(globalVaultDir).filter(
199
+ (n) => n.status === 'active' && RULE_CATEGORIES.has(n.category) && n.slug !== candidate.slug
200
+ );
201
+ allNodes = [...allNodes, ...globalNodes];
202
+ } catch {
203
+ // Ignore global load errors if any
204
+ }
205
+ }
206
+
207
+ if (allNodes.length === 0) {
208
+ return { isDuplicate: false };
209
+ }
210
+
211
+ const candidateBody = (candidate.body || '').trim();
212
+ const candidateNorm = candidateBody.toLowerCase().replace(/\s+/g, ' ');
213
+ const candidateTokens = tokenize(
214
+ `${candidate.title || ''} ${candidateBody} ${candidate.subject || ''} ${candidate.predicate || ''} ${candidate.object || ''}`
215
+ );
216
+
217
+ // Optional vector index check
218
+ let embeddingsIndex = null;
219
+ if (derivedDir && fs.existsSync(path.join(derivedDir, 'embeddings.db'))) {
220
+ try {
221
+ embeddingsIndex = loadEmbeddingsIndex(derivedDir);
222
+ } catch {
223
+ embeddingsIndex = null;
224
+ }
225
+ }
226
+
227
+ for (const existing of allNodes) {
228
+ const existingBody = (existing.body || '').trim();
229
+ const existingNorm = existingBody.toLowerCase().replace(/\s+/g, ' ');
230
+
231
+ // 1. Exact or near-exact normalized body match
232
+ if (candidateNorm.length > 10 && (candidateNorm === existingNorm || existingNorm.includes(candidateNorm) || candidateNorm.includes(existingNorm))) {
233
+ const surfaceLoc = findSurfaceLocation(existing.slug, projectRoot);
234
+ return {
235
+ isDuplicate: true,
236
+ matchedNode: existing,
237
+ similarity: 1.0,
238
+ surfaceLoc,
239
+ };
240
+ }
241
+
242
+ // 2. Token Jaccard similarity & Containment
243
+ const existingTokens = tokenize(
244
+ `${existing.title || ''} ${existingBody} ${existing.subject || ''} ${existing.predicate || ''} ${existing.object || ''}`
245
+ );
246
+ const tokenSim = jaccard(candidateTokens, existingTokens);
247
+ const containSim = containment(candidateTokens, existingTokens);
248
+
249
+ if (tokenSim >= jaccardThreshold || containSim >= containmentThreshold) {
250
+ const surfaceLoc = findSurfaceLocation(existing.slug, projectRoot);
251
+ const effectiveSim = Math.max(tokenSim, containSim);
252
+ return {
253
+ isDuplicate: true,
254
+ matchedNode: existing,
255
+ similarity: Math.round(effectiveSim * 1000) / 1000,
256
+ surfaceLoc,
257
+ };
258
+ }
259
+
260
+ // 3. Vector Embedding similarity (if available)
261
+ if (embeddingsIndex && embeddingsIndex[candidate.slug] && embeddingsIndex[existing.slug]) {
262
+ try {
263
+ const vecSim = cosineSimilarity(
264
+ embeddingsIndex[candidate.slug].embedding,
265
+ embeddingsIndex[existing.slug].embedding
266
+ );
267
+ if (vecSim >= cosineThreshold) {
268
+ const surfaceLoc = findSurfaceLocation(existing.slug, projectRoot);
269
+ return {
270
+ isDuplicate: true,
271
+ matchedNode: existing,
272
+ similarity: Math.round(vecSim * 1000) / 1000,
273
+ surfaceLoc,
274
+ };
275
+ }
276
+ } catch {
277
+ // Fall back to token match
278
+ }
279
+ }
280
+ }
281
+
282
+ return { isDuplicate: false };
283
+ }
284
+
285
+ /**
286
+ * Enforces that a candidate rule is not a duplicate.
287
+ * Throws an Error and logs an incident if a collision occurs.
288
+ */
289
+ export function assertNoDuplicateRule(candidate, options = {}) {
290
+ const check = checkDuplicateRule(candidate, options);
291
+ if (!check.isDuplicate) return;
292
+
293
+ const { matchedNode, similarity, surfaceLoc } = check;
294
+ const surfaceStr = surfaceLoc ? `${surfaceLoc.file} at Line ${surfaceLoc.line}` : 'Active Invariant Pool';
295
+
296
+ const incident = {
297
+ timestamp: new Date().toISOString(),
298
+ session_id: process.env.TR_SESSION_ID || 'unknown',
299
+ event: 'rule_write_rejected',
300
+ reason: 'An attempt was made to write a rule that already exists in active memory.',
301
+ attempted: {
302
+ category: candidate.category,
303
+ title: candidate.title,
304
+ body: candidate.body,
305
+ priority: candidate.priority || 'normal',
306
+ },
307
+ existing: {
308
+ slug: matchedNode.slug,
309
+ title: matchedNode.title,
310
+ body: matchedNode.body,
311
+ priority: matchedNode.priority || 'normal',
312
+ category: matchedNode.category,
313
+ },
314
+ similarity,
315
+ surface_location: surfaceStr,
316
+ };
317
+
318
+ if (options.derivedDir) {
319
+ logIncident(incident, options.derivedDir);
320
+ }
321
+
322
+ const message = [
323
+ '',
324
+ '================================================================================',
325
+ '🚨 VIOLATION INCIDENT DETECTED: DUPLICATE RULE REJECTED',
326
+ '--------------------------------------------------------------------------------',
327
+ `Attempted Rule: "${candidate.body}"`,
328
+ `Existing Rule: [${matchedNode.slug}] "${matchedNode.body}"`,
329
+ `Similarity: ${Math.round(similarity * 100)}%`,
330
+ `Surface Status: Active (${surfaceStr})`,
331
+ '',
332
+ 'ROOT-CAUSE ANALYSIS:',
333
+ 'The agent attempted to write a rule that ALREADY exists in the active instruction',
334
+ 'surface. This indicates the agent failed to follow an invariant that was already',
335
+ 'active in its context.',
336
+ '',
337
+ 'Refusing to write duplicate node. Zero disk bytes written.',
338
+ 'Fix the agent\'s behavior at the source.',
339
+ '================================================================================',
340
+ '',
341
+ ].join('\n');
342
+
343
+ console.error(message);
344
+
345
+ const error = new Error(`Rule already exists: [${matchedNode.slug}] "${matchedNode.body}" (${Math.round(similarity * 100)}% match)`);
346
+ error.code = 'ERR_DUPLICATE_RULE';
347
+ error.incident = incident;
348
+ error.matchedNode = matchedNode;
349
+ throw error;
350
+ }