brainclaw 0.29.2 → 1.5.3
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.
- package/README.md +193 -170
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +673 -24
- package/dist/commands/accept.js +3 -0
- package/dist/commands/add-step.js +11 -26
- package/dist/commands/agent-board.js +70 -3
- package/dist/commands/audit.js +19 -0
- package/dist/commands/check-policy.js +54 -0
- package/dist/commands/check-security-mcp.js +145 -0
- package/dist/commands/check-security.js +106 -0
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/codev.js +672 -0
- package/dist/commands/compact.js +74 -0
- package/dist/commands/complete-step.js +16 -26
- package/dist/commands/constraint.js +8 -20
- package/dist/commands/decision.js +9 -20
- package/dist/commands/delete-plan.js +10 -12
- package/dist/commands/delete-step.js +16 -0
- package/dist/commands/dispatch.js +163 -0
- package/dist/commands/doctor.js +1122 -49
- package/dist/commands/enable-agent.js +1 -0
- package/dist/commands/export.js +280 -22
- package/dist/commands/handoff.js +33 -0
- package/dist/commands/harvest.js +189 -0
- package/dist/commands/hooks.js +82 -25
- package/dist/commands/inbox.js +169 -0
- package/dist/commands/init.js +38 -31
- package/dist/commands/install-hooks.js +71 -44
- package/dist/commands/link.js +89 -0
- package/dist/commands/list-claims.js +48 -3
- package/dist/commands/list-plans.js +129 -25
- package/dist/commands/loops-handlers.js +409 -0
- package/dist/commands/mcp-read-handlers.js +1628 -0
- package/dist/commands/mcp-schemas.generated.js +74 -0
- package/dist/commands/mcp.js +4221 -1501
- package/dist/commands/plan-resource.js +64 -0
- package/dist/commands/plan.js +12 -26
- package/dist/commands/prune.js +37 -2
- package/dist/commands/reflect.js +20 -7
- package/dist/commands/release-claim.js +11 -6
- package/dist/commands/release-notes.js +170 -0
- package/dist/commands/repair.js +210 -0
- package/dist/commands/run-profile.js +57 -0
- package/dist/commands/sequence.js +113 -0
- package/dist/commands/session-end.js +423 -14
- package/dist/commands/session-start.js +214 -41
- package/dist/commands/setup-security.js +103 -0
- package/dist/commands/setup.js +42 -4
- package/dist/commands/stale.js +109 -0
- package/dist/commands/switch.js +100 -2
- package/dist/commands/trap.js +14 -31
- package/dist/commands/update-handoff.js +63 -4
- package/dist/commands/update-plan.js +21 -28
- package/dist/commands/update-step.js +37 -0
- package/dist/commands/upgrade.js +313 -6
- package/dist/commands/usage.js +102 -0
- package/dist/commands/version.js +20 -0
- package/dist/commands/who.js +33 -5
- package/dist/commands/worktree.js +105 -0
- package/dist/core/actions.js +315 -0
- package/dist/core/agent-capability.js +610 -17
- package/dist/core/agent-context.js +7 -1
- package/dist/core/agent-files.js +1169 -85
- package/dist/core/agent-integrations.js +160 -5
- package/dist/core/agent-inventory.js +2 -0
- package/dist/core/agent-profiles.js +93 -0
- package/dist/core/agent-registry.js +162 -30
- package/dist/core/agentrun-reconciler.js +345 -0
- package/dist/core/agentruns.js +424 -0
- package/dist/core/ai-agent-detection.js +31 -10
- package/dist/core/archival.js +77 -0
- package/dist/core/assignment-sweeper.js +82 -0
- package/dist/core/assignments.js +367 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/brainclaw-version.js +94 -2
- package/dist/core/candidates.js +93 -2
- package/dist/core/claims.js +419 -0
- package/dist/core/codev-metrics.js +77 -0
- package/dist/core/codev-personas.js +31 -0
- package/dist/core/codev-plan-gen.js +35 -0
- package/dist/core/codev-prompts.js +74 -0
- package/dist/core/codev-responses.js +62 -0
- package/dist/core/codev-rounds.js +218 -0
- package/dist/core/config.js +4 -0
- package/dist/core/context.js +381 -34
- package/dist/core/coordination.js +201 -6
- package/dist/core/cross-project.js +230 -16
- package/dist/core/default-profiles/doctor.yaml +11 -0
- package/dist/core/default-profiles/janitor.yaml +11 -0
- package/dist/core/default-profiles/onboarder.yaml +11 -0
- package/dist/core/default-profiles/reviewer.yaml +13 -0
- package/dist/core/dispatcher.js +1189 -0
- package/dist/core/duplicates.js +2 -2
- package/dist/core/entity-operations.js +450 -0
- package/dist/core/entity-registry.js +344 -0
- package/dist/core/events.js +106 -2
- package/dist/core/execution-adapters.js +154 -0
- package/dist/core/execution-context.js +63 -0
- package/dist/core/execution-profile.js +270 -0
- package/dist/core/execution.js +255 -0
- package/dist/core/facade-schema.js +81 -0
- package/dist/core/federation-cloud.js +99 -0
- package/dist/core/federation-message.js +52 -0
- package/dist/core/federation-transport.js +65 -0
- package/dist/core/gc-semantic.js +482 -0
- package/dist/core/governance.js +247 -0
- package/dist/core/guards.js +19 -0
- package/dist/core/ideation.js +72 -0
- package/dist/core/identity.js +110 -25
- package/dist/core/ids.js +6 -0
- package/dist/core/input-validation.js +2 -2
- package/dist/core/instruction-templates.js +344 -136
- package/dist/core/io.js +90 -11
- package/dist/core/lock.js +6 -2
- package/dist/core/loops/brief-assembly.js +213 -0
- package/dist/core/loops/facade-schema.js +148 -0
- package/dist/core/loops/index.js +7 -0
- package/dist/core/loops/iteration-engine.js +139 -0
- package/dist/core/loops/lock.js +385 -0
- package/dist/core/loops/store.js +201 -0
- package/dist/core/loops/types.js +403 -0
- package/dist/core/loops/verbs.js +534 -0
- package/dist/core/markdown.js +15 -3
- package/dist/core/memory-compactor.js +432 -0
- package/dist/core/memory-git.js +152 -8
- package/dist/core/messaging.js +278 -0
- package/dist/core/migration.js +32 -1
- package/dist/core/mutation-pipeline.js +4 -2
- package/dist/core/operations/memory-mutation.js +129 -0
- package/dist/core/operations/memory-write.js +78 -0
- package/dist/core/operations/plan.js +190 -0
- package/dist/core/policy.js +169 -0
- package/dist/core/reputation.js +9 -3
- package/dist/core/schema.js +491 -6
- package/dist/core/search.js +21 -2
- package/dist/core/security-cache.js +71 -0
- package/dist/core/security-guard.js +152 -0
- package/dist/core/security-scoring.js +86 -0
- package/dist/core/sequence.js +130 -0
- package/dist/core/socket-client.js +113 -0
- package/dist/core/staleness.js +246 -0
- package/dist/core/state.js +98 -22
- package/dist/core/store-resolution.js +43 -11
- package/dist/core/toml-writer.js +76 -0
- package/dist/core/upgrades/backup.js +232 -0
- package/dist/core/upgrades/health-check.js +169 -0
- package/dist/core/upgrades/patches/candidate-archive.js +145 -0
- package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
- package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
- package/dist/core/upgrades/schema-version.js +97 -0
- package/dist/core/worktree.js +606 -0
- package/dist/facts.js +114 -0
- package/dist/facts.json +111 -0
- package/docs/architecture/project-refs.md +5 -1
- package/docs/cli.md +690 -43
- package/docs/concepts/ideation-loop.md +317 -0
- package/docs/concepts/loop-engine.md +456 -0
- package/docs/concepts/mcp-governance.md +268 -0
- package/docs/concepts/memory-staleness.md +122 -0
- package/docs/concepts/multi-agent-workflows.md +166 -0
- package/docs/concepts/plans-and-claims.md +31 -6
- package/docs/concepts/project-md-convention.md +35 -0
- package/docs/concepts/troubleshooting.md +220 -0
- package/docs/concepts/upgrade-cli.md +202 -0
- package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
- package/docs/context-format-changelog.md +2 -2
- package/docs/context-format.md +2 -2
- package/docs/index.md +68 -0
- package/docs/integrations/agents.md +15 -16
- package/docs/integrations/cline.md +88 -0
- package/docs/integrations/codex.md +75 -23
- package/docs/integrations/continue.md +60 -0
- package/docs/integrations/copilot.md +67 -9
- package/docs/integrations/kilocode.md +72 -0
- package/docs/integrations/mcp.md +304 -21
- package/docs/integrations/mistral-vibe.md +122 -0
- package/docs/integrations/opencode.md +84 -0
- package/docs/integrations/overview.md +23 -8
- package/docs/integrations/roo.md +74 -0
- package/docs/integrations/windsurf.md +83 -0
- package/docs/mcp-schema-changelog.md +191 -1
- package/docs/playbooks/integration/index.md +121 -0
- package/docs/playbooks/productivity/index.md +102 -0
- package/docs/playbooks/team/index.md +122 -0
- package/docs/product/agent-first-model.md +184 -0
- package/docs/product/entity-model-audit.md +462 -0
- package/docs/quickstart-existing-project.md +135 -0
- package/docs/quickstart.md +124 -37
- package/docs/release-maintenance.md +79 -0
- package/docs/review.md +2 -0
- package/docs/server-operations.md +118 -0
- package/package.json +20 -12
- package/dist/commands/claude-desktop-extension.js +0 -18
- package/dist/commands/diff.js +0 -99
- 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
|
package/dist/core/memory-git.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|