brainclaw 1.16.0 → 1.18.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.
- package/README.md +22 -8
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-review.js +2 -2
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +241 -25
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-coordination.js +159 -40
- package/dist/commands/mcp.js +11 -2
- package/dist/core/agent-capability.js +7 -2
- package/dist/core/agent-files.js +53 -2
- package/dist/core/agent-integrations.js +1 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +209 -29
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/ideation-loop-close.js +124 -0
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +9 -3
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/review-loop-close.js +106 -34
- package/dist/core/review-loop-turn-dispatch.js +445 -0
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +34 -0
- package/dist/core/worktree.js +240 -22
- package/dist/facts.js +10 -10
- package/dist/facts.json +9 -9
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +28 -2
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/integrations/codex.md +19 -3
- package/docs/mcp-schema-changelog.md +43 -1
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { hashContent } from './extractor.js';
|
|
15
|
+
import { coarseFreshness } from './freshness.js';
|
|
15
16
|
import { readImportsIndex, readManifest, readResolutionIndex, readShard, readSymbolsIndex, } from './store.js';
|
|
16
17
|
// --- lazy read-path freshness budget (spec §6.1) ---
|
|
17
18
|
/** Default per-query lazy-check budget (spec §6.1). */
|
|
@@ -26,7 +27,7 @@ export const LAZY_BUDGET = {
|
|
|
26
27
|
* per-path memoization so a brief() that touches one file from several ranking
|
|
27
28
|
* signals spends a single budget slot.
|
|
28
29
|
*/
|
|
29
|
-
function makeLazyChecker(budget = LAZY_BUDGET) {
|
|
30
|
+
export function makeLazyChecker(budget = LAZY_BUDGET) {
|
|
30
31
|
return {
|
|
31
32
|
budget,
|
|
32
33
|
startedAt: Date.now(),
|
|
@@ -47,7 +48,7 @@ function budgetExhausted(checker) {
|
|
|
47
48
|
}
|
|
48
49
|
return checker.exhausted;
|
|
49
50
|
}
|
|
50
|
-
function newAccumulator() {
|
|
51
|
+
export function newAccumulator() {
|
|
51
52
|
return {
|
|
52
53
|
staleChangedPaths: new Set(),
|
|
53
54
|
missingPaths: new Set(),
|
|
@@ -62,7 +63,16 @@ function newAccumulator() {
|
|
|
62
63
|
* served as a *confident* (fresh) result.
|
|
63
64
|
*/
|
|
64
65
|
function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd, preferredDirName) {
|
|
65
|
-
|
|
66
|
+
// pln#631 — memo key scoped by the store's cwd. Two collision hazards must both be
|
|
67
|
+
// avoided when a shared checker spans a workspace: keying by PATH collides two
|
|
68
|
+
// packages' same-named `src/index.ts`; keying by file_id ALONE collides when two
|
|
69
|
+
// stores share a project_id (the `prj_${basename}` fallback, or a copied
|
|
70
|
+
// `.brainclaw/config.yaml`) — file_id = sha256(project_id + rel_path), so a shared
|
|
71
|
+
// id makes the file_id identical too (review F1). Scoping by the store's cwd (unique
|
|
72
|
+
// per store) is collision-proof either way; single-store keeps cwd constant, so
|
|
73
|
+
// behavior is identical to before.
|
|
74
|
+
const memoKey = `${cwd ?? ''} ${entry.file_id}`;
|
|
75
|
+
const cached = checker.memo.get(memoKey);
|
|
66
76
|
if (cached !== undefined)
|
|
67
77
|
return cached;
|
|
68
78
|
const abs = path.join(projectRoot, entry.path);
|
|
@@ -72,19 +82,19 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
|
|
|
72
82
|
}
|
|
73
83
|
catch {
|
|
74
84
|
acc.missingPaths.add(entry.path); // §6.1.2 — deletion.
|
|
75
|
-
checker.memo.set(
|
|
85
|
+
checker.memo.set(memoKey, false);
|
|
76
86
|
return false;
|
|
77
87
|
}
|
|
78
88
|
const shard = readShard(entry.file_id, cwd, preferredDirName);
|
|
79
89
|
if (!shard) {
|
|
80
90
|
// No backing shard to compare against — treat as unchecked, not confident.
|
|
81
91
|
acc.uncheckedPaths.add(entry.path);
|
|
82
|
-
checker.memo.set(
|
|
92
|
+
checker.memo.set(memoKey, false);
|
|
83
93
|
return false;
|
|
84
94
|
}
|
|
85
95
|
// §6.1.3 — cheap gate: mtime + size match => fresh for this read.
|
|
86
96
|
if (stat.mtimeMs === shard.mtime_ms && stat.size === shard.size_bytes) {
|
|
87
|
-
checker.memo.set(
|
|
97
|
+
checker.memo.set(memoKey, true);
|
|
88
98
|
return true;
|
|
89
99
|
}
|
|
90
100
|
// §6.1.4/§6.1.6 — gate tripped: hash only when within budget AND not oversized.
|
|
@@ -93,13 +103,13 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
|
|
|
93
103
|
// `partial`. Keep them separable so the badge reason is accurate.
|
|
94
104
|
if (stat.size > maxParseFileBytes) {
|
|
95
105
|
acc.uncheckedPaths.add(entry.path); // structurally unverifiable, not budget.
|
|
96
|
-
checker.memo.set(
|
|
106
|
+
checker.memo.set(memoKey, false);
|
|
97
107
|
return false;
|
|
98
108
|
}
|
|
99
109
|
if (budgetExhausted(checker)) {
|
|
100
110
|
acc.uncheckedPaths.add(entry.path);
|
|
101
111
|
acc.budgetSkippedPaths.add(entry.path);
|
|
102
|
-
checker.memo.set(
|
|
112
|
+
checker.memo.set(memoKey, false);
|
|
103
113
|
return false;
|
|
104
114
|
}
|
|
105
115
|
checker.filesChecked++;
|
|
@@ -109,17 +119,30 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
|
|
|
109
119
|
}
|
|
110
120
|
catch {
|
|
111
121
|
acc.uncheckedPaths.add(entry.path);
|
|
112
|
-
checker.memo.set(
|
|
122
|
+
checker.memo.set(memoKey, false);
|
|
113
123
|
return false;
|
|
114
124
|
}
|
|
115
125
|
if (hashContent(live) === shard.file_hash) {
|
|
116
|
-
checker.memo.set(
|
|
126
|
+
checker.memo.set(memoKey, true); // §6.1 — identical despite mtime touch.
|
|
117
127
|
return true;
|
|
118
128
|
}
|
|
119
129
|
acc.staleChangedPaths.add(entry.path); // §6.1.5 — confirmed content change.
|
|
120
|
-
checker.memo.set(
|
|
130
|
+
checker.memo.set(memoKey, false);
|
|
121
131
|
return false;
|
|
122
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* pln#631 PR3 — validate a single file entry against a SPECIFIC store's live tree
|
|
135
|
+
* (resolves that store's root + parse budget from its manifest). Lets the cross-package
|
|
136
|
+
* scan lazy-validate importer rows from a sibling store and drop deleted/stale ones,
|
|
137
|
+
* upholding the same "no silent stale graph hints" rule intra-package graph rows obey.
|
|
138
|
+
* Shares the caller's checker (one budget) + records into the caller's acc.
|
|
139
|
+
*/
|
|
140
|
+
export function validateStoreEntry(entry, checker, acc, cwd, preferredDirName) {
|
|
141
|
+
const manifest = readManifest(cwd, preferredDirName);
|
|
142
|
+
const root = manifest?.project_root ?? cwd ?? process.cwd();
|
|
143
|
+
const maxBytes = manifest?.extractor_config.max_parse_file_bytes ?? 1024 * 1024;
|
|
144
|
+
return validateEntry(entry, checker, acc, root, maxBytes, cwd, preferredDirName);
|
|
145
|
+
}
|
|
123
146
|
/**
|
|
124
147
|
* Derive the response freshness badge from the base manifest status + the
|
|
125
148
|
* outcomes recorded during this query's lazy check (spec §6.1, §9).
|
|
@@ -127,7 +150,7 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
|
|
|
127
150
|
* Precedence: an exhausted budget yields `partial`; otherwise any detected
|
|
128
151
|
* change/deletion yields `stale_changed_files`; else the manifest base status.
|
|
129
152
|
*/
|
|
130
|
-
function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex) {
|
|
153
|
+
export function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex) {
|
|
131
154
|
const details = {};
|
|
132
155
|
if (acc.staleChangedPaths.size > 0) {
|
|
133
156
|
details.stale_changed_files = [...acc.staleChangedPaths].sort();
|
|
@@ -167,7 +190,7 @@ function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex)
|
|
|
167
190
|
if (status !== base)
|
|
168
191
|
details.index_status = base;
|
|
169
192
|
void hadConfidentMatch;
|
|
170
|
-
return { status, details };
|
|
193
|
+
return { status, coarse: coarseFreshness(status), details };
|
|
171
194
|
}
|
|
172
195
|
const DEFAULT_FIND_LIMIT = 20;
|
|
173
196
|
/** Lowercase token normalization mirroring indexes.ts (spec §5.6 keys). */
|
|
@@ -185,25 +208,73 @@ function queryTokens(query) {
|
|
|
185
208
|
return [...tokens];
|
|
186
209
|
}
|
|
187
210
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
211
|
+
* pln#601 — normalize an identifier to a separator/case-INSENSITIVE canonical
|
|
212
|
+
* form (strip `_`, `-`, `.`, camelCase boundaries collapse to nothing; lowercase).
|
|
213
|
+
* `EntityRegistry`, `ENTITY_REGISTRY`, and `entity-registry` all → `entityregistry`.
|
|
214
|
+
* Without this, `scoreEntry` compared raw-lowercased strings, so a Pascal-case
|
|
215
|
+
* query `EntityRegistry` failed to exact/prefix/substring-match the snake_case
|
|
216
|
+
* `ENTITY_REGISTRY` and dropped it to the sub-token floor (score 1) alongside 19
|
|
217
|
+
* unrelated `*Registry` symbols — the Fable-audit "all results score 1 → the agent
|
|
218
|
+
* re-greps" defect that undercuts the whole "stop grepping blind" value prop.
|
|
219
|
+
*/
|
|
220
|
+
function normIdent(s) {
|
|
221
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* pln#601 — a path that is a test/spec file (its symbols must not outrank the real
|
|
225
|
+
* def, and it should not crowd source out of a brief). Recognizes conventions across
|
|
226
|
+
* ALL supported languages (JS/TS + py/php/java/go/rust/c#/ruby/c/c++), so the
|
|
227
|
+
* source-over-test bias actually works for the polyglot langs, not just JS/TS.
|
|
228
|
+
* Exported for the classification regression tests.
|
|
191
229
|
*/
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
|
|
230
|
+
export function isTestPath(p) {
|
|
231
|
+
// Directory conventions (all langs): tests/, test/, __tests__/, spec(s)/ (Ruby
|
|
232
|
+
// RSpec), __mocks__/. NOTE: a source dir literally named `spec/` (e.g. an OpenAPI
|
|
233
|
+
// `spec/` folder) is an accepted false-positive — RSpec `spec/` is the commoner
|
|
234
|
+
// meaning now that Ruby is supported, and path alone can't disambiguate.
|
|
235
|
+
if (/(?:^|[\\/])(?:tests?|__tests__|specs?|__mocks__)[\\/]/i.test(p))
|
|
236
|
+
return true;
|
|
237
|
+
const base = p.replace(/\\/g, '/').split('/').pop() ?? p;
|
|
238
|
+
return (
|
|
239
|
+
// separator-suffixed: foo.test.ts, foo_test.go, foo-spec.js, foo_spec.rb, foo_test.py
|
|
240
|
+
/[._-](?:test|spec)\.(?:[cm]?[jt]sx?|py|rb|go|php|java|cs|rs)$/i.test(base) ||
|
|
241
|
+
// pytest / minitest prefix: test_foo.py, test_foo.rb
|
|
242
|
+
/^test_.+\.(?:py|rb)$/i.test(base) ||
|
|
243
|
+
// bare test/spec file: test.ts, spec.rb
|
|
244
|
+
/^(?:test|spec)\.(?:[cm]?[jt]sx?|py|rb|go)$/i.test(base) ||
|
|
245
|
+
// xUnit / JUnit PascalCase suffix: FooTest.cs, BarTests.cs, BazTest.java
|
|
246
|
+
// (case-SENSITIVE capital T so `contest.cs` / `latest.cs` are not false hits)
|
|
247
|
+
/Tests?\.(?:cs|java)$/.test(base));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Score a symbol index entry against the query. Matching is separator/case
|
|
251
|
+
* INSENSITIVE (pln#601): an exact NORMALIZED match scores highest, then prefix,
|
|
252
|
+
* then substring, then the sub-token floor. Exported symbols + components/hooks
|
|
253
|
+
* get a small boost; test-file symbols are biased DOWN so a test helper never
|
|
254
|
+
* outranks the real definition of the same name (the Fable-audit brief-noise
|
|
255
|
+
* companion to the find defect). Exported for focused ranking tests.
|
|
256
|
+
*/
|
|
257
|
+
export function scoreEntry(entry, query) {
|
|
258
|
+
const q = normIdent(query);
|
|
259
|
+
const name = normIdent(entry.name);
|
|
195
260
|
let score = 0;
|
|
196
|
-
if (
|
|
197
|
-
score +=
|
|
261
|
+
if (q.length === 0)
|
|
262
|
+
score += 1;
|
|
263
|
+
else if (name === q)
|
|
264
|
+
score += 10; // exact, style-insensitive
|
|
198
265
|
else if (name.startsWith(q))
|
|
199
|
-
score += 6;
|
|
266
|
+
score += 6; // prefix
|
|
200
267
|
else if (name.includes(q))
|
|
201
|
-
score += 3;
|
|
268
|
+
score += 3; // substring
|
|
202
269
|
else
|
|
203
270
|
score += 1; // matched only via a sub-token bucket
|
|
204
271
|
score *= entry.score_hint; // exported (1.0) vs internal (0.8)
|
|
205
272
|
if (entry.subtype === 'component' || entry.subtype === 'hook')
|
|
206
273
|
score += 1;
|
|
274
|
+
// pln#601 — source over test: a test/spec symbol of the same name must not
|
|
275
|
+
// outrank the real definition (keeps find/brief pointing at source first).
|
|
276
|
+
if (isTestPath(entry.path))
|
|
277
|
+
score *= 0.4;
|
|
207
278
|
return score;
|
|
208
279
|
}
|
|
209
280
|
function resolveRoot(ctx) {
|
|
@@ -237,20 +308,22 @@ function gatherSymbolEntries(index, query) {
|
|
|
237
308
|
}
|
|
238
309
|
return out;
|
|
239
310
|
}
|
|
240
|
-
|
|
241
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Store-local find CORE (pln#631): gather → lazy-validate → score → sort, with NO
|
|
313
|
+
* cap and NO badge. Accepts an INJECTED checker + acc so a workspace aggregation can
|
|
314
|
+
* share ONE lazy budget across stores (the checker's memo is file_id-keyed, unique
|
|
315
|
+
* per store, so sharing never collides same-named files across packages). `find()`
|
|
316
|
+
* wraps this with a fresh checker/acc + cap + badge for the unchanged single-store
|
|
317
|
+
* path; `aggregate.ts` fans it out across stores with a shared checker.
|
|
318
|
+
*/
|
|
319
|
+
export function findInStore(query, ctx, checker, acc) {
|
|
242
320
|
const index = readSymbolsIndex(ctx.cwd, ctx.preferredDirName);
|
|
243
321
|
if (!index) {
|
|
244
|
-
return {
|
|
245
|
-
query,
|
|
246
|
-
matches: [],
|
|
247
|
-
freshness_badge: { status: 'missing_index', details: { hint: 'run refresh' } },
|
|
248
|
-
};
|
|
322
|
+
return { matches: [], base: 'missing_index', hasIndex: false, emptyCandidates: true, acc };
|
|
249
323
|
}
|
|
324
|
+
const base = baseStatus(ctx);
|
|
250
325
|
const root = resolveRoot(ctx);
|
|
251
326
|
const maxBytes = maxParseBytes(ctx);
|
|
252
|
-
const checker = makeLazyChecker();
|
|
253
|
-
const acc = newAccumulator();
|
|
254
327
|
const candidates = gatherSymbolEntries(index, query);
|
|
255
328
|
const ranked = [];
|
|
256
329
|
for (const entry of candidates) {
|
|
@@ -269,8 +342,21 @@ export function find(query, limit, ctx) {
|
|
|
269
342
|
});
|
|
270
343
|
}
|
|
271
344
|
ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
|
|
272
|
-
|
|
273
|
-
|
|
345
|
+
return { matches: ranked, base, hasIndex: true, emptyCandidates: candidates.length === 0, acc };
|
|
346
|
+
}
|
|
347
|
+
export function find(query, limit, ctx) {
|
|
348
|
+
const checker = makeLazyChecker();
|
|
349
|
+
const acc = newAccumulator();
|
|
350
|
+
const r = findInStore(query, ctx, checker, acc);
|
|
351
|
+
if (!r.hasIndex) {
|
|
352
|
+
return {
|
|
353
|
+
query,
|
|
354
|
+
matches: [],
|
|
355
|
+
freshness_badge: { status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } },
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const capped = r.matches.slice(0, limit ?? DEFAULT_FIND_LIMIT);
|
|
359
|
+
const badge = deriveBadge(r.base, acc, checker.exhausted, capped.length > 0, r.emptyCandidates);
|
|
274
360
|
return { query, matches: capped, freshness_badge: badge };
|
|
275
361
|
}
|
|
276
362
|
/** spec §11 — cap related memory at top 5 by relevance. */
|
|
@@ -399,6 +485,42 @@ function rankFiles(defining, forwardRows, reverseRows, symbolsIndex, importsInde
|
|
|
399
485
|
}
|
|
400
486
|
return [...byPath.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
401
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* pln#601 — reserve most of the §9 reading list for SOURCE files. On a symbol with
|
|
490
|
+
* many test importers, the reverse-dependent test files (blast radius, +5 each) can
|
|
491
|
+
* fill the entire {@link BRIEF_FILE_CAP} and crowd out the source files an agent needs
|
|
492
|
+
* to understand the symbol — the Fable-audit brief-noise defect. At most `maxTest`
|
|
493
|
+
* test files (~1/4 of the cap, min 2) are admitted UNLESS there aren't enough non-test
|
|
494
|
+
* files to fill the cap, in which case the deferred tests backfill the empty slots so
|
|
495
|
+
* the list is never artificially short. Defining files ARE the target and are never
|
|
496
|
+
* counted as noise (a symbol legitimately defined in a test file still leads the list).
|
|
497
|
+
* Input is pre-sorted by score; output preserves that order within each bucket.
|
|
498
|
+
*/
|
|
499
|
+
export function reserveSourceSlots(ranked, cap, definingPaths) {
|
|
500
|
+
const maxTest = Math.max(2, Math.floor(cap / 4)); // e.g. 3 of 12
|
|
501
|
+
const out = [];
|
|
502
|
+
const overflowTests = [];
|
|
503
|
+
let testsIncluded = 0;
|
|
504
|
+
for (const rf of ranked) {
|
|
505
|
+
if (out.length >= cap)
|
|
506
|
+
break;
|
|
507
|
+
const isNoise = isTestPath(rf.path) && !definingPaths.has(rf.path);
|
|
508
|
+
if (isNoise && testsIncluded >= maxTest) {
|
|
509
|
+
overflowTests.push(rf);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
out.push(rf);
|
|
513
|
+
if (isNoise)
|
|
514
|
+
testsIncluded++;
|
|
515
|
+
}
|
|
516
|
+
// Non-test ran out before the cap → backfill the empty slots with deferred tests.
|
|
517
|
+
for (const rf of overflowTests) {
|
|
518
|
+
if (out.length >= cap)
|
|
519
|
+
break;
|
|
520
|
+
out.push(rf);
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
402
524
|
/** Build a node-id → symbol index entry map (deduped; entries repeat across token buckets). */
|
|
403
525
|
function buildNodeIdIndex(symbolsIndex) {
|
|
404
526
|
const out = new Map();
|
|
@@ -495,49 +617,64 @@ function filesMatchingPath(symbolsIndex, target) {
|
|
|
495
617
|
}
|
|
496
618
|
return out;
|
|
497
619
|
}
|
|
498
|
-
|
|
499
|
-
|
|
620
|
+
/**
|
|
621
|
+
* Store-local brief CORE (pln#631 PR2): resolve the target → graph signals →
|
|
622
|
+
* rankFiles → confident list, with NO cap, NO badge, NO related-memory attach.
|
|
623
|
+
* Accepts an INJECTED checker + acc so a workspace aggregation can share ONE lazy
|
|
624
|
+
* budget across stores (memo is cwd-scoped, so sharing is collision-safe). `brief()`
|
|
625
|
+
* wraps this with a fresh checker/acc + reserve + memory + badge for the unchanged
|
|
626
|
+
* single-store path; `aggregate.ts` fans it out and merges target-defining stores.
|
|
627
|
+
*/
|
|
628
|
+
export function briefInStore(target, ctx, checker, acc) {
|
|
500
629
|
const symbolsIndex = readSymbolsIndex(ctx.cwd, ctx.preferredDirName);
|
|
501
630
|
if (!symbolsIndex) {
|
|
502
631
|
return {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
632
|
+
defining: [],
|
|
633
|
+
definingPaths: new Set(),
|
|
634
|
+
matchKind: 'none',
|
|
635
|
+
confident: [],
|
|
636
|
+
base: 'missing_index',
|
|
637
|
+
hasIndex: false,
|
|
638
|
+
emptyRanked: true,
|
|
639
|
+
acc,
|
|
507
640
|
};
|
|
508
641
|
}
|
|
642
|
+
const base = baseStatus(ctx);
|
|
509
643
|
const importsIndex = readImportsIndex(ctx.cwd, ctx.preferredDirName);
|
|
510
644
|
const resolutionIndex = readResolutionIndex(ctx.cwd, ctx.preferredDirName);
|
|
511
645
|
// Resolve target -> defining symbol entries. A brief orients on a SPECIFIC target,
|
|
512
646
|
// so prefer EXACT name matches when present — otherwise the token index floods the
|
|
513
|
-
// result with unrelated same-token symbols
|
|
514
|
-
//
|
|
515
|
-
//
|
|
647
|
+
// result with unrelated same-token symbols. Fall back to the fuzzy token set, then
|
|
648
|
+
// a path match. `matchKind` records which path won so an aggregation can prefer the
|
|
649
|
+
// stores that actually DEFINE the target over stores that only fuzzy-match a token.
|
|
516
650
|
let defining;
|
|
651
|
+
let matchKind;
|
|
517
652
|
if (looksLikePathTarget(target)) {
|
|
518
|
-
// PATH target (pln#593 1b): resolve the exact file; the graph signals (its
|
|
519
|
-
// imports / dependents / direct tests) then rank below it via rankFiles. Skip
|
|
520
|
-
// the fuzzy token gather entirely — it floods a path brief with same-token
|
|
521
|
-
// noise. Degrade to the fuzzy set only if the path resolves to nothing indexed.
|
|
522
653
|
defining = filesMatchingPath(symbolsIndex, target);
|
|
523
|
-
if (defining.length
|
|
654
|
+
if (defining.length > 0)
|
|
655
|
+
matchKind = 'path';
|
|
656
|
+
else {
|
|
524
657
|
defining = gatherSymbolEntries(symbolsIndex, target);
|
|
658
|
+
matchKind = defining.length > 0 ? 'fuzzy' : 'none';
|
|
659
|
+
}
|
|
525
660
|
}
|
|
526
661
|
else {
|
|
527
662
|
defining = gatherSymbolEntries(symbolsIndex, target);
|
|
528
663
|
const exact = defining.filter((e) => e.name.toLowerCase() === target.toLowerCase());
|
|
529
|
-
if (exact.length > 0)
|
|
664
|
+
if (exact.length > 0) {
|
|
530
665
|
defining = exact;
|
|
531
|
-
|
|
666
|
+
matchKind = 'exact';
|
|
667
|
+
}
|
|
668
|
+
else if (defining.length > 0) {
|
|
669
|
+
matchKind = 'fuzzy';
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
532
672
|
defining = filesMatchingPath(symbolsIndex, target);
|
|
673
|
+
matchKind = defining.length > 0 ? 'path' : 'none';
|
|
674
|
+
}
|
|
533
675
|
}
|
|
534
676
|
const root = resolveRoot(ctx);
|
|
535
677
|
const maxBytes = maxParseBytes(ctx);
|
|
536
|
-
const checker = makeLazyChecker();
|
|
537
|
-
const acc = newAccumulator();
|
|
538
|
-
// P1d graph signals. FORWARD: read from defining shards — but only CONFIDENT ones
|
|
539
|
-
// (validate first; a stale importer shard's edge list is not trusted). REVERSE: from
|
|
540
|
-
// the resolution index (each importer row is lazy-validated below like any other).
|
|
541
678
|
const definingPaths = new Set(defining.map((e) => e.path));
|
|
542
679
|
const definingByNodeId = new Map(defining.map((e) => [e.node_id, e]));
|
|
543
680
|
const confidentDefiningFileIds = new Map();
|
|
@@ -552,31 +689,25 @@ export function brief(target, limit, ctx, memoryReader) {
|
|
|
552
689
|
const fwd = forwardDeps(confidentDefiningFileIds, nodeIndex, ctx.cwd, ctx.preferredDirName);
|
|
553
690
|
const rev = reverseDeps(resolutionIndex, definingPaths, definingByNodeId);
|
|
554
691
|
const ranked = rankFiles(defining, fwd, rev, symbolsIndex, importsIndex, target);
|
|
555
|
-
// §6.1 — lazy validate each suggested file; exclude deletions
|
|
556
|
-
//
|
|
557
|
-
// (stale / unchecked / deleted) is SUPPRESSED — no silent stale graph hints (Codex).
|
|
692
|
+
// §6.1 — lazy validate each suggested file; exclude deletions; suppress a graph-only
|
|
693
|
+
// row that fails validation (no silent stale graph hints).
|
|
558
694
|
const confident = [];
|
|
559
695
|
for (const rf of ranked) {
|
|
560
696
|
const ok = validateEntry({ path: rf.path, file_id: rf.file_id }, checker, acc, root, maxBytes, ctx.cwd, ctx.preferredDirName);
|
|
561
697
|
if (acc.missingPaths.has(rf.path))
|
|
562
|
-
continue;
|
|
698
|
+
continue;
|
|
563
699
|
if (rf.graphDerived && !ok)
|
|
564
|
-
continue;
|
|
565
|
-
// Non-graph stale/unchecked rows still appear (badge flags them) so the agent
|
|
566
|
-
// knows the file exists but may be out of date.
|
|
700
|
+
continue;
|
|
567
701
|
confident.push(rf);
|
|
568
702
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const related = attachRelatedMemory(memoryItems, candidatePaths, symbolNames);
|
|
578
|
-
// Attach matching memory ids per file (those whose related_paths/text name it).
|
|
579
|
-
const suggested = capped.map((f) => {
|
|
703
|
+
return { defining, definingPaths, matchKind, confident, base, hasIndex: true, emptyRanked: ranked.length === 0, acc };
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Attach related-memory ids per reading-list entry (spec §11). Shared by the
|
|
707
|
+
* single-store brief() and the workspace aggregation so both surface memory identically.
|
|
708
|
+
*/
|
|
709
|
+
export function attachMemoryIds(capped, related) {
|
|
710
|
+
return capped.map((f) => {
|
|
580
711
|
const ids = related
|
|
581
712
|
.filter((m) => {
|
|
582
713
|
const fileNorm = f.path.replace(/\\/g, '/');
|
|
@@ -588,12 +719,30 @@ export function brief(target, limit, ctx, memoryReader) {
|
|
|
588
719
|
.map((m) => m.id);
|
|
589
720
|
return { path: f.path, reason: f.reason, score: f.score, related_memory_ids: ids };
|
|
590
721
|
});
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
722
|
+
}
|
|
723
|
+
export function brief(target, limit, ctx, memoryReader) {
|
|
724
|
+
const checker = makeLazyChecker();
|
|
725
|
+
const acc = newAccumulator();
|
|
726
|
+
const r = briefInStore(target, ctx, checker, acc);
|
|
727
|
+
if (!r.hasIndex) {
|
|
728
|
+
return {
|
|
729
|
+
target,
|
|
730
|
+
suggested_files_to_read: [],
|
|
731
|
+
related_memory: [],
|
|
732
|
+
freshness_badge: { status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } },
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const cap = Math.min(limit ?? BRIEF_FILE_CAP, BRIEF_FILE_CAP);
|
|
736
|
+
// pln#601 — reserve source slots so test importers can't crowd out source files.
|
|
737
|
+
const capped = reserveSourceSlots(r.confident, cap, r.definingPaths);
|
|
738
|
+
// Related memory (spec §11): match by the candidate paths + symbol names.
|
|
739
|
+
const candidatePaths = capped.map((f) => f.path);
|
|
740
|
+
const symbolNames = [...new Set(r.defining.map((e) => e.name))];
|
|
741
|
+
if (symbolNames.length === 0)
|
|
742
|
+
symbolNames.push(target);
|
|
743
|
+
const related = attachRelatedMemory(memoryReader(ctx), candidatePaths, symbolNames);
|
|
744
|
+
const suggested = attachMemoryIds(capped, related);
|
|
745
|
+
const badge = deriveBadge(r.base, acc, checker.exhausted, capped.length > 0, r.emptyRanked);
|
|
746
|
+
return { target, suggested_files_to_read: suggested, related_memory: related, freshness_badge: badge };
|
|
598
747
|
}
|
|
599
748
|
//# sourceMappingURL=query.js.map
|
|
@@ -61,6 +61,12 @@ const KNOWN_CODE_LANGS = new Set([
|
|
|
61
61
|
'python',
|
|
62
62
|
'php',
|
|
63
63
|
'java',
|
|
64
|
+
'go',
|
|
65
|
+
'rust',
|
|
66
|
+
'csharp',
|
|
67
|
+
'ruby',
|
|
68
|
+
'c',
|
|
69
|
+
'cpp',
|
|
64
70
|
]);
|
|
65
71
|
/** A well-formed (lowercase) language id a future provider could register. */
|
|
66
72
|
const CODE_LANG_RE = /^[a-z][a-z0-9_]*$/;
|
|
@@ -285,9 +291,21 @@ export const CodeLockSchema = z.object({
|
|
|
285
291
|
heartbeat_at: z.string(),
|
|
286
292
|
stale_after_ms: z.number().int(),
|
|
287
293
|
});
|
|
294
|
+
/**
|
|
295
|
+
* spec §9 (pln#601) — the COARSE, surface-uniform rollup of the detailed 7-value
|
|
296
|
+
* {@link FreshnessStatus}. find/brief/status/work each expose their own precise
|
|
297
|
+
* status, but an agent wants one consistent top-line signal to decide "trust this
|
|
298
|
+
* or refresh first" without memorizing which `stale_*` variant applies. `coarse`
|
|
299
|
+
* collapses the detail: every `stale_*` → `stale`, `missing_index` → `missing`,
|
|
300
|
+
* `partial`/`fresh` unchanged. Derived (never independently authored) via
|
|
301
|
+
* `coarseFreshness()` so it can never contradict `status`.
|
|
302
|
+
*/
|
|
303
|
+
export const CoarseFreshnessSchema = z.enum(['fresh', 'stale', 'partial', 'missing']);
|
|
288
304
|
/** Freshness badge attached to every agent-facing read response (spec §9). */
|
|
289
305
|
export const FreshnessBadgeSchema = z.object({
|
|
290
306
|
status: FreshnessStatusSchema,
|
|
307
|
+
/** pln#601 — coarse rollup of `status`, uniform across all read surfaces. */
|
|
308
|
+
coarse: CoarseFreshnessSchema.optional(),
|
|
291
309
|
details: z.record(z.string(), z.unknown()).default({}),
|
|
292
310
|
});
|
|
293
311
|
//# sourceMappingURL=types.js.map
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* bclaw_work beyond that bounded wait (rule §6 rule 8).
|
|
19
19
|
*/
|
|
20
20
|
import { readManifest } from './store.js';
|
|
21
|
+
import { withCoarse } from './freshness.js';
|
|
21
22
|
import { readCodeLock, isLockAbandoned } from './lock.js';
|
|
22
23
|
import { codeMapDir, lockPath } from './paths.js';
|
|
23
24
|
import { JsonlBackend } from './backend.js';
|
|
@@ -107,10 +108,10 @@ export async function codeMapWorkSection(cwd, opts = {}) {
|
|
|
107
108
|
return {
|
|
108
109
|
enabled: true,
|
|
109
110
|
matches: out.matches,
|
|
110
|
-
freshness_badge: {
|
|
111
|
+
freshness_badge: withCoarse({
|
|
111
112
|
status: 'partial',
|
|
112
113
|
details: { partial_reason: 'code_map_lock_active', lock_wait_ms: lockWaitMs },
|
|
113
|
-
},
|
|
114
|
+
}),
|
|
114
115
|
lock_wait_ms: lockWaitMs,
|
|
115
116
|
};
|
|
116
117
|
}
|
|
@@ -121,10 +122,10 @@ export async function codeMapWorkSection(cwd, opts = {}) {
|
|
|
121
122
|
return {
|
|
122
123
|
enabled: true,
|
|
123
124
|
matches: [],
|
|
124
|
-
freshness_badge: {
|
|
125
|
+
freshness_badge: withCoarse({
|
|
125
126
|
status: 'partial',
|
|
126
127
|
details: { partial_reason: 'code_map_lock_active', lock_wait_ms: lockWaitMs },
|
|
127
|
-
},
|
|
128
|
+
}),
|
|
128
129
|
lock_wait_ms: lockWaitMs,
|
|
129
130
|
};
|
|
130
131
|
}
|
|
@@ -135,7 +136,7 @@ export async function codeMapWorkSection(cwd, opts = {}) {
|
|
|
135
136
|
enabled: true,
|
|
136
137
|
missing_index: 'Code Map index is empty for this project. Run `brainclaw code-map refresh --all` (or bclaw_code_refresh) before relying on find/brief.',
|
|
137
138
|
matches: [],
|
|
138
|
-
freshness_badge: { status: 'missing_index', details: {} },
|
|
139
|
+
freshness_badge: withCoarse({ status: 'missing_index', details: {} }),
|
|
139
140
|
...(lockWaitMs !== undefined ? { lock_wait_ms: lockWaitMs } : {}),
|
|
140
141
|
};
|
|
141
142
|
}
|
|
@@ -146,13 +147,13 @@ export async function codeMapWorkSection(cwd, opts = {}) {
|
|
|
146
147
|
return {
|
|
147
148
|
enabled: true,
|
|
148
149
|
matches: [],
|
|
149
|
-
freshness_badge: {
|
|
150
|
+
freshness_badge: withCoarse({
|
|
150
151
|
status: manifest.freshness.status,
|
|
151
152
|
details: {
|
|
152
153
|
stale_file_count: manifest.freshness.stale_file_count,
|
|
153
154
|
partial_reason: manifest.freshness.partial_reason,
|
|
154
155
|
},
|
|
155
|
-
},
|
|
156
|
+
}),
|
|
156
157
|
...(lockWaitMs !== undefined ? { lock_wait_ms: lockWaitMs } : {}),
|
|
157
158
|
};
|
|
158
159
|
}
|
|
@@ -15,6 +15,22 @@ export function responseDir(threadSlug, cwd) {
|
|
|
15
15
|
export function responseFilePath(threadSlug, roundNumber, persona, cwd) {
|
|
16
16
|
return path.join(responseDir(threadSlug, cwd), `round_${roundNumber}_${persona}.json`);
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Persist a full CoDev/ideation phase body to the artifact store (pln#627
|
|
20
|
+
* Phase C) and return a repo-relative pointer. The inbox thread then carries
|
|
21
|
+
* only a bounded head + this pointer instead of a multi-hundred-KB rfc dump
|
|
22
|
+
* (root cause of the 3.8 MB inbox: full phase briefs persisted as messages).
|
|
23
|
+
* The `.brainclaw/coordination/ideation/<slug>/phases/` dir sits alongside the
|
|
24
|
+
* existing per-round `responses/` store so a thread's artifacts stay together.
|
|
25
|
+
*/
|
|
26
|
+
export function writePhaseArtifact(threadSlug, label, text, cwd) {
|
|
27
|
+
const dir = path.join(memoryDir(cwd), 'coordination', 'ideation', sanitizeForPath(threadSlug), 'phases');
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
const filePath = path.join(dir, `${sanitizeForPath(label)}.md`);
|
|
30
|
+
fs.writeFileSync(filePath, text, 'utf8');
|
|
31
|
+
const relPath = path.relative(memoryDir(cwd), filePath).split(path.sep).join('/');
|
|
32
|
+
return { path: filePath, relPath, charCount: text.length };
|
|
33
|
+
}
|
|
18
34
|
export function writeResponse(threadSlug, roundNumber, persona, agent, text, cwd) {
|
|
19
35
|
const filePath = responseFilePath(threadSlug, roundNumber, persona, cwd);
|
|
20
36
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|