brainclaw 0.29.2 → 1.5.4

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 (197) hide show
  1. package/LICENSE +21 -74
  2. package/README.md +199 -176
  3. package/dist/brainclaw-vscode.vsix +0 -0
  4. package/dist/cli.js +710 -25
  5. package/dist/commands/accept.js +3 -0
  6. package/dist/commands/add-step.js +11 -26
  7. package/dist/commands/agent-board.js +70 -3
  8. package/dist/commands/audit.js +19 -0
  9. package/dist/commands/check-policy.js +54 -0
  10. package/dist/commands/check-security-mcp.js +145 -0
  11. package/dist/commands/check-security.js +106 -0
  12. package/dist/commands/claim-resource.js +1 -0
  13. package/dist/commands/codev.js +672 -0
  14. package/dist/commands/compact.js +74 -0
  15. package/dist/commands/complete-step.js +16 -26
  16. package/dist/commands/constraint.js +8 -20
  17. package/dist/commands/decision.js +9 -20
  18. package/dist/commands/delete-plan.js +10 -12
  19. package/dist/commands/delete-step.js +16 -0
  20. package/dist/commands/dispatch.js +163 -0
  21. package/dist/commands/doctor.js +1122 -49
  22. package/dist/commands/enable-agent.js +1 -0
  23. package/dist/commands/export.js +280 -22
  24. package/dist/commands/handoff.js +33 -0
  25. package/dist/commands/harvest.js +189 -0
  26. package/dist/commands/hooks.js +82 -25
  27. package/dist/commands/inbox.js +169 -0
  28. package/dist/commands/init.js +38 -31
  29. package/dist/commands/install-hooks.js +71 -44
  30. package/dist/commands/link.js +89 -0
  31. package/dist/commands/list-claims.js +48 -3
  32. package/dist/commands/list-plans.js +129 -25
  33. package/dist/commands/loops-handlers.js +409 -0
  34. package/dist/commands/mcp-read-handlers.js +1628 -0
  35. package/dist/commands/mcp-schemas.generated.js +269 -0
  36. package/dist/commands/mcp.js +4224 -1501
  37. package/dist/commands/plan-resource.js +64 -0
  38. package/dist/commands/plan.js +12 -26
  39. package/dist/commands/prune.js +37 -2
  40. package/dist/commands/reflect.js +20 -7
  41. package/dist/commands/release-claim.js +11 -6
  42. package/dist/commands/release-notes.js +170 -0
  43. package/dist/commands/repair.js +210 -0
  44. package/dist/commands/run-profile.js +57 -0
  45. package/dist/commands/sequence.js +113 -0
  46. package/dist/commands/session-end.js +423 -14
  47. package/dist/commands/session-start.js +214 -41
  48. package/dist/commands/setup-security.js +103 -0
  49. package/dist/commands/setup.js +42 -4
  50. package/dist/commands/stale.js +109 -0
  51. package/dist/commands/switch.js +100 -2
  52. package/dist/commands/trap.js +14 -31
  53. package/dist/commands/update-handoff.js +63 -4
  54. package/dist/commands/update-plan.js +21 -28
  55. package/dist/commands/update-step.js +37 -0
  56. package/dist/commands/upgrade.js +313 -6
  57. package/dist/commands/usage.js +102 -0
  58. package/dist/commands/version.js +20 -0
  59. package/dist/commands/who.js +33 -5
  60. package/dist/commands/worktree.js +105 -0
  61. package/dist/core/actions.js +315 -0
  62. package/dist/core/agent-capability.js +610 -17
  63. package/dist/core/agent-context.js +7 -1
  64. package/dist/core/agent-files.js +1169 -85
  65. package/dist/core/agent-integrations.js +160 -5
  66. package/dist/core/agent-inventory.js +2 -0
  67. package/dist/core/agent-profiles.js +93 -0
  68. package/dist/core/agent-registry.js +162 -30
  69. package/dist/core/agentrun-reconciler.js +345 -0
  70. package/dist/core/agentruns.js +424 -0
  71. package/dist/core/ai-agent-detection.js +31 -10
  72. package/dist/core/archival.js +77 -0
  73. package/dist/core/assignment-sweeper.js +82 -0
  74. package/dist/core/assignments.js +367 -0
  75. package/dist/core/audit.js +30 -0
  76. package/dist/core/brainclaw-version.js +94 -2
  77. package/dist/core/candidates.js +93 -2
  78. package/dist/core/claims.js +419 -0
  79. package/dist/core/codev-metrics.js +77 -0
  80. package/dist/core/codev-personas.js +31 -0
  81. package/dist/core/codev-plan-gen.js +35 -0
  82. package/dist/core/codev-prompts.js +74 -0
  83. package/dist/core/codev-responses.js +62 -0
  84. package/dist/core/codev-rounds.js +218 -0
  85. package/dist/core/config.js +4 -0
  86. package/dist/core/context.js +381 -34
  87. package/dist/core/coordination.js +201 -6
  88. package/dist/core/cross-project.js +230 -16
  89. package/dist/core/default-profiles/doctor.yaml +11 -0
  90. package/dist/core/default-profiles/janitor.yaml +11 -0
  91. package/dist/core/default-profiles/onboarder.yaml +11 -0
  92. package/dist/core/default-profiles/reviewer.yaml +13 -0
  93. package/dist/core/dispatcher.js +1189 -0
  94. package/dist/core/duplicates.js +2 -2
  95. package/dist/core/entity-operations.js +450 -0
  96. package/dist/core/entity-registry.js +344 -0
  97. package/dist/core/events.js +106 -2
  98. package/dist/core/execution-adapters.js +154 -0
  99. package/dist/core/execution-context.js +63 -0
  100. package/dist/core/execution-profile.js +270 -0
  101. package/dist/core/execution.js +255 -0
  102. package/dist/core/facade-schema.js +81 -0
  103. package/dist/core/federation-cloud.js +99 -0
  104. package/dist/core/federation-message.js +52 -0
  105. package/dist/core/federation-transport.js +65 -0
  106. package/dist/core/gc-semantic.js +482 -0
  107. package/dist/core/governance.js +247 -0
  108. package/dist/core/guards.js +19 -0
  109. package/dist/core/ideation.js +72 -0
  110. package/dist/core/identity.js +110 -25
  111. package/dist/core/ids.js +6 -0
  112. package/dist/core/input-validation.js +2 -2
  113. package/dist/core/instruction-templates.js +344 -136
  114. package/dist/core/io.js +90 -11
  115. package/dist/core/lock.js +6 -2
  116. package/dist/core/loops/brief-assembly.js +213 -0
  117. package/dist/core/loops/facade-schema.js +148 -0
  118. package/dist/core/loops/index.js +7 -0
  119. package/dist/core/loops/iteration-engine.js +139 -0
  120. package/dist/core/loops/lock.js +385 -0
  121. package/dist/core/loops/store.js +201 -0
  122. package/dist/core/loops/types.js +403 -0
  123. package/dist/core/loops/verbs.js +534 -0
  124. package/dist/core/markdown.js +15 -3
  125. package/dist/core/memory-compactor.js +432 -0
  126. package/dist/core/memory-git.js +152 -8
  127. package/dist/core/messaging.js +278 -0
  128. package/dist/core/migration.js +32 -1
  129. package/dist/core/mutation-pipeline.js +4 -2
  130. package/dist/core/operations/memory-mutation.js +129 -0
  131. package/dist/core/operations/memory-write.js +78 -0
  132. package/dist/core/operations/plan.js +190 -0
  133. package/dist/core/policy.js +169 -0
  134. package/dist/core/reputation.js +9 -3
  135. package/dist/core/schema.js +491 -6
  136. package/dist/core/search.js +21 -2
  137. package/dist/core/security-cache.js +71 -0
  138. package/dist/core/security-guard.js +152 -0
  139. package/dist/core/security-scoring.js +86 -0
  140. package/dist/core/sequence.js +130 -0
  141. package/dist/core/socket-client.js +113 -0
  142. package/dist/core/staleness.js +246 -0
  143. package/dist/core/state.js +98 -22
  144. package/dist/core/store-resolution.js +43 -11
  145. package/dist/core/toml-writer.js +76 -0
  146. package/dist/core/upgrades/backup.js +232 -0
  147. package/dist/core/upgrades/health-check.js +169 -0
  148. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  149. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  150. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  151. package/dist/core/upgrades/schema-version.js +97 -0
  152. package/dist/core/worktree.js +606 -0
  153. package/dist/facts.js +114 -0
  154. package/dist/facts.json +111 -0
  155. package/docs/architecture/project-refs.md +5 -1
  156. package/docs/cli.md +690 -43
  157. package/docs/concepts/ideation-loop.md +317 -0
  158. package/docs/concepts/loop-engine.md +456 -0
  159. package/docs/concepts/mcp-governance.md +268 -0
  160. package/docs/concepts/memory-staleness.md +122 -0
  161. package/docs/concepts/multi-agent-workflows.md +166 -0
  162. package/docs/concepts/plans-and-claims.md +31 -6
  163. package/docs/concepts/project-md-convention.md +35 -0
  164. package/docs/concepts/troubleshooting.md +220 -0
  165. package/docs/concepts/upgrade-cli.md +202 -0
  166. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  167. package/docs/context-format-changelog.md +2 -2
  168. package/docs/context-format.md +2 -2
  169. package/docs/index.md +68 -0
  170. package/docs/integrations/agents.md +15 -16
  171. package/docs/integrations/cline.md +88 -0
  172. package/docs/integrations/codex.md +75 -23
  173. package/docs/integrations/continue.md +60 -0
  174. package/docs/integrations/copilot.md +67 -9
  175. package/docs/integrations/kilocode.md +72 -0
  176. package/docs/integrations/mcp.md +304 -21
  177. package/docs/integrations/mistral-vibe.md +122 -0
  178. package/docs/integrations/opencode.md +84 -0
  179. package/docs/integrations/overview.md +23 -8
  180. package/docs/integrations/roo.md +74 -0
  181. package/docs/integrations/windsurf.md +83 -0
  182. package/docs/mcp-schema-changelog.md +191 -1
  183. package/docs/playbooks/integration/index.md +121 -0
  184. package/docs/playbooks/productivity/index.md +102 -0
  185. package/docs/playbooks/team/index.md +122 -0
  186. package/docs/product/agent-first-model.md +184 -0
  187. package/docs/product/entity-model-audit.md +462 -0
  188. package/docs/product/positioning.md +10 -10
  189. package/docs/quickstart-existing-project.md +135 -0
  190. package/docs/quickstart.md +124 -37
  191. package/docs/release-maintenance.md +79 -0
  192. package/docs/review.md +2 -0
  193. package/docs/server-operations.md +118 -0
  194. package/package.json +21 -13
  195. package/dist/commands/claude-desktop-extension.js +0 -18
  196. package/dist/commands/diff.js +0 -99
  197. package/dist/core/claude-desktop-extension.js +0 -224
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Memory compactor — semantic consolidation of near-duplicate and stale memory items.
3
+ *
4
+ * Scans constraints, decisions, and traps for similarity clusters using the Dice
5
+ * coefficient, scores freshness, and proposes merges or archival.
6
+ *
7
+ * Three consumption modes:
8
+ * 1. `analyzeMemory()` → dry-run report (clusters + stale items)
9
+ * 2. `applyCompaction()` → execute merges, archive originals
10
+ * 3. `suggestCompaction()` → lightweight hint for session_end
11
+ *
12
+ * Non-destructive: merged items are archived to JSONL, never deleted outright.
13
+ *
14
+ * @module
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { normalise, similarity } from './duplicates.js';
19
+ import { resolveEntityDir } from './io.js';
20
+ import { loadState, saveState } from './state.js';
21
+ import { mutate } from './mutation-pipeline.js';
22
+ import { logger } from './logger.js';
23
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.55;
24
+ const DEFAULT_STALENESS_MAX_DAYS = 180;
25
+ const DEFAULT_STALENESS_SCORE_THRESHOLD = 0.20;
26
+ const DEFAULT_MIN_CLUSTER_SIZE = 2;
27
+ // ---------------------------------------------------------------------------
28
+ // Analysis
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Analyze the full memory state and produce a compaction report.
32
+ * Pure function — reads state, does not mutate.
33
+ */
34
+ export function analyzeMemory(state, options = {}) {
35
+ const threshold = options.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD;
36
+ const stalenessMaxDays = options.stalenessMaxDays ?? DEFAULT_STALENESS_MAX_DAYS;
37
+ const stalenessScoreThreshold = options.stalenessScoreThreshold ?? DEFAULT_STALENESS_SCORE_THRESHOLD;
38
+ const minClusterSize = options.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE;
39
+ // Flatten all memory items with their type tag
40
+ const items = flattenMemoryItems(state);
41
+ const totalItems = items.length;
42
+ // Build a reference count index (how many times each ID is mentioned across all items)
43
+ const refCounts = buildReferenceIndex(state);
44
+ // Cluster by type
45
+ const clusters = [];
46
+ for (const type of ['constraint', 'decision', 'trap']) {
47
+ const typed = items.filter(i => i.type === type);
48
+ const typeClusters = findClusters(typed, threshold, minClusterSize);
49
+ clusters.push(...typeClusters);
50
+ }
51
+ // Find stale items (not already in a cluster)
52
+ const clusteredIds = new Set(clusters.flatMap(c => c.items.map(i => i.id)));
53
+ const staleItems = [];
54
+ for (const item of items) {
55
+ if (clusteredIds.has(item.id))
56
+ continue;
57
+ const score = computeStalenessScore(item, refCounts, stalenessMaxDays);
58
+ if (score < stalenessScoreThreshold) {
59
+ staleItems.push({
60
+ ...item,
61
+ score: Math.round(score * 100) / 100,
62
+ reason: score === 0
63
+ ? `older than ${stalenessMaxDays} days with no references`
64
+ : `low confidence (freshness × reference density)`,
65
+ });
66
+ }
67
+ }
68
+ // Sort stale by score ascending (most stale first)
69
+ staleItems.sort((a, b) => a.score - b.score);
70
+ const archivableCount = clusters.reduce((n, c) => n + c.items.length - 1, 0) + staleItems.length;
71
+ return {
72
+ clusters,
73
+ staleItems,
74
+ totalItems,
75
+ archivableCount,
76
+ estimatedReductionPct: totalItems > 0 ? Math.round((archivableCount / totalItems) * 100) : 0,
77
+ };
78
+ }
79
+ /**
80
+ * Lightweight analysis returning only a short summary string,
81
+ * suitable for embedding in session_end output.
82
+ * Returns undefined if nothing actionable.
83
+ */
84
+ export function suggestCompaction(state, options = {}) {
85
+ const report = analyzeMemory(state, options);
86
+ if (report.clusters.length === 0 && report.staleItems.length === 0)
87
+ return undefined;
88
+ const parts = [];
89
+ if (report.clusters.length > 0) {
90
+ const totalDups = report.clusters.reduce((n, c) => n + c.items.length - 1, 0);
91
+ parts.push(`${report.clusters.length} similar cluster(s) (${totalDups} mergeable items)`);
92
+ }
93
+ if (report.staleItems.length > 0) {
94
+ parts.push(`${report.staleItems.length} stale item(s)`);
95
+ }
96
+ return `Memory compaction opportunity: ${parts.join(', ')}. Run \`brainclaw prune --semantic --dry-run\` for details.`;
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Apply compaction
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * Analyze and apply compaction atomically under a single mutation lock.
103
+ * This prevents race conditions where another agent modifies state between
104
+ * analysis and application.
105
+ */
106
+ export function analyzeAndApply(options = {}) {
107
+ const cwd = options.cwd ?? process.cwd();
108
+ let report;
109
+ let archivedCount = 0;
110
+ let mergedClusters = 0;
111
+ let staleArchived = 0;
112
+ mutate({ cwd }, () => {
113
+ const state = loadState(cwd);
114
+ report = analyzeMemory(state, options);
115
+ if (report.archivableCount === 0)
116
+ return;
117
+ const applied = applyReportToState(report, state, cwd);
118
+ archivedCount = applied.archivedCount;
119
+ mergedClusters = applied.mergedClusters;
120
+ staleArchived = applied.staleArchived;
121
+ saveState(state, cwd);
122
+ });
123
+ return { report, result: { archivedCount, mergedClusters, staleArchived } };
124
+ }
125
+ /**
126
+ * Apply a pre-computed compaction report. Re-validates that items still exist
127
+ * in the current state before archiving, to handle concurrent modifications.
128
+ * Runs inside the mutation lock.
129
+ */
130
+ export function applyCompaction(report, options = {}) {
131
+ const cwd = options.cwd ?? process.cwd();
132
+ let archivedCount = 0;
133
+ let mergedClusters = 0;
134
+ let staleArchived = 0;
135
+ mutate({ cwd }, () => {
136
+ const state = loadState(cwd);
137
+ const applied = applyReportToState(report, state, cwd);
138
+ archivedCount = applied.archivedCount;
139
+ mergedClusters = applied.mergedClusters;
140
+ staleArchived = applied.staleArchived;
141
+ saveState(state, cwd);
142
+ });
143
+ return { archivedCount, mergedClusters, staleArchived };
144
+ }
145
+ /** Shared logic: apply report mutations to a loaded state. Validates items still exist. */
146
+ function applyReportToState(report, state, cwd) {
147
+ let archivedCount = 0;
148
+ let mergedClusters = 0;
149
+ let staleArchived = 0;
150
+ for (const cluster of report.clusters) {
151
+ // Validate keeper still exists in state — skip cluster if not
152
+ const keeper = findInState(state, cluster.keepId, cluster.type);
153
+ if (!keeper)
154
+ continue;
155
+ // Only archive items that still exist in current state
156
+ const archiveIds = new Set(cluster.items
157
+ .filter(i => i.id !== cluster.keepId && findInState(state, i.id, cluster.type))
158
+ .map(i => i.id));
159
+ if (archiveIds.size === 0)
160
+ continue;
161
+ // Merge tags into keeper
162
+ const allTags = new Set(keeper.tags);
163
+ for (const item of cluster.items) {
164
+ for (const tag of item.tags)
165
+ allTags.add(tag);
166
+ }
167
+ keeper.tags = [...allTags];
168
+ const entityName = entityNameForType(cluster.type);
169
+ const archived = archiveItems(archiveIds, entityName, cwd);
170
+ removeFromState(state, archiveIds, cluster.type);
171
+ archivedCount += archived;
172
+ mergedClusters++;
173
+ }
174
+ for (const stale of report.staleItems) {
175
+ // Validate item still exists in current state
176
+ if (!findInState(state, stale.id, stale.type))
177
+ continue;
178
+ const entityName = entityNameForType(stale.type);
179
+ const archived = archiveItems(new Set([stale.id]), entityName, cwd);
180
+ removeFromState(state, new Set([stale.id]), stale.type);
181
+ staleArchived += archived;
182
+ archivedCount += archived;
183
+ }
184
+ return { archivedCount, mergedClusters, staleArchived };
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Clustering (union-find with Dice coefficient)
188
+ // ---------------------------------------------------------------------------
189
+ function findClusters(items, threshold, minSize) {
190
+ if (items.length < 2)
191
+ return [];
192
+ // Normalise texts once
193
+ const normalised = items.map(item => normalise(item.text));
194
+ // Union-find structure
195
+ const parent = items.map((_, i) => i);
196
+ const find = (i) => {
197
+ while (parent[i] !== i) {
198
+ parent[i] = parent[parent[i]];
199
+ i = parent[i];
200
+ }
201
+ return i;
202
+ };
203
+ const union = (a, b) => { parent[find(a)] = find(b); };
204
+ // Pairwise similarity — union items above threshold
205
+ const pairSim = new Map();
206
+ for (let i = 0; i < items.length; i++) {
207
+ for (let j = i + 1; j < items.length; j++) {
208
+ const sim = similarity(normalised[i], normalised[j]);
209
+ if (sim >= threshold) {
210
+ union(i, j);
211
+ pairSim.set(`${i}:${j}`, sim);
212
+ }
213
+ }
214
+ }
215
+ // Group by root
216
+ const groups = new Map();
217
+ for (let i = 0; i < items.length; i++) {
218
+ const root = find(i);
219
+ if (!groups.has(root))
220
+ groups.set(root, []);
221
+ groups.get(root).push(i);
222
+ }
223
+ // Build clusters from groups >= minSize
224
+ const clusters = [];
225
+ for (const indices of groups.values()) {
226
+ if (indices.length < minSize)
227
+ continue;
228
+ const type = items[indices[0]].type;
229
+ // Compute average pairwise similarity
230
+ let simSum = 0;
231
+ let simCount = 0;
232
+ for (let a = 0; a < indices.length; a++) {
233
+ for (let b = a + 1; b < indices.length; b++) {
234
+ const key1 = `${Math.min(indices[a], indices[b])}:${Math.max(indices[a], indices[b])}`;
235
+ simSum += pairSim.get(key1) ?? similarity(normalised[indices[a]], normalised[indices[b]]);
236
+ simCount++;
237
+ }
238
+ }
239
+ const avgSimilarity = simCount > 0 ? Math.round((simSum / simCount) * 100) / 100 : 0;
240
+ // Pick keeper: most recent, then longest text
241
+ const sorted = [...indices].sort((a, b) => {
242
+ const dateComp = items[b].created_at.localeCompare(items[a].created_at);
243
+ if (dateComp !== 0)
244
+ return dateComp;
245
+ return items[b].text.length - items[a].text.length;
246
+ });
247
+ const keepIdx = sorted[0];
248
+ const keepId = items[keepIdx].id;
249
+ const clusterItems = indices.map(idx => ({
250
+ ...items[idx],
251
+ similarity: idx === keepIdx ? 1.0 : (pairSim.get(`${Math.min(idx, keepIdx)}:${Math.max(idx, keepIdx)}`)
252
+ ?? similarity(normalised[idx], normalised[keepIdx])),
253
+ }));
254
+ // Sort: keeper first, then by similarity desc
255
+ clusterItems.sort((a, b) => {
256
+ if (a.id === keepId)
257
+ return -1;
258
+ if (b.id === keepId)
259
+ return 1;
260
+ return b.similarity - a.similarity;
261
+ });
262
+ clusters.push({ type, items: clusterItems, avgSimilarity, keepId });
263
+ }
264
+ // Sort clusters by size descending
265
+ clusters.sort((a, b) => b.items.length - a.items.length);
266
+ return clusters;
267
+ }
268
+ // ---------------------------------------------------------------------------
269
+ // Staleness scoring
270
+ // ---------------------------------------------------------------------------
271
+ function computeStalenessScore(item, refCounts, maxDays) {
272
+ const ageMs = Date.now() - Date.parse(item.created_at);
273
+ const ageDays = ageMs / (24 * 60 * 60 * 1000);
274
+ const freshness = Math.max(0, 1 - ageDays / maxDays);
275
+ const refs = refCounts.get(item.id) ?? 0;
276
+ // Referenced items get a floor so they're never fully stale —
277
+ // an actively referenced item is useful regardless of age.
278
+ const effective = Math.max(freshness, refs > 0 ? 0.25 : 0);
279
+ return effective * (1 + Math.log1p(refs));
280
+ }
281
+ /**
282
+ * Build an index of how many times each item ID is referenced across
283
+ * all plan_items (text + plan_id) and handoffs (text + plan_id + contract).
284
+ */
285
+ function buildReferenceIndex(state) {
286
+ const counts = new Map();
287
+ const increment = (id) => counts.set(id, (counts.get(id) ?? 0) + 1);
288
+ // Collect all IDs we care about
289
+ const allIds = new Set();
290
+ for (const c of state.active_constraints)
291
+ allIds.add(c.id);
292
+ for (const d of state.recent_decisions)
293
+ allIds.add(d.id);
294
+ for (const t of state.known_traps)
295
+ allIds.add(t.id);
296
+ // Scan plans for references (plans reference memory items by ID in their text)
297
+ for (const plan of state.plan_items) {
298
+ for (const id of allIds) {
299
+ if (plan.text.includes(id))
300
+ increment(id);
301
+ }
302
+ }
303
+ // Scan handoffs for references
304
+ for (const handoff of state.open_handoffs) {
305
+ for (const id of allIds) {
306
+ if (handoff.text.includes(id))
307
+ increment(id);
308
+ if (handoff.plan_id === id)
309
+ increment(id);
310
+ }
311
+ }
312
+ // Cross-references within memory items themselves
313
+ const allItems = [
314
+ ...state.active_constraints,
315
+ ...state.recent_decisions,
316
+ ...state.known_traps,
317
+ ];
318
+ for (const item of allItems) {
319
+ for (const id of allIds) {
320
+ if (id !== item.id && item.text.includes(id))
321
+ increment(id);
322
+ }
323
+ }
324
+ return counts;
325
+ }
326
+ // ---------------------------------------------------------------------------
327
+ // Helpers
328
+ // ---------------------------------------------------------------------------
329
+ function flattenMemoryItems(state) {
330
+ const items = [];
331
+ for (const c of state.active_constraints) {
332
+ items.push({ id: c.id, text: c.text, created_at: c.created_at, tags: c.tags, type: 'constraint', related_paths: c.related_paths });
333
+ }
334
+ for (const d of state.recent_decisions) {
335
+ items.push({ id: d.id, text: d.text, created_at: d.created_at, tags: d.tags, type: 'decision', related_paths: d.related_paths });
336
+ }
337
+ for (const t of state.known_traps) {
338
+ items.push({ id: t.id, text: t.text, created_at: t.created_at, tags: t.tags, type: 'trap', related_paths: t.related_paths });
339
+ }
340
+ return items;
341
+ }
342
+ function entityNameForType(type) {
343
+ switch (type) {
344
+ case 'constraint': return 'constraints';
345
+ case 'decision': return 'decisions';
346
+ case 'trap': return 'traps';
347
+ }
348
+ }
349
+ function findInState(state, id, type) {
350
+ switch (type) {
351
+ case 'constraint': return state.active_constraints.find(c => c.id === id);
352
+ case 'decision': return state.recent_decisions.find(d => d.id === id);
353
+ case 'trap': return state.known_traps.find(t => t.id === id);
354
+ }
355
+ }
356
+ function removeFromState(state, ids, type) {
357
+ switch (type) {
358
+ case 'constraint':
359
+ state.active_constraints = state.active_constraints.filter(c => !ids.has(c.id));
360
+ break;
361
+ case 'decision':
362
+ state.recent_decisions = state.recent_decisions.filter(d => !ids.has(d.id));
363
+ break;
364
+ case 'trap':
365
+ state.known_traps = state.known_traps.filter(t => !ids.has(t.id));
366
+ break;
367
+ }
368
+ }
369
+ /**
370
+ * Archive items by ID: read their JSON files, append to compacted.jsonl, delete originals.
371
+ * Returns number of items archived.
372
+ */
373
+ function archiveItems(ids, entityName, cwd) {
374
+ const dir = resolveEntityDir(entityName, cwd, 'read');
375
+ const archivePath = path.join(resolveEntityDir(entityName, cwd, 'write'), 'compacted.jsonl');
376
+ let archived = 0;
377
+ for (const id of ids) {
378
+ const filePath = path.join(dir, `${id}.json`);
379
+ if (!fs.existsSync(filePath))
380
+ continue;
381
+ try {
382
+ const content = fs.readFileSync(filePath, 'utf-8');
383
+ const item = JSON.parse(content);
384
+ item._compacted_at = new Date().toISOString();
385
+ fs.mkdirSync(path.dirname(archivePath), { recursive: true });
386
+ fs.appendFileSync(archivePath, JSON.stringify(item) + '\n', 'utf-8');
387
+ fs.unlinkSync(filePath);
388
+ archived++;
389
+ }
390
+ catch (err) {
391
+ logger.debug(`Failed to archive ${entityName}/${id}:`, err);
392
+ }
393
+ }
394
+ return archived;
395
+ }
396
+ // ---------------------------------------------------------------------------
397
+ // Report formatting (CLI output)
398
+ // ---------------------------------------------------------------------------
399
+ export function formatReport(report) {
400
+ const lines = [];
401
+ lines.push(`Memory compaction analysis — ${report.totalItems} items scanned\n`);
402
+ if (report.clusters.length > 0) {
403
+ lines.push(`Similar clusters (${report.clusters.length}):`);
404
+ for (const cluster of report.clusters) {
405
+ lines.push(` Cluster (${cluster.items.length} ${cluster.type}s, avg similarity: ${cluster.avgSimilarity}):`);
406
+ for (const item of cluster.items) {
407
+ const marker = item.id === cluster.keepId ? 'KEEP' : 'archive';
408
+ const preview = item.text.length > 80 ? item.text.slice(0, 77) + '...' : item.text;
409
+ lines.push(` [${marker}] ${item.id} (${item.created_at.slice(0, 10)}) ${preview}`);
410
+ }
411
+ }
412
+ lines.push('');
413
+ }
414
+ if (report.staleItems.length > 0) {
415
+ lines.push(`Stale items (${report.staleItems.length}):`);
416
+ for (const item of report.staleItems) {
417
+ const preview = item.text.length > 80 ? item.text.slice(0, 77) + '...' : item.text;
418
+ lines.push(` [${item.type}] ${item.id} (score: ${item.score}) ${preview}`);
419
+ lines.push(` → ${item.reason}`);
420
+ }
421
+ lines.push('');
422
+ }
423
+ if (report.archivableCount > 0) {
424
+ lines.push(`Estimated reduction: ${report.archivableCount}/${report.totalItems} items (${report.estimatedReductionPct}%)`);
425
+ lines.push(`Run \`brainclaw prune --semantic\` to apply.`);
426
+ }
427
+ else {
428
+ lines.push('No compaction opportunities found.');
429
+ }
430
+ return lines.join('\n');
431
+ }
432
+ //# sourceMappingURL=memory-compactor.js.map
@@ -4,6 +4,44 @@ import path from 'node:path';
4
4
  import { memoryDir } from './io.js';
5
5
  import { logger } from './logger.js';
6
6
  const GIT_DIR_NAME = '.git';
7
+ const ROLLBACK_ROOT_FILES = new Set([
8
+ 'config.yaml',
9
+ 'state.yaml',
10
+ 'project.md',
11
+ 'project.identity.json',
12
+ '.id-counter.json',
13
+ ]);
14
+ const ROLLBACK_EXCLUDED_BASENAMES = new Set([
15
+ 'archive.jsonl',
16
+ 'compacted.jsonl',
17
+ ]);
18
+ const ROLLBACK_ALLOWED_EXTENSIONS = new Set([
19
+ '.json',
20
+ '.yaml',
21
+ '.yml',
22
+ ]);
23
+ const ROLLBACK_ROOTS = [
24
+ 'constraints/',
25
+ 'decisions/',
26
+ 'traps/',
27
+ 'instructions/',
28
+ 'plans/',
29
+ 'sequences/',
30
+ 'claims/',
31
+ 'handoffs/',
32
+ 'surface-tasks/',
33
+ 'memory/constraints/',
34
+ 'memory/decisions/',
35
+ 'memory/traps/',
36
+ 'memory/instructions/',
37
+ 'coordination/plans/',
38
+ 'coordination/sequences/',
39
+ 'coordination/claims/',
40
+ 'coordination/handoffs/',
41
+ 'coordination/sessions/',
42
+ 'coordination/surface-tasks/',
43
+ 'sessions/',
44
+ ];
7
45
  /**
8
46
  * Check if the memory directory has an internal git repo.
9
47
  */
@@ -87,7 +125,13 @@ export function getMemoryLog(limit = 20, cwd) {
87
125
  }
88
126
  }
89
127
  /**
90
- * Rollback the memory to a previous commit.
128
+ * Restore live files from the current project's Brainclaw store to a previous
129
+ * commit without deleting durable logs, archives, or compaction outputs.
130
+ *
131
+ * Behaviour note: this is intentionally selective and non-destructive.
132
+ * It replaces an older "full-store reset" rollback model.
133
+ *
134
+ * This intentionally creates a new commit instead of performing a hard reset.
91
135
  * Returns true if successful.
92
136
  */
93
137
  export function rollbackMemory(ref, cwd) {
@@ -95,13 +139,27 @@ export function rollbackMemory(ref, cwd) {
95
139
  return false;
96
140
  const dir = memoryDir(cwd);
97
141
  try {
98
- // Remove all tracked files, then restore from the target ref
99
- // This ensures files added after the ref are also removed
100
- git(dir, ['rm', '-rf', '--quiet', '--ignore-unmatch', '.']);
101
- git(dir, ['checkout', ref, '--', '.']);
102
- git(dir, ['clean', '-fd', '--quiet']);
103
- git(dir, ['add', '-A']);
104
- git(dir, ['commit', '--quiet', '--allow-empty', '-m', `brainclaw: rollback to ${ref}`]);
142
+ git(dir, ['rev-parse', '--verify', `${ref}^{commit}`]);
143
+ const currentLiveFiles = listRollbackManagedFilesOnDisk(dir);
144
+ const targetLiveFiles = listRollbackManagedFilesAtRef(dir, ref);
145
+ const targetSet = new Set(targetLiveFiles);
146
+ for (const relPath of currentLiveFiles) {
147
+ if (!targetSet.has(relPath)) {
148
+ removeManagedPath(dir, relPath);
149
+ }
150
+ }
151
+ for (const relPath of targetLiveFiles) {
152
+ restoreManagedPathFromRef(dir, ref, relPath);
153
+ }
154
+ stageManagedPaths(dir, new Set([...currentLiveFiles, ...targetLiveFiles]));
155
+ const staged = git(dir, ['diff', '--cached', '--name-only'])
156
+ .trim()
157
+ .split('\n')
158
+ .map((entry) => normalizeRelativePath(entry))
159
+ .filter((entry) => entry.length > 0 && isRollbackManagedPath(entry));
160
+ if (staged.length === 0)
161
+ return false;
162
+ git(dir, ['commit', '--quiet', '-m', `brainclaw: rollback live memory to ${ref}`]);
105
163
  return true;
106
164
  }
107
165
  catch (err) {
@@ -130,4 +188,90 @@ function git(cwd, args) {
130
188
  stdio: ['pipe', 'pipe', 'pipe'],
131
189
  });
132
190
  }
191
+ function normalizeRelativePath(filepath) {
192
+ return filepath.replace(/\\/g, '/').replace(/^\.\/+/, '').trim();
193
+ }
194
+ function isRollbackManagedPath(relativePath) {
195
+ const normalized = normalizeRelativePath(relativePath);
196
+ if (!normalized)
197
+ return false;
198
+ if (ROLLBACK_ROOT_FILES.has(normalized))
199
+ return true;
200
+ const rootMatched = ROLLBACK_ROOTS.some((prefix) => normalized.startsWith(prefix));
201
+ if (!rootMatched)
202
+ return false;
203
+ const basename = path.posix.basename(normalized);
204
+ if (ROLLBACK_EXCLUDED_BASENAMES.has(basename))
205
+ return false;
206
+ const extension = path.posix.extname(basename).toLowerCase();
207
+ return ROLLBACK_ALLOWED_EXTENSIONS.has(extension);
208
+ }
209
+ function listRollbackManagedFilesAtRef(cwd, ref) {
210
+ const output = git(cwd, ['ls-tree', '-r', '--name-only', ref]);
211
+ return output
212
+ .split('\n')
213
+ .map((entry) => normalizeRelativePath(entry))
214
+ .filter((entry) => entry.length > 0 && isRollbackManagedPath(entry))
215
+ .sort();
216
+ }
217
+ function listRollbackManagedFilesOnDisk(cwd) {
218
+ const results = [];
219
+ walkFiles(cwd, cwd, results);
220
+ results.sort();
221
+ return results;
222
+ }
223
+ function walkFiles(baseDir, currentDir, results) {
224
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
225
+ if (entry.name === GIT_DIR_NAME)
226
+ continue;
227
+ const absolutePath = path.join(currentDir, entry.name);
228
+ if (entry.isDirectory()) {
229
+ walkFiles(baseDir, absolutePath, results);
230
+ continue;
231
+ }
232
+ const relativePath = normalizeRelativePath(path.relative(baseDir, absolutePath));
233
+ if (isRollbackManagedPath(relativePath)) {
234
+ results.push(relativePath);
235
+ }
236
+ }
237
+ }
238
+ function removeManagedPath(cwd, relativePath) {
239
+ const absolutePath = path.join(cwd, relativePath);
240
+ if (!fs.existsSync(absolutePath))
241
+ return;
242
+ fs.unlinkSync(absolutePath);
243
+ removeEmptyParentDirs(path.dirname(absolutePath), cwd);
244
+ }
245
+ function removeEmptyParentDirs(startDir, stopDir) {
246
+ let current = startDir;
247
+ const resolvedStop = path.resolve(stopDir);
248
+ while (path.resolve(current).startsWith(resolvedStop) && path.resolve(current) !== resolvedStop) {
249
+ const entries = fs.readdirSync(current);
250
+ if (entries.length > 0)
251
+ return;
252
+ fs.rmdirSync(current);
253
+ current = path.dirname(current);
254
+ }
255
+ }
256
+ function restoreManagedPathFromRef(cwd, ref, relativePath) {
257
+ const absolutePath = path.join(cwd, relativePath);
258
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
259
+ const content = gitBuffer(cwd, ['show', `${ref}:${relativePath}`]);
260
+ fs.writeFileSync(absolutePath, content);
261
+ }
262
+ function stageManagedPaths(cwd, managedPaths) {
263
+ const paths = [...managedPaths].filter(Boolean);
264
+ const chunkSize = 64;
265
+ for (let index = 0; index < paths.length; index += chunkSize) {
266
+ const chunk = paths.slice(index, index + chunkSize);
267
+ git(cwd, ['add', '-A', '--', ...chunk]);
268
+ }
269
+ }
270
+ function gitBuffer(cwd, args) {
271
+ return execFileSync('git', args, {
272
+ cwd,
273
+ timeout: 10_000,
274
+ stdio: ['pipe', 'pipe', 'pipe'],
275
+ });
276
+ }
133
277
  //# sourceMappingURL=memory-git.js.map