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.
@@ -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 { BEHAVIORAL_TAG, MODULE_LEVEL, NAME_MATCH_HEADER_QUALIFIER, NAME_MATCH_TAG, STRUCTURAL_TAG, displaySignature, formatComplexity, normalizeFilePath, pickByLine, plural, readinessBanner, renderAmbiguous, renderSuggestions, safeReadIndexedFile, sectionOrEmpty, sectionOrNone, textResponse, topCoChangePartners, } from './common.js';
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
- // `co_changes` and `git` sit last: they render at the end and are the
12
- // first casualties under max_tokens pressure (enrichment, not core).
13
- const ALL_SECTIONS = ['body', 'callers', 'callees', 'coupling', 'imports', 'co_changes', 'git'];
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 lower = s.toLowerCase();
86
- if (ALL_SECTIONS.includes(lower)) {
87
- set.add(lower);
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
- return set;
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
- }
@@ -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
+ }
@@ -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
- appendGitSections(lines, await collectGitData(deps));
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: deps.index.getGitMeta()?.windowDays ?? deps.config.gitWindow,
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
+ }