brainclaw 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +47 -1
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +18 -1
  4. package/dist/commands/code-map.js +129 -0
  5. package/dist/commands/codev.js +7 -0
  6. package/dist/commands/mcp.js +121 -0
  7. package/dist/commands/run-profile.js +3 -2
  8. package/dist/commands/switch.js +100 -89
  9. package/dist/core/agent-files.js +12 -0
  10. package/dist/core/code-map/backend.js +123 -0
  11. package/dist/core/code-map/core.js +81 -0
  12. package/dist/core/code-map/drafts.js +2 -0
  13. package/dist/core/code-map/extractor.js +29 -0
  14. package/dist/core/code-map/finalizer.js +191 -0
  15. package/dist/core/code-map/freshness.js +108 -0
  16. package/dist/core/code-map/ids.js +0 -0
  17. package/dist/core/code-map/importable.js +35 -0
  18. package/dist/core/code-map/indexes.js +197 -0
  19. package/dist/core/code-map/lang/java/imports.scm +17 -0
  20. package/dist/core/code-map/lang/java/index.js +254 -0
  21. package/dist/core/code-map/lang/java/tags.scm +48 -0
  22. package/dist/core/code-map/lang/php/imports.scm +21 -0
  23. package/dist/core/code-map/lang/php/index.js +251 -0
  24. package/dist/core/code-map/lang/php/tags.scm +44 -0
  25. package/dist/core/code-map/lang/provider.js +9 -0
  26. package/dist/core/code-map/lang/providers.js +24 -0
  27. package/dist/core/code-map/lang/python/imports.scm +90 -0
  28. package/dist/core/code-map/lang/python/index.js +364 -0
  29. package/dist/core/code-map/lang/python/tags.scm +81 -0
  30. package/dist/core/code-map/lang/query-runtime.js +374 -0
  31. package/dist/core/code-map/lang/registry.js +125 -0
  32. package/dist/core/code-map/lang/typescript/imports.scm +90 -0
  33. package/dist/core/code-map/lang/typescript/index.js +306 -0
  34. package/dist/core/code-map/lang/typescript/tags.js.scm +106 -0
  35. package/dist/core/code-map/lang/typescript/tags.scm +151 -0
  36. package/dist/core/code-map/lock.js +210 -0
  37. package/dist/core/code-map/materialized.js +51 -0
  38. package/dist/core/code-map/memory-reader.js +59 -0
  39. package/dist/core/code-map/paths.js +53 -0
  40. package/dist/core/code-map/query.js +568 -0
  41. package/dist/core/code-map/refresh.js +0 -0
  42. package/dist/core/code-map/resolve.js +177 -0
  43. package/dist/core/code-map/store.js +206 -0
  44. package/dist/core/code-map/types.js +288 -0
  45. package/dist/core/code-map/vocabulary.js +57 -0
  46. package/dist/core/code-map/wasm-loader.js +294 -0
  47. package/dist/core/code-map/work-section.js +206 -0
  48. package/dist/core/codev-rounds.js +4 -0
  49. package/dist/core/execution-adapters.js +11 -10
  50. package/dist/core/execution-profile.js +58 -0
  51. package/dist/core/facade-schema.js +9 -0
  52. package/dist/core/instruction-templates.js +2 -0
  53. package/dist/core/mcp-command-resolution.js +3 -1
  54. package/dist/core/store-resolution.js +41 -4
  55. package/dist/facts.js +9 -5
  56. package/dist/facts.json +8 -4
  57. package/dist/vendor/web-tree-sitter/tree-sitter.js +3980 -0
  58. package/dist/vendor/web-tree-sitter/tree-sitter.wasm +0 -0
  59. package/dist/wasm/tree-sitter-java.wasm +0 -0
  60. package/dist/wasm/tree-sitter-javascript.wasm +0 -0
  61. package/dist/wasm/tree-sitter-php.wasm +0 -0
  62. package/dist/wasm/tree-sitter-python.wasm +0 -0
  63. package/dist/wasm/tree-sitter-tsx.wasm +0 -0
  64. package/dist/wasm/tree-sitter-typescript.wasm +0 -0
  65. package/dist/wasm/tree-sitter.wasm +0 -0
  66. package/docs/cli.md +46 -8
  67. package/docs/code-map.md +198 -0
  68. package/docs/integrations/mcp.md +13 -6
  69. package/docs/mcp-schema-changelog.md +7 -3
  70. package/docs/quickstart.md +1 -1
  71. package/package.json +11 -6
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Code Map per-project lock (spec §5.8 + §6 rule 1).
3
+ *
4
+ * Distinct from the generic advisory lock in `src/core/lock.ts` because the
5
+ * Code Map lock carries rich metadata (owner_agent, pid, operation, scope,
6
+ * heartbeat_at, stale_after_ms) and implements operator-free abandoned-lock
7
+ * auto-recovery. It follows the same discipline as `withLock`:
8
+ *
9
+ * - exclusive create with the `wx` open flag — creating an already-existing
10
+ * lock must NOT truncate it;
11
+ * - heartbeat refresh during a long operation;
12
+ * - only a *live* lock blocks; an *abandoned* lock (dead pid OR stale
13
+ * heartbeat, with no store file changed since) is reclaimed via an
14
+ * atomic-rename takeover, logging the prior owner's metadata.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { writeFileAtomic } from '../io.js';
20
+ import { logger } from '../logger.js';
21
+ import { CODE_MAP_SCHEMA_VERSION, CodeLockSchema } from './types.js';
22
+ import { codeMapDir, lockPath } from './paths.js';
23
+ /** Default lock validity window before a silent owner is treated as abandoned. */
24
+ export const DEFAULT_STALE_AFTER_MS = 60_000;
25
+ function defaultIsPidAlive(pid) {
26
+ if (!Number.isInteger(pid) || pid <= 0)
27
+ return false;
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ function readLock(p) {
37
+ try {
38
+ const raw = fs.readFileSync(p, 'utf-8');
39
+ const parsed = CodeLockSchema.safeParse(JSON.parse(raw));
40
+ return parsed.success ? parsed.data : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** Most recent mtime of any file under the store dir (excluding the lock itself). */
47
+ function latestStoreMtimeMs(storeDir, lockFile) {
48
+ let latest = 0;
49
+ const walk = (dir) => {
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(dir, { withFileTypes: true });
53
+ }
54
+ catch {
55
+ return;
56
+ }
57
+ for (const entry of entries) {
58
+ const full = path.join(dir, entry.name);
59
+ if (full === lockFile)
60
+ continue;
61
+ if (entry.isDirectory()) {
62
+ walk(full);
63
+ continue;
64
+ }
65
+ // Ignore non-authoritative litter: writeFileAtomic temp files and the
66
+ // advisory lock-of-the-lock it creates. They are not store content and
67
+ // must not falsely register as "the store changed after the heartbeat"
68
+ // (which would wrongly block an otherwise-valid abandoned-lock reclaim).
69
+ const name = entry.name;
70
+ if (name.endsWith('.tmp') || name.endsWith('.lock'))
71
+ continue;
72
+ try {
73
+ const m = fs.statSync(full).mtimeMs;
74
+ if (m > latest)
75
+ latest = m;
76
+ }
77
+ catch {
78
+ /* skip */
79
+ }
80
+ }
81
+ };
82
+ walk(storeDir);
83
+ return latest;
84
+ }
85
+ /**
86
+ * Evaluate the spec §5.8 abandoned conditions:
87
+ * - the owner `pid` is no longer alive (definitive — the writer is gone), OR
88
+ * - `heartbeat_at` is older than `stale_after_ms` AND no file under the store
89
+ * changed after the last heartbeat (a silent-but-alive pid might still be
90
+ * mid-operation, so the store-change guard protects only this path).
91
+ *
92
+ * IMPORTANT: the store-change guard must NOT gate the dead-pid path. A process
93
+ * that crashed mid-refresh after writing a shard (store mtime > heartbeat) is
94
+ * still definitively dead; gating it would freeze Code Map until an operator
95
+ * intervened, violating the operator-free crash-recovery guarantee (§6 rule 1,
96
+ * §12.3).
97
+ */
98
+ export function isLockAbandoned(existing, opts) {
99
+ const pidDead = !opts.isPidAlive(existing.pid);
100
+ // Dead owner → reclaim unconditionally. No live writer can exist.
101
+ if (pidDead)
102
+ return true;
103
+ const heartbeatMs = Date.parse(existing.heartbeat_at);
104
+ const heartbeatStale = Number.isFinite(heartbeatMs) && opts.now - heartbeatMs > existing.stale_after_ms;
105
+ if (!heartbeatStale)
106
+ return false;
107
+ // Stale heartbeat but the pid is (or appears) alive: if the store changed
108
+ // after the last heartbeat, a writer may still be mid-operation despite an
109
+ // old heartbeat timestamp — do not reclaim.
110
+ const latestChange = latestStoreMtimeMs(opts.storeDir, opts.lockFile);
111
+ if (latestChange > heartbeatMs)
112
+ return false;
113
+ return true;
114
+ }
115
+ function buildLock(input, nowMs) {
116
+ const iso = new Date(nowMs).toISOString();
117
+ return CodeLockSchema.parse({
118
+ schema_version: CODE_MAP_SCHEMA_VERSION,
119
+ lock_id: `lock_${crypto.randomBytes(8).toString('hex')}`,
120
+ project_id: input.projectId ?? null,
121
+ worktree_id: input.worktreeId ?? null,
122
+ owner_agent: input.ownerAgent ?? null,
123
+ owner_agent_id: input.ownerAgentId ?? null,
124
+ pid: process.pid,
125
+ operation: input.operation ?? 'refresh',
126
+ scope: input.scope ?? 'changed',
127
+ created_at: iso,
128
+ heartbeat_at: iso,
129
+ stale_after_ms: input.staleAfterMs ?? DEFAULT_STALE_AFTER_MS,
130
+ });
131
+ }
132
+ /**
133
+ * Acquire the Code Map lock. Returns a handle on success, or `null` when a
134
+ * *live* lock is held by another owner.
135
+ *
136
+ * Exclusive-create path: `open(...'wx')` fails with EEXIST if a lock already
137
+ * exists, which guarantees we never truncate a competitor's live lock.
138
+ */
139
+ export function acquireCodeLock(input = {}) {
140
+ const isPidAlive = input.isPidAlive ?? defaultIsPidAlive;
141
+ const now = input.now ?? Date.now;
142
+ const storeDir = codeMapDir(input.cwd, input.preferredDirName);
143
+ const lockFile = lockPath(input.cwd, input.preferredDirName);
144
+ if (!fs.existsSync(storeDir))
145
+ fs.mkdirSync(storeDir, { recursive: true });
146
+ const lock = buildLock(input, now());
147
+ const serialized = JSON.stringify(lock, null, 2);
148
+ // 1. Exclusive create. 'wx' never truncates an existing file (spec §6 rule 1).
149
+ try {
150
+ fs.writeFileSync(lockFile, serialized, { encoding: 'utf-8', flag: 'wx' });
151
+ return { lock, lockPath: lockFile };
152
+ }
153
+ catch (err) {
154
+ const code = err instanceof Error && 'code' in err ? err.code : undefined;
155
+ if (code !== 'EEXIST' && code !== 'EPERM' && code !== 'EACCES')
156
+ throw err;
157
+ }
158
+ // 2. A lock exists — read it and evaluate abandonment.
159
+ const existing = readLock(lockFile);
160
+ if (!existing) {
161
+ // Unreadable/corrupt lock — treat as abandoned and take over atomically.
162
+ writeFileAtomic(lockFile, serialized);
163
+ logger.debug(`code-map lock: reclaimed unreadable lock at ${lockFile}`);
164
+ return { lock, lockPath: lockFile };
165
+ }
166
+ // Re-entrant: same process already owns it -> refresh heartbeat, reuse.
167
+ if (existing.pid === process.pid) {
168
+ const refreshed = { ...existing, heartbeat_at: new Date(now()).toISOString() };
169
+ writeFileAtomic(lockFile, JSON.stringify(refreshed, null, 2));
170
+ return { lock: refreshed, lockPath: lockFile };
171
+ }
172
+ const abandoned = isLockAbandoned(existing, { now: now(), isPidAlive, storeDir, lockFile });
173
+ if (!abandoned) {
174
+ // Live lock blocks.
175
+ return null;
176
+ }
177
+ // 3. Abandoned takeover via atomic rename (writeFileAtomic does temp+rename
178
+ // with EPERM/EBUSY backoff — the NTFS-safe path).
179
+ writeFileAtomic(lockFile, serialized);
180
+ logger.debug(`code-map lock: reclaimed abandoned lock (prior owner agent=${existing.owner_agent ?? 'unknown'} ` +
181
+ `pid=${existing.pid} operation=${existing.operation} heartbeat_at=${existing.heartbeat_at})`);
182
+ return { lock, lockPath: lockFile };
183
+ }
184
+ /** Refresh the heartbeat on a held lock (spec §6 rule 2 — at least every 10s). */
185
+ export function heartbeatCodeLock(handle, now = Date.now) {
186
+ const current = readLock(handle.lockPath);
187
+ // Only refresh if we still own it (pid match).
188
+ if (!current || current.pid !== process.pid)
189
+ return handle;
190
+ const refreshed = { ...current, heartbeat_at: new Date(now()).toISOString() };
191
+ writeFileAtomic(handle.lockPath, JSON.stringify(refreshed, null, 2));
192
+ return { lock: refreshed, lockPath: handle.lockPath };
193
+ }
194
+ /** Release a held lock. Only removes the file if this process still owns it. */
195
+ export function releaseCodeLock(handle) {
196
+ const current = readLock(handle.lockPath);
197
+ if (current && current.pid !== process.pid)
198
+ return; // someone reclaimed it; leave it.
199
+ try {
200
+ fs.unlinkSync(handle.lockPath);
201
+ }
202
+ catch {
203
+ /* already gone */
204
+ }
205
+ }
206
+ /** Read the current lock, if any (for status / doctor). */
207
+ export function readCodeLock(cwd, preferredDirName) {
208
+ return readLock(lockPath(cwd, preferredDirName));
209
+ }
210
+ //# sourceMappingURL=lock.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Materialized JSONL cache (spec §4, §6 rule 5).
3
+ *
4
+ * nodes.v1.jsonl + edges.v1.jsonl are REBUILDABLE outputs with deterministic
5
+ * ordering. They exist for inspection / export / bulk scans only — queries must
6
+ * never depend on them. They are ignored by project generated-ignore rules by
7
+ * default, so they should not be committed.
8
+ */
9
+ import fs from 'node:fs';
10
+ import { writeFileAtomic } from '../io.js';
11
+ import { materializedDir, materializedNodesPath, materializedEdgesPath } from './paths.js';
12
+ function ensureDir(dir) {
13
+ if (!fs.existsSync(dir))
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ }
16
+ /** Deterministic node ordering: by path, then id. */
17
+ function orderedNodes(shards) {
18
+ const ordered = [...shards].sort((a, b) => a.path.localeCompare(b.path));
19
+ const out = [];
20
+ for (const shard of ordered) {
21
+ const nodes = [...shard.nodes].sort((a, b) => a.id.localeCompare(b.id));
22
+ out.push(...nodes);
23
+ }
24
+ return out;
25
+ }
26
+ /** Deterministic edge ordering: by from, to, kind, id. */
27
+ function orderedEdges(shards) {
28
+ const ordered = [...shards].sort((a, b) => a.path.localeCompare(b.path));
29
+ const out = [];
30
+ for (const shard of ordered) {
31
+ const edges = [...shard.edges].sort((a, b) => a.from.localeCompare(b.from) ||
32
+ a.to.localeCompare(b.to) ||
33
+ a.kind.localeCompare(b.kind) ||
34
+ a.id.localeCompare(b.id));
35
+ out.push(...edges);
36
+ }
37
+ return out;
38
+ }
39
+ function toJsonl(rows) {
40
+ return rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '');
41
+ }
42
+ /**
43
+ * Rebuild both materialized JSONL files from shards, atomically. Safe to call
44
+ * after every refresh; cheap relative to parsing.
45
+ */
46
+ export function rebuildMaterialized(shards, cwd, preferredDirName) {
47
+ ensureDir(materializedDir(cwd, preferredDirName));
48
+ writeFileAtomic(materializedNodesPath(cwd, preferredDirName), toJsonl(orderedNodes(shards)));
49
+ writeFileAtomic(materializedEdgesPath(cwd, preferredDirName), toJsonl(orderedEdges(shards)));
50
+ }
51
+ //# sourceMappingURL=materialized.js.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Default brainclaw-memory reader for Code Map `brief()` related-memory
3
+ * attachment (spec §11).
4
+ *
5
+ * This is the production wiring of the `MemoryReader` seam declared in
6
+ * query.ts. It reuses the canonical entity read path (`listEntities`) — the
7
+ * same one `bclaw_find`/`bclaw_context` use — so related-memory attachment sees
8
+ * exactly the decisions/traps/constraints/plans an agent would see elsewhere,
9
+ * with no duplicated store logic.
10
+ *
11
+ * The seam is deliberately injectable: tests construct `RelatedMemoryItem[]`
12
+ * directly instead of standing up a full entity store, and the backend can swap
13
+ * this for a cross-project or budget-bounded reader later without touching the
14
+ * ranking logic in query.ts.
15
+ */
16
+ import { listEntities } from '../entity-operations.js';
17
+ /** Entity kinds carrying path/tag context useful to a code-scope brief (spec §11). */
18
+ const MEMORY_ENTITY_KINDS = ['decision', 'trap', 'constraint', 'plan'];
19
+ function coerceTags(raw) {
20
+ if (Array.isArray(raw))
21
+ return raw.filter((t) => typeof t === 'string');
22
+ return [];
23
+ }
24
+ function coercePaths(raw) {
25
+ if (Array.isArray(raw))
26
+ return raw.filter((p) => typeof p === 'string');
27
+ return [];
28
+ }
29
+ function toRelatedMemoryItem(kind, item) {
30
+ return {
31
+ id: typeof item.id === 'string' ? item.id : '',
32
+ kind,
33
+ text: typeof item.text === 'string' ? item.text : '',
34
+ tags: coerceTags(item.tags),
35
+ related_paths: coercePaths(item.related_paths),
36
+ };
37
+ }
38
+ /**
39
+ * Production `MemoryReader`: read decisions/traps/constraints/plans for the
40
+ * project at `ctx.cwd` via the canonical entity read path. Best-effort — a read
41
+ * failure for one kind never breaks a brief; it simply contributes no memory.
42
+ */
43
+ export const defaultMemoryReader = (ctx) => {
44
+ const cwd = ctx.cwd ?? process.cwd();
45
+ const out = [];
46
+ for (const kind of MEMORY_ENTITY_KINDS) {
47
+ try {
48
+ const result = listEntities(kind, cwd, { includeLegacy: true });
49
+ for (const raw of result.items) {
50
+ out.push(toRelatedMemoryItem(kind, raw));
51
+ }
52
+ }
53
+ catch {
54
+ // best-effort: a brief never fails because one memory kind couldn't load.
55
+ }
56
+ }
57
+ return out;
58
+ };
59
+ //# sourceMappingURL=memory-reader.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Code Map store layout path helpers (spec §4).
3
+ *
4
+ * All paths are relative to `<project>/.brainclaw/code/`. The store root is
5
+ * derived from the Brainclaw memory dir convention (`.brainclaw/`).
6
+ */
7
+ import path from 'node:path';
8
+ import { MEMORY_DIR } from '../io.js';
9
+ import { shardPrefix } from './ids.js';
10
+ /** Subdirectory under `.brainclaw/` that holds the Code Map store. */
11
+ export const CODE_MAP_SUBDIR = 'code';
12
+ /** Absolute path to `<project>/.brainclaw/code/`. */
13
+ export function codeMapDir(cwd = process.cwd(), preferredDirName) {
14
+ return path.join(cwd, preferredDirName ?? MEMORY_DIR, CODE_MAP_SUBDIR);
15
+ }
16
+ export function manifestPath(cwd, preferredDirName) {
17
+ return path.join(codeMapDir(cwd, preferredDirName), 'manifest.json');
18
+ }
19
+ export function profilerPath(cwd, preferredDirName) {
20
+ return path.join(codeMapDir(cwd, preferredDirName), 'profiler.json');
21
+ }
22
+ export function lockPath(cwd, preferredDirName) {
23
+ return path.join(codeMapDir(cwd, preferredDirName), '.lock');
24
+ }
25
+ export function filesDir(cwd, preferredDirName) {
26
+ return path.join(codeMapDir(cwd, preferredDirName), 'files');
27
+ }
28
+ /** Absolute path to a per-file shard: files/<prefix>/<file_id>.json. */
29
+ export function shardPath(fileIdHash, cwd, preferredDirName) {
30
+ return path.join(filesDir(cwd, preferredDirName), shardPrefix(fileIdHash), `${fileIdHash}.json`);
31
+ }
32
+ export function indexesDir(cwd, preferredDirName) {
33
+ return path.join(codeMapDir(cwd, preferredDirName), 'indexes');
34
+ }
35
+ export function symbolsIndexPath(cwd, preferredDirName) {
36
+ return path.join(indexesDir(cwd, preferredDirName), 'index.symbols.v1.json');
37
+ }
38
+ export function importsIndexPath(cwd, preferredDirName) {
39
+ return path.join(indexesDir(cwd, preferredDirName), 'index.imports.v1.json');
40
+ }
41
+ export function resolutionIndexPath(cwd, preferredDirName) {
42
+ return path.join(indexesDir(cwd, preferredDirName), 'index.resolution.v1.json');
43
+ }
44
+ export function materializedDir(cwd, preferredDirName) {
45
+ return path.join(codeMapDir(cwd, preferredDirName), 'materialized');
46
+ }
47
+ export function materializedNodesPath(cwd, preferredDirName) {
48
+ return path.join(materializedDir(cwd, preferredDirName), 'nodes.v1.jsonl');
49
+ }
50
+ export function materializedEdgesPath(cwd, preferredDirName) {
51
+ return path.join(materializedDir(cwd, preferredDirName), 'edges.v1.jsonl');
52
+ }
53
+ //# sourceMappingURL=paths.js.map