codedeep-mcp 0.1.0 → 0.2.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 +23 -8
- package/dist/config.js +1 -1
- package/dist/fs-util.js +48 -0
- package/dist/git/git-service.js +27 -0
- package/dist/index.js +15 -1
- package/dist/indexer/code-index.js +91 -22
- package/dist/indexer/parser.js +100 -25
- package/dist/indexer/pipeline.js +64 -4
- package/dist/indexer/scanner.js +6 -4
- package/dist/indexer/watcher.js +9 -0
- package/dist/notes/note-store.js +513 -0
- package/dist/notes/staleness.js +168 -0
- package/dist/notes/types.js +19 -0
- package/dist/server.js +105 -16
- package/dist/tools/common.js +51 -41
- package/dist/tools/find-references.js +9 -11
- package/dist/tools/forget.js +26 -0
- package/dist/tools/get-context.js +149 -18
- package/dist/tools/impact.js +18 -5
- package/dist/tools/note-render.js +57 -0
- package/dist/tools/overview.js +76 -3
- package/dist/tools/recall.js +165 -0
- package/dist/tools/remember.js +207 -0
- package/dist/tools/search-structure.js +3 -2
- package/package.json +4 -2
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { isCallerOf, isClassMember, } from '../indexer/code-index.js';
|
|
2
2
|
import { errMsg } from '../logger.js';
|
|
3
|
+
import { qualifiedSymbolName } from '../notes/note-store.js';
|
|
4
|
+
import { computeNoteStatusSafe, newCommitCache, newFileProbeCache, } from '../notes/staleness.js';
|
|
3
5
|
import { classNameFromFqn } from '../types.js';
|
|
4
|
-
import {
|
|
6
|
+
import { renderNote } from './note-render.js';
|
|
7
|
+
import { BEHAVIORAL_TAG, MODULE_LEVEL, NAME_MATCH_HEADER_QUALIFIER, NAME_MATCH_TAG, STRUCTURAL_TAG, displaySignature, estimate, formatComplexity, normalizeFilePath, pickByLine, plural, readinessBanner, renderAmbiguous, renderSuggestions, safeReadIndexedFile, sectionOrEmpty, sectionOrNone, textResponse, topCoChangePartners, } from './common.js';
|
|
5
8
|
const DEFAULT_MAX_TOKENS = 3000;
|
|
6
9
|
const SUGGEST_LIMIT = 5;
|
|
7
10
|
// `exported_by` is intentionally absent: the TS extractor skips re-export
|
|
8
11
|
// statements (`export { x } from './y'`), so any "exported by" listing in
|
|
9
12
|
// Phase 1a would only surface coincidentally same-named exports from
|
|
10
13
|
// unrelated files. The section returns when re-export edges land.
|
|
11
|
-
// `
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
+
// `notes` (anchored knowledge-layer notes, staleness-checked) sits after the
|
|
15
|
+
// structural sections — enrichment must never displace body/callers — but
|
|
16
|
+
// above the git tail: a curated note about THIS symbol outranks generic
|
|
17
|
+
// churn data. `co_changes` and `git` sit last: they render at the end and are
|
|
18
|
+
// the first casualties under max_tokens pressure (enrichment, not core).
|
|
19
|
+
const ALL_SECTIONS = ['body', 'callers', 'callees', 'coupling', 'imports', 'notes', 'co_changes', 'git'];
|
|
14
20
|
function truncationNote(at, maxTokens) {
|
|
15
21
|
return `(Sections from \`${at}\` onward omitted to stay within max_tokens=${maxTokens}.)`;
|
|
16
22
|
}
|
|
@@ -60,7 +66,7 @@ export async function runGetContext(args, deps) {
|
|
|
60
66
|
if (file === null) {
|
|
61
67
|
return textResponse(`Error: file "${args.file}" is outside the project root.`);
|
|
62
68
|
}
|
|
63
|
-
const include = parseInclude(args.include);
|
|
69
|
+
const { include, note: includeNote } = parseInclude(args.include);
|
|
64
70
|
const maxTokens = args.max_tokens ?? DEFAULT_MAX_TOKENS;
|
|
65
71
|
const banner = readinessBanner(deps.indexer.ready);
|
|
66
72
|
if (args.symbol !== undefined) {
|
|
@@ -69,25 +75,46 @@ export async function runGetContext(args, deps) {
|
|
|
69
75
|
return textResponse('Error: symbol must be non-empty.');
|
|
70
76
|
}
|
|
71
77
|
const body = await renderSymbolMode(file, trimmed, args.line, include, maxTokens, deps);
|
|
72
|
-
return textResponse(banner + body);
|
|
78
|
+
return textResponse(banner + includeNote + body);
|
|
73
79
|
}
|
|
74
|
-
return textResponse(banner + (await renderFileMode(file, include, maxTokens, deps)));
|
|
80
|
+
return textResponse(banner + includeNote + (await renderFileMode(file, include, maxTokens, deps)));
|
|
75
81
|
}
|
|
76
82
|
catch (err) {
|
|
77
83
|
return textResponse(`Error: ${errMsg(err)}`);
|
|
78
84
|
}
|
|
79
85
|
}
|
|
86
|
+
// Unknown section names are SURFACED, never silently dropped: a typo'd
|
|
87
|
+
// `include: ["callrs"]` used to yield an empty shell that read as "no data
|
|
88
|
+
// here" — a false conclusion the agent then acts on. Unknown keys produce an
|
|
89
|
+
// in-band note listing the valid names, and when NOTHING valid remains the
|
|
90
|
+
// filter falls back to ALL_SECTIONS (a full answer with a correction beats an
|
|
91
|
+
// empty one). An explicit empty array means the default too. Hyphens fold to
|
|
92
|
+
// underscores so `co-changes` finds `co_changes`.
|
|
80
93
|
function parseInclude(input) {
|
|
81
|
-
if (!input)
|
|
82
|
-
return new Set(ALL_SECTIONS);
|
|
94
|
+
if (!input || input.length === 0) {
|
|
95
|
+
return { include: new Set(ALL_SECTIONS), note: '' };
|
|
96
|
+
}
|
|
83
97
|
const set = new Set();
|
|
98
|
+
const unknown = [];
|
|
84
99
|
for (const s of input) {
|
|
85
|
-
const
|
|
86
|
-
if (ALL_SECTIONS.includes(
|
|
87
|
-
set.add(
|
|
100
|
+
const key = s.trim().toLowerCase().replace(/-/g, '_');
|
|
101
|
+
if (ALL_SECTIONS.includes(key)) {
|
|
102
|
+
set.add(key);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
unknown.push(s);
|
|
88
106
|
}
|
|
89
107
|
}
|
|
90
|
-
|
|
108
|
+
if (unknown.length === 0)
|
|
109
|
+
return { include: set, note: '' };
|
|
110
|
+
const fellBack = set.size === 0;
|
|
111
|
+
if (fellBack)
|
|
112
|
+
for (const s of ALL_SECTIONS)
|
|
113
|
+
set.add(s);
|
|
114
|
+
const note = `(Ignored unknown include section${unknown.length === 1 ? '' : 's'}: ` +
|
|
115
|
+
`${unknown.map((u) => `"${u}"`).join(', ')}. Valid: ${ALL_SECTIONS.join(', ')}.` +
|
|
116
|
+
`${fellBack ? ' Showing all sections.' : ''})\n\n`;
|
|
117
|
+
return { include: set, note };
|
|
91
118
|
}
|
|
92
119
|
async function renderSymbolMode(file, name, line, include, maxTokens, deps) {
|
|
93
120
|
const candidates = deps.index
|
|
@@ -124,6 +151,7 @@ async function renderSymbolBlock(target, file, include, maxTokens, deps) {
|
|
|
124
151
|
if (target.doc && target.doc.length > 0)
|
|
125
152
|
headerLines.push(target.doc);
|
|
126
153
|
const header = headerLines.join('\n');
|
|
154
|
+
const { item: notesSection, degradedNotice } = await notesItem(deps, file, include, target);
|
|
127
155
|
// `body` is the highest-priority section and is never dropped to fit budget.
|
|
128
156
|
const items = [
|
|
129
157
|
{
|
|
@@ -155,9 +183,112 @@ async function renderSymbolBlock(target, file, include, maxTokens, deps) {
|
|
|
155
183
|
render: () => renderImports(file, deps.index),
|
|
156
184
|
peekNonEmpty: () => deps.index.getImports(file).length > 0,
|
|
157
185
|
},
|
|
186
|
+
notesSection,
|
|
158
187
|
...gitSectionItems(target.file, deps),
|
|
159
188
|
];
|
|
160
|
-
return renderBudgeted(header, items, include, maxTokens, 'body');
|
|
189
|
+
return (await renderBudgeted(header, items, include, maxTokens, 'body')) + degradedNotice;
|
|
190
|
+
}
|
|
191
|
+
// --- PULL: anchored knowledge-layer notes rendered in place ---
|
|
192
|
+
// Cap so a note-heavy file can't displace the structural sections; the
|
|
193
|
+
// overflow line names the recall query that lists the rest.
|
|
194
|
+
const MAX_CONTEXT_NOTES = 3;
|
|
195
|
+
// Builds the notes SectionItem for either mode, plus a `degradedNotice` the
|
|
196
|
+
// caller appends OUTSIDE the token budget. The store load + selection happen up
|
|
197
|
+
// front (sync store queries after the memoized load, so the budget loop can
|
|
198
|
+
// peek emptiness for free) but are GATED on the include filter — a caller
|
|
199
|
+
// looping get_context({include:['body']}) must not pay a store read per call.
|
|
200
|
+
// Both modes share this so the load-before-select ordering and the peek
|
|
201
|
+
// contract can't drift apart.
|
|
202
|
+
async function notesItem(deps, file, include, target) {
|
|
203
|
+
let selected = [];
|
|
204
|
+
let degradedNotice = '';
|
|
205
|
+
if (include.has('notes')) {
|
|
206
|
+
await deps.notes.load();
|
|
207
|
+
selected = selectContextNotes(deps.notes, file, target);
|
|
208
|
+
const degraded = deps.notes.degradedReason;
|
|
209
|
+
// Rides EVERY response — appended by the caller AFTER renderBudgeted, so a
|
|
210
|
+
// body that eats the whole token budget (dropping the notes section) can't
|
|
211
|
+
// suppress the "prior notes were quarantined; recover manually" signal.
|
|
212
|
+
// Same guarantee recall makes.
|
|
213
|
+
if (degraded)
|
|
214
|
+
degradedNotice = `\n\n(note store degraded: ${degraded})`;
|
|
215
|
+
}
|
|
216
|
+
const item = {
|
|
217
|
+
name: 'notes',
|
|
218
|
+
includeKey: 'notes',
|
|
219
|
+
render: () => renderNotesSection(selected, file, deps),
|
|
220
|
+
peekNonEmpty: () => selected.length > 0,
|
|
221
|
+
};
|
|
222
|
+
return { item, degradedNotice };
|
|
223
|
+
}
|
|
224
|
+
// Symbol mode: a note surfaces when one of its anchors names THIS symbol —
|
|
225
|
+
// plus FILE-level anchors (notes about the whole file). An anchor names the
|
|
226
|
+
// symbol when EITHER:
|
|
227
|
+
// - its stored (qualified) name equals this symbol's qualified name
|
|
228
|
+
// ("Class.member" / top-level bare name) — a RESOLVED anchor; or
|
|
229
|
+
// - it is a NAME-ONLY anchor (no symbolId — remember's ambiguous /
|
|
230
|
+
// index-still-building paths, declared "anchored by name") whose bare name
|
|
231
|
+
// equals this symbol's simple name, i.e. "about any symbol so named".
|
|
232
|
+
// The name arm is GATED on symbolId===undefined: a resolved anchor pinned to
|
|
233
|
+
// ONE specific symbol (e.g. top-level `foo`, symbolId set) must match by
|
|
234
|
+
// qualified name ONLY, never bleed onto a same-named sibling added later (a
|
|
235
|
+
// method `C.foo`) — and, symmetrically, a member-qualified anchor never bleeds
|
|
236
|
+
// onto a same-named top-level target. Anchors qualified to a DIFFERENT
|
|
237
|
+
// container, or to another symbol, stay out. File mode (no `target`): every
|
|
238
|
+
// note with an anchor in the file.
|
|
239
|
+
function selectContextNotes(notes, file, target) {
|
|
240
|
+
const all = notes.byFile(file); // newest first
|
|
241
|
+
if (target === undefined)
|
|
242
|
+
return all;
|
|
243
|
+
const qualified = qualifiedSymbolName(target.fqn, file, target.name);
|
|
244
|
+
const anchorNamesSymbol = (a) => {
|
|
245
|
+
if (a.file !== file || a.symbol === undefined)
|
|
246
|
+
return false;
|
|
247
|
+
if (a.symbol === qualified)
|
|
248
|
+
return true; // resolved to THIS symbol
|
|
249
|
+
return a.symbolId === undefined && a.symbol === target.name; // name-only
|
|
250
|
+
};
|
|
251
|
+
const symbolNotes = all.filter((n) => n.anchors.some(anchorNamesSymbol));
|
|
252
|
+
const seen = new Set(symbolNotes.map((n) => n.id));
|
|
253
|
+
const fileLevel = all.filter((n) => !seen.has(n.id) &&
|
|
254
|
+
n.anchors.some((a) => a.file === file && a.symbol === undefined));
|
|
255
|
+
return [...symbolNotes, ...fileLevel];
|
|
256
|
+
}
|
|
257
|
+
async function renderNotesSection(selected, file, deps) {
|
|
258
|
+
// Degraded-store signalling is handled OUTSIDE this section (notesItem's
|
|
259
|
+
// degradedNotice, appended past the budget) — an empty selection here means
|
|
260
|
+
// "no notes to render", nothing more.
|
|
261
|
+
if (selected.length === 0)
|
|
262
|
+
return '';
|
|
263
|
+
const shown = selected.slice(0, MAX_CONTEXT_NOTES);
|
|
264
|
+
// Same staleness path — and the same render grammar (note-render.ts) — as
|
|
265
|
+
// recall, so a note reads identically everywhere. The caches dedupe the
|
|
266
|
+
// per-file hash/git probe across the shown notes' anchors; per-note failure
|
|
267
|
+
// isolation lives in computeNoteStatusSafe (shared with recall).
|
|
268
|
+
const stalenessDeps = {
|
|
269
|
+
index: deps.index,
|
|
270
|
+
config: deps.config,
|
|
271
|
+
git: deps.git,
|
|
272
|
+
};
|
|
273
|
+
const fileCache = newFileProbeCache();
|
|
274
|
+
const commitCache = newCommitCache();
|
|
275
|
+
const blocks = await Promise.all(shown.map(async (note) => renderNote(note, await computeNoteStatusSafe(note, stalenessDeps, fileCache, commitCache))));
|
|
276
|
+
const lines = ['### Notes (agent-curated)', ...blocks];
|
|
277
|
+
if (selected.length > shown.length) {
|
|
278
|
+
lines.push(notesOverflowLine(selected.length - shown.length, file));
|
|
279
|
+
}
|
|
280
|
+
return lines.join('\n\n');
|
|
281
|
+
}
|
|
282
|
+
// The cap hid `hidden` selected notes. Point the agent at recall to BROWSE the
|
|
283
|
+
// rest rather than promise reproduction: recall paginates (default 10, max 50).
|
|
284
|
+
// ALWAYS recall({file}) — NOT recall({file, symbol}): symbol mode's selection
|
|
285
|
+
// mixes symbol-anchored AND file-level notes, but recall's `symbol` filter is
|
|
286
|
+
// bySymbol (file-level anchors excluded), so it would return a SUBSET that can
|
|
287
|
+
// miss the very notes we hid. byFile ({file} only) is the true SUPERSET of the
|
|
288
|
+
// selection in both modes. JSON.stringify quotes+escapes the path so the
|
|
289
|
+
// suggested call is copy-paste valid even for exotic filenames.
|
|
290
|
+
function notesOverflowLine(hidden, file) {
|
|
291
|
+
return `(${hidden} more not shown — recall({ file: ${JSON.stringify(file)} }) to browse notes here.)`;
|
|
161
292
|
}
|
|
162
293
|
// The two git sections are identical in both modes and always trail the
|
|
163
294
|
// list (first to drop under budget pressure).
|
|
@@ -296,6 +427,8 @@ async function renderFileMode(file, include, maxTokens, deps) {
|
|
|
296
427
|
});
|
|
297
428
|
const exported = topLevel.filter((s) => s.exported);
|
|
298
429
|
const internal = topLevel.filter((s) => !s.exported);
|
|
430
|
+
// File mode surfaces EVERY note anchored in the file (whole-file view).
|
|
431
|
+
const { item: notesSection, degradedNotice } = await notesItem(deps, file, include);
|
|
299
432
|
// `body` covers the outline (Exports + Internal); `callees` has no
|
|
300
433
|
// file-mode analogue. Renderers are lazy so dropped sections don't pay
|
|
301
434
|
// for caller scans.
|
|
@@ -324,9 +457,10 @@ async function renderFileMode(file, include, maxTokens, deps) {
|
|
|
324
457
|
// when the budget is already gone.
|
|
325
458
|
peekNonEmpty: () => true,
|
|
326
459
|
},
|
|
460
|
+
notesSection,
|
|
327
461
|
...gitSectionItems(file, deps),
|
|
328
462
|
];
|
|
329
|
-
return renderBudgeted(header, items, include, maxTokens);
|
|
463
|
+
return (await renderBudgeted(header, items, include, maxTokens)) + degradedNotice;
|
|
330
464
|
}
|
|
331
465
|
// Uses `getReferencesByNameOrAlias + isCallerOf` (same data path as
|
|
332
466
|
// find_references) so cross-file callers — the common case for exported
|
|
@@ -365,6 +499,3 @@ function collectExportCallers(exportedSyms, index) {
|
|
|
365
499
|
function formatFileSymbolLine(s) {
|
|
366
500
|
return `- ${s.name} (${s.kind}, line ${s.startLine}) — ${displaySignature(s)}`;
|
|
367
501
|
}
|
|
368
|
-
function estimate(text) {
|
|
369
|
-
return Math.ceil(text.length / 4);
|
|
370
|
-
}
|
package/dist/tools/impact.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { countDistinctCallers, } from '../indexer/code-index.js';
|
|
2
2
|
import { errMsg } from '../logger.js';
|
|
3
|
-
import { BEHAVIORAL_TAG, MEMBER_MATCH_TAG, MODULE_LEVEL, NAME_MATCH_TAG, STRUCTURAL_TAG, normalizeFilePath, pickByLine, plural, readinessBanner, renderAmbiguous, renderSuggestions, textResponse, topCoChangePartners, } from './common.js';
|
|
3
|
+
import { BEHAVIORAL_TAG, confidencePreamble, MEMBER_MATCH_TAG, estimate, MODULE_LEVEL, NAME_MATCH_TAG, STRUCTURAL_TAG, normalizeFilePath, pickByLine, plural, readinessBanner, renderAmbiguous, renderSuggestions, textResponse, topCoChangePartners, } from './common.js';
|
|
4
4
|
const DEFAULT_DEPTH = 3;
|
|
5
5
|
// Past 5 hops the heuristic name-match noise compounds multiplicatively and
|
|
6
6
|
// the tree is mostly candidates; cap is a precision guardrail, not just budget.
|
|
@@ -72,9 +72,6 @@ function tagFor(strength) {
|
|
|
72
72
|
return MEMBER_MATCH_TAG;
|
|
73
73
|
return NAME_MATCH_TAG;
|
|
74
74
|
}
|
|
75
|
-
function estimate(text) {
|
|
76
|
-
return Math.ceil(text.length / 4);
|
|
77
|
-
}
|
|
78
75
|
function flattenByDepth(root) {
|
|
79
76
|
const byDepth = new Map();
|
|
80
77
|
const walk = (node) => {
|
|
@@ -165,7 +162,7 @@ function renderImpact(tree, target, depth, maxTokens, index) {
|
|
|
165
162
|
// numbers. impact keeps tree.truncated and depthCapped SEPARATE below: they
|
|
166
163
|
// map to two distinct remediation hints (raise max_tokens/narrow vs raise
|
|
167
164
|
// depth), unlike the scalar BlastRadius which collapses both into one `+`.
|
|
168
|
-
const blast = countDistinctCallers(tree.root);
|
|
165
|
+
const blast = countDistinctCallers(tree.root, /* withTiers */ true);
|
|
169
166
|
const depthCapped = blast.depthCapped;
|
|
170
167
|
const callerCount = blast.callers;
|
|
171
168
|
// A trailing `+` flags that a breadth/size cap fired, so the true total may
|
|
@@ -173,6 +170,22 @@ function renderImpact(tree, target, depth, maxTokens, index) {
|
|
|
173
170
|
blocks.push(`${callerCount}${tree.truncated ? '+' : ''} ${plural('caller', callerCount)} across ` +
|
|
174
171
|
`${blast.depths} ${plural('depth', blast.depths)} ` +
|
|
175
172
|
`(${blast.files} ${plural('file', blast.files)}).`);
|
|
173
|
+
// Confidence summary, derived from blast.tiers — the SAME distinct-caller
|
|
174
|
+
// dedup that produced the "N callers" headline above, so the two reconcile by
|
|
175
|
+
// construction (a DAG diamond / cycle is one caller, not N). Carries the
|
|
176
|
+
// headline's truncation flag so a capped walk shows "+ more" here too. Pushed
|
|
177
|
+
// into the never-dropped floor so it always shows.
|
|
178
|
+
// TRADEOFF: each distinct caller is bucketed by its STRONGEST edge, while the
|
|
179
|
+
// per-row tags below are per-PATH (tagFor on each occurrence). In the rare
|
|
180
|
+
// mixed-strength DAG diamond (one caller reached via a resolved AND a
|
|
181
|
+
// name-match edge) the summary counts it once as resolved while both rows
|
|
182
|
+
// still render — reconciling with the adjacent "N callers" headline is the
|
|
183
|
+
// primary invariant; per-occurrence counting would re-break that.
|
|
184
|
+
// blast.tiers is present because we passed withTiers=true above; `?? {}` keeps
|
|
185
|
+
// the type honest (CallerCounts.tiers is optional) and degrades to no preamble.
|
|
186
|
+
const preamble = confidencePreamble(blast.tiers ?? {}, tree.truncated);
|
|
187
|
+
if (preamble)
|
|
188
|
+
blocks.push(preamble);
|
|
176
189
|
let used = estimate(blocks.join('\n\n'));
|
|
177
190
|
let cutoff = null;
|
|
178
191
|
for (const d of depths) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Shared renderers for staleness-checked notes. Extracted from recall.ts so
|
|
2
|
+
// the PULL surfaces (a notes section inside get_context / a knowledge line in
|
|
3
|
+
// overview) render notes IDENTICALLY to recall — one grammar for verdict tags,
|
|
4
|
+
// ages, and anchor lines. This lives in tools/ (not notes/) on purpose: the
|
|
5
|
+
// renderers depend on tools/common's formatRelativeAge + BEHAVIORAL_TAG, and a
|
|
6
|
+
// notes/-located module would re-create the notes→tools layering inversion.
|
|
7
|
+
import { BEHAVIORAL_TAG, formatRelativeAge } from './common.js';
|
|
8
|
+
// Module-private until a PULL surface actually consumes them — renderNote is
|
|
9
|
+
// the one export with a consumer today; widen the surface when real callers
|
|
10
|
+
// land, not speculatively.
|
|
11
|
+
const VERDICT_TAG = {
|
|
12
|
+
fresh: '✓ fresh',
|
|
13
|
+
stale: '⚠ stale',
|
|
14
|
+
unverified: '? unverified',
|
|
15
|
+
missing: '✗ missing',
|
|
16
|
+
};
|
|
17
|
+
const VERDICT_MARK = {
|
|
18
|
+
fresh: '✓',
|
|
19
|
+
stale: '⚠',
|
|
20
|
+
unverified: '?',
|
|
21
|
+
missing: '✗',
|
|
22
|
+
};
|
|
23
|
+
export function renderNote(note, status) {
|
|
24
|
+
// Guard a hand-edited / non-ISO createdAt that parses to NaN (isValidNote only
|
|
25
|
+
// checks it's a string) so the header never reads "NaNd ago".
|
|
26
|
+
const parsed = Date.parse(note.createdAt);
|
|
27
|
+
const age = Number.isNaN(parsed)
|
|
28
|
+
? 'unknown age'
|
|
29
|
+
: `${formatRelativeAge(Date.now() - parsed)} ago`;
|
|
30
|
+
const tag = VERDICT_TAG[status.overall];
|
|
31
|
+
const lines = [`### Note ${note.id} ${tag} · ${age}`, note.text];
|
|
32
|
+
if (status.anchors.length > 0) {
|
|
33
|
+
lines.push('Anchors:');
|
|
34
|
+
for (const a of status.anchors)
|
|
35
|
+
lines.push(renderAnchor(a));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
lines.push('(no anchors — not staleness-tracked)');
|
|
39
|
+
}
|
|
40
|
+
if (note.head)
|
|
41
|
+
lines.push(`Noted at commit ${note.head}.`);
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
function renderAnchor(a) {
|
|
45
|
+
const where = a.anchor.symbol
|
|
46
|
+
? `${a.anchor.file}:${a.anchor.symbol}`
|
|
47
|
+
: a.anchor.file;
|
|
48
|
+
let line = `- ${VERDICT_MARK[a.verdict]} ${where} — ${a.detail}`;
|
|
49
|
+
if (a.lastCommit) {
|
|
50
|
+
// The file's most recent COMMIT — not necessarily when it went stale (a
|
|
51
|
+
// working-tree edit is uncommitted), so phrase it as provenance, not cause.
|
|
52
|
+
line +=
|
|
53
|
+
`; last commit ${a.lastCommit.hash} ${a.lastCommit.date} ` +
|
|
54
|
+
`"${a.lastCommit.subject}" ${BEHAVIORAL_TAG}`;
|
|
55
|
+
}
|
|
56
|
+
return line;
|
|
57
|
+
}
|
package/dist/tools/overview.js
CHANGED
|
@@ -4,7 +4,7 @@ import { ENTRY_POINT_FILENAME_RE, isClassMember, zeroSymbolsByKind, } from '../i
|
|
|
4
4
|
import { compareShallowFirst } from '../indexer/scanner.js';
|
|
5
5
|
import { errMsg, log } from '../logger.js';
|
|
6
6
|
import { LANGUAGE_UNKNOWN, } from '../types.js';
|
|
7
|
-
import { BEHAVIORAL_TAG, formatComplexityMetrics, INDEXING_BANNER, plural, textResponse, } from './common.js';
|
|
7
|
+
import { BEHAVIORAL_TAG, formatComplexityMetrics, formatRelativeAge, INDEXING_BANNER, plural, textResponse, } from './common.js';
|
|
8
8
|
const MAX_DIR_GROUPS = 7;
|
|
9
9
|
const MAX_KINDS_PER_GROUP = 3;
|
|
10
10
|
const MAX_ENTRY_POINTS = 15;
|
|
@@ -117,13 +117,65 @@ export async function runOverview(args, deps) {
|
|
|
117
117
|
if (kindLine)
|
|
118
118
|
lines.push(`- ${kindLine}`);
|
|
119
119
|
lines.push(`- ${stats.totalFiles} ${plural('file', stats.totalFiles)} indexed, ${stats.totalSymbols} total ${plural('symbol', stats.totalSymbols)}`);
|
|
120
|
-
|
|
120
|
+
// The note-store read and the git subprocess work are independent I/O —
|
|
121
|
+
// start them concurrently (load never throws; it quarantines internally).
|
|
122
|
+
const [gitData] = await Promise.all([
|
|
123
|
+
collectGitData(deps),
|
|
124
|
+
deps.notes.load(),
|
|
125
|
+
]);
|
|
126
|
+
appendKnowledgeSection(lines, deps.notes);
|
|
127
|
+
appendGitSections(lines, gitData);
|
|
121
128
|
return textResponse(lines.join('\n'));
|
|
122
129
|
}
|
|
123
130
|
catch (err) {
|
|
124
131
|
return textResponse(`Error: ${errMsg(err)}`);
|
|
125
132
|
}
|
|
126
133
|
}
|
|
134
|
+
// Counts-only PULL surface of the knowledge layer: overview is the always-
|
|
135
|
+
// first call, so this deliberately does NO disk re-hashing (staleness lives in
|
|
136
|
+
// recall and get_context's notes section). Silently omitted when the store is
|
|
137
|
+
// empty AND healthy. A degraded store gets its one-line notice — and STILL
|
|
138
|
+
// reports the live counts when notes exist (after a quarantine + new
|
|
139
|
+
// remembers, hiding the counts would read as "no stored knowledge"). The
|
|
140
|
+
// caller has already awaited notes.load().
|
|
141
|
+
function appendKnowledgeSection(lines, notes) {
|
|
142
|
+
const all = notes.all();
|
|
143
|
+
const degraded = notes.degradedReason;
|
|
144
|
+
if (all.length === 0 && degraded === null)
|
|
145
|
+
return;
|
|
146
|
+
lines.push('');
|
|
147
|
+
lines.push('### Knowledge (agent-curated)');
|
|
148
|
+
if (degraded !== null) {
|
|
149
|
+
lines.push(`- note store degraded: ${degraded}`);
|
|
150
|
+
}
|
|
151
|
+
if (all.length === 0)
|
|
152
|
+
return;
|
|
153
|
+
// Only ANCHORED notes are staleness-tracked; count them separately from
|
|
154
|
+
// unanchored ones so a mixed store never claims "N notes … recall checks
|
|
155
|
+
// staleness" over notes recall reports as "(no anchors — not tracked)".
|
|
156
|
+
const anchoredFiles = new Set();
|
|
157
|
+
let anchoredNotes = 0;
|
|
158
|
+
for (const note of all) {
|
|
159
|
+
if (note.anchors.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
anchoredNotes++;
|
|
162
|
+
for (const anchor of note.anchors)
|
|
163
|
+
anchoredFiles.add(anchor.file);
|
|
164
|
+
}
|
|
165
|
+
const unanchored = all.length - anchoredNotes;
|
|
166
|
+
if (anchoredNotes > 0) {
|
|
167
|
+
const base = `- ${anchoredNotes} anchored ${plural('note', anchoredNotes)} across ` +
|
|
168
|
+
`${anchoredFiles.size} ${plural('file', anchoredFiles.size)} — ` +
|
|
169
|
+
'`recall` checks staleness';
|
|
170
|
+
lines.push(unanchored > 0
|
|
171
|
+
? `${base}; ${unanchored} unanchored (not tracked)`
|
|
172
|
+
: base);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
lines.push(`- ${unanchored} unanchored ${plural('note', unanchored)} — ` +
|
|
176
|
+
'not staleness-tracked; anchor notes to files/symbols to track them');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
127
179
|
// Defensive catch at the tool boundary: GitService promises not to
|
|
128
180
|
// throw, but a git failure must never break overview output.
|
|
129
181
|
async function collectGitData(deps) {
|
|
@@ -134,6 +186,8 @@ async function collectGitData(deps) {
|
|
|
134
186
|
catch {
|
|
135
187
|
branch = null;
|
|
136
188
|
}
|
|
189
|
+
// Captured once: drives both the window label and the freshness banner.
|
|
190
|
+
const gitMeta = deps.index.getGitMeta();
|
|
137
191
|
return {
|
|
138
192
|
branch,
|
|
139
193
|
hotspots: deps.index.getHotspots(MAX_HOTSPOTS),
|
|
@@ -143,7 +197,8 @@ async function collectGitData(deps) {
|
|
|
143
197
|
// Label with the window that PRODUCED the data (gitMeta provenance),
|
|
144
198
|
// not the live config: after a gitWindow change, persisted counts
|
|
145
199
|
// keep their true label until the re-analysis lands.
|
|
146
|
-
windowDays:
|
|
200
|
+
windowDays: gitMeta?.windowDays ?? deps.config.gitWindow,
|
|
201
|
+
gitMeta,
|
|
147
202
|
};
|
|
148
203
|
}
|
|
149
204
|
// Both sections vanish entirely outside git repos (and before the first
|
|
@@ -151,6 +206,24 @@ async function collectGitData(deps) {
|
|
|
151
206
|
// placeholder. Hotspots come from the persisted index, so a warm start
|
|
152
207
|
// shows them immediately, even while the indexing banner is up.
|
|
153
208
|
function appendGitSections(lines, data) {
|
|
209
|
+
// Freshness banner: the analyzed HEAD and how long ago the git analysis ran,
|
|
210
|
+
// all from gitMeta (no extra git spawn). Tag-less — it's provenance, not a
|
|
211
|
+
// behavioral signal. Gated on a git analysis having run (gitMeta !== null),
|
|
212
|
+
// which is BROADER than the Hotspots/Risk gates (those need a qualifying
|
|
213
|
+
// file): the banner can therefore appear as the sole git output. Independent
|
|
214
|
+
// of the per-call branch probe so it still shows when branchSummary degrades.
|
|
215
|
+
// Omitted off-git, preserving the silent-omission contract.
|
|
216
|
+
if (data.gitMeta !== null) {
|
|
217
|
+
// "analyzed", not "index": this is the GIT-analysis timestamp (re-run on
|
|
218
|
+
// logs/HEAD changes), distinct from the structural index (re-run by the file
|
|
219
|
+
// watcher) — labeling it "index age" would overstate index staleness after a
|
|
220
|
+
// source-edit burst with no commit.
|
|
221
|
+
const analyzed = formatRelativeAge(Date.now() - data.gitMeta.analyzedAt);
|
|
222
|
+
// head is normally a full sha (rev-parse HEAD); guard a degenerate/empty
|
|
223
|
+
// value so the line never renders "HEAD ·" with the identity dropped.
|
|
224
|
+
const head = data.gitMeta.head.slice(0, 7) || 'unknown';
|
|
225
|
+
lines.push('', `Freshness: HEAD ${head} · analyzed ${analyzed} ago · window ${data.windowDays}d`);
|
|
226
|
+
}
|
|
154
227
|
if (data.branch !== null) {
|
|
155
228
|
lines.push('', `### Branch ${BEHAVIORAL_TAG}`, formatBranchLine(data.branch));
|
|
156
229
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { errMsg } from '../logger.js';
|
|
2
|
+
import { computeNoteStatusSafe, newCommitCache, newFileProbeCache, } from '../notes/staleness.js';
|
|
3
|
+
import { estimate, normalizeFilePath, readinessBanner, textResponse, } from './common.js';
|
|
4
|
+
import { renderNote } from './note-render.js';
|
|
5
|
+
const DEFAULT_LIMIT = 10;
|
|
6
|
+
const MAX_LIMIT = 50;
|
|
7
|
+
const DEFAULT_MAX_TOKENS = 3000;
|
|
8
|
+
// The summary line + truncation tail are emitted around the note blocks but not
|
|
9
|
+
// counted per-block; reserve for their worst-case length (a full multi-lever
|
|
10
|
+
// tail + count breakdown runs ~200 chars) so the response respects the soft
|
|
11
|
+
// max_tokens budget rather than overshooting.
|
|
12
|
+
const SUMMARY_RESERVE = 64;
|
|
13
|
+
export async function runRecall(args, deps) {
|
|
14
|
+
try {
|
|
15
|
+
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
|
16
|
+
const maxTokens = args.max_tokens ?? DEFAULT_MAX_TOKENS;
|
|
17
|
+
const banner = readinessBanner(deps.indexer.ready);
|
|
18
|
+
// Ensure the store is loaded (retries a prior transient read failure so a
|
|
19
|
+
// recovered store serves its real notes instead of a stale empty view).
|
|
20
|
+
await deps.notes.load();
|
|
21
|
+
// A store-level degraded notice — set when the prior notes were quarantined
|
|
22
|
+
// (moved aside), an interrupted-swap `.bak` may hold recoverable notes, or the
|
|
23
|
+
// store is transiently unreadable. Surfaced on EVERY recall (not just the
|
|
24
|
+
// zero-match one): after a quarantine the agent may add new notes, so a later
|
|
25
|
+
// NON-empty recall must still warn that the earlier notes were moved aside and
|
|
26
|
+
// need manual recovery. A newer-version store that SERVES its notes has no
|
|
27
|
+
// notice (its zero-match filter is a normal no-match), nor does a truly-empty one.
|
|
28
|
+
const degraded = deps.notes.degradedReason;
|
|
29
|
+
const degradedNote = degraded
|
|
30
|
+
? `\n\n(The note store is degraded: ${degraded})`
|
|
31
|
+
: '';
|
|
32
|
+
const selection = selectNotes(args, deps);
|
|
33
|
+
if ('error' in selection)
|
|
34
|
+
return textResponse(selection.error);
|
|
35
|
+
const { notes, header } = selection;
|
|
36
|
+
if (notes.length === 0) {
|
|
37
|
+
return textResponse(banner + `${header}\n\nNo notes match.${degradedNote}`);
|
|
38
|
+
}
|
|
39
|
+
// Hash each distinct anchored file — and fetch its last commit — at most
|
|
40
|
+
// once across the whole result set.
|
|
41
|
+
const fileCache = newFileProbeCache();
|
|
42
|
+
const commitCache = newCommitCache();
|
|
43
|
+
const stalenessDeps = {
|
|
44
|
+
index: deps.index,
|
|
45
|
+
config: deps.config,
|
|
46
|
+
git: deps.git,
|
|
47
|
+
};
|
|
48
|
+
// Compute staleness for the CHECKED window (top `limit`) concurrently — the
|
|
49
|
+
// fileCache dedups same-file probes and each note is independent, so this
|
|
50
|
+
// avoids serializing per-note git/disk latency.
|
|
51
|
+
const considered = notes.slice(0, limit);
|
|
52
|
+
// Per-note failure isolation lives in computeNoteStatusSafe (shared with
|
|
53
|
+
// get_context's notes section) — one bad anchor degrades its note only.
|
|
54
|
+
const statuses = await Promise.all(considered.map(async (note) => ({
|
|
55
|
+
note,
|
|
56
|
+
status: await computeNoteStatusSafe(note, stalenessDeps, fileCache, commitCache),
|
|
57
|
+
})));
|
|
58
|
+
const staleCount = statuses.filter((s) => s.status.overall === 'stale').length;
|
|
59
|
+
const missingCount = statuses.filter((s) => s.status.overall === 'missing').length;
|
|
60
|
+
// Render as many as fit the token budget.
|
|
61
|
+
const rendered = [];
|
|
62
|
+
let used = estimate(header) + SUMMARY_RESERVE;
|
|
63
|
+
for (const { note, status } of statuses) {
|
|
64
|
+
const block = renderNote(note, status);
|
|
65
|
+
const cost = estimate(block);
|
|
66
|
+
if (rendered.length > 0 && used + cost > maxTokens)
|
|
67
|
+
break;
|
|
68
|
+
rendered.push(block);
|
|
69
|
+
used += cost;
|
|
70
|
+
}
|
|
71
|
+
// Two DISTINCT kinds of omission: `limitHid` notes were never CHECKED
|
|
72
|
+
// (staleness unknown — so "0 stale" must NOT read as "area is clean"), while
|
|
73
|
+
// `budgetHid` notes were checked+counted but not rendered under the budget.
|
|
74
|
+
const limitHid = notes.length - considered.length;
|
|
75
|
+
const budgetHid = considered.length - rendered.length;
|
|
76
|
+
const flags = [
|
|
77
|
+
staleCount > 0 ? `${staleCount} stale` : '',
|
|
78
|
+
missingCount > 0 ? `${missingCount} missing` : '',
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join(', ');
|
|
82
|
+
const shownDesc = rendered.length < considered.length
|
|
83
|
+
? `${rendered.length} shown of ${considered.length} checked`
|
|
84
|
+
: `${rendered.length} shown`;
|
|
85
|
+
const summary = `${header} — ${shownDesc}${flags ? `, ${flags}` : ''}`;
|
|
86
|
+
const moreParts = [
|
|
87
|
+
budgetHid > 0 ? `${budgetHid} checked but not shown` : '',
|
|
88
|
+
limitHid > 0 ? `${limitHid} not checked` : '',
|
|
89
|
+
].filter(Boolean);
|
|
90
|
+
const levers = [
|
|
91
|
+
limitHid > 0 && limit < MAX_LIMIT ? 'raise `limit`' : '',
|
|
92
|
+
budgetHid > 0 ? 'raise `max_tokens`' : '',
|
|
93
|
+
moreParts.length > 0 ? 'narrow with `file`/`symbol`/`query`' : '',
|
|
94
|
+
].filter(Boolean);
|
|
95
|
+
const tail = moreParts.length
|
|
96
|
+
? `\n\n(${moreParts.join('; ')}${levers.length ? ` — ${levers.join(', ')}` : ''}.)`
|
|
97
|
+
: '';
|
|
98
|
+
return textResponse(banner + [summary, ...rendered].join('\n\n') + tail + degradedNote);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return textResponse(`Error: ${errMsg(err)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function tokenize(query) {
|
|
105
|
+
return query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
// True if any query token appears in the note's text or its anchors. For an
|
|
108
|
+
// anchor on `excludeFile` (the file+query path — every candidate is already
|
|
109
|
+
// anchored there) the PATH is dropped from the haystack but the anchored SYMBOL
|
|
110
|
+
// is kept: leaving the path in would make a token that's a substring of it
|
|
111
|
+
// (e.g. "auth" in "src/auth.ts") match every note, but dropping the symbol too
|
|
112
|
+
// would lose a legitimate `query="login"` match on a `file:login` anchor.
|
|
113
|
+
function noteMatchesTokens(note, tokens, excludeFile) {
|
|
114
|
+
const anchorHay = note.anchors
|
|
115
|
+
.map((a) => (a.file === excludeFile ? (a.symbol ?? '') : `${a.file} ${a.symbol ?? ''}`))
|
|
116
|
+
.join(' ');
|
|
117
|
+
const hay = `${note.text} ${anchorHay}`.toLowerCase();
|
|
118
|
+
return tokens.some((t) => hay.includes(t));
|
|
119
|
+
}
|
|
120
|
+
function selectNotes(args, deps) {
|
|
121
|
+
const symbol = args.symbol?.trim();
|
|
122
|
+
const query = args.query?.trim();
|
|
123
|
+
const fileArg = args.file?.trim(); // trim so a padded path still matches anchors
|
|
124
|
+
if (fileArg) {
|
|
125
|
+
const rel = normalizeFilePath(fileArg, deps.config.projectRoot);
|
|
126
|
+
if (rel === null) {
|
|
127
|
+
return { error: `Error: file "${args.file}" is outside the project root.` };
|
|
128
|
+
}
|
|
129
|
+
let notes = symbol ? deps.notes.bySymbol(rel, symbol) : deps.notes.byFile(rel);
|
|
130
|
+
let header = symbol
|
|
131
|
+
? `Notes anchored to \`${rel}:${symbol}\``
|
|
132
|
+
: `Notes anchored to \`${rel}\``;
|
|
133
|
+
// Honor `query` as an additional filter over the anchored subset (matching
|
|
134
|
+
// note TEXT + OTHER anchors, not the already-known filter path).
|
|
135
|
+
if (query) {
|
|
136
|
+
const tokens = tokenize(query);
|
|
137
|
+
notes = notes.filter((n) => noteMatchesTokens(n, tokens, rel));
|
|
138
|
+
header += ` matching "${query}"`;
|
|
139
|
+
}
|
|
140
|
+
return { notes, header };
|
|
141
|
+
}
|
|
142
|
+
if (query) {
|
|
143
|
+
// A `symbol` without a `file` can't be resolved as an anchor, but if a query
|
|
144
|
+
// is present, honor the query (folding the orphan symbol in as a search term)
|
|
145
|
+
// rather than erroring or discarding it. The header reflects the ACTUAL query.
|
|
146
|
+
const q = symbol ? `${symbol} ${query}` : query;
|
|
147
|
+
return {
|
|
148
|
+
notes: deps.notes.search(q).map((r) => r.note),
|
|
149
|
+
header: `Notes matching "${q}"`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Anchors are file-scoped, so a symbol with no file and no query can't be
|
|
153
|
+
// resolved — reject in-band rather than silently returning every note.
|
|
154
|
+
if (symbol) {
|
|
155
|
+
return {
|
|
156
|
+
error: 'Error: `symbol` requires `file` (anchors are file-scoped). ' +
|
|
157
|
+
'Provide both, or use `query` to search note text.',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// No filter → all notes, recency-ranked.
|
|
161
|
+
return {
|
|
162
|
+
notes: deps.notes.search('').map((r) => r.note),
|
|
163
|
+
header: 'All notes',
|
|
164
|
+
};
|
|
165
|
+
}
|