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.
@@ -0,0 +1,168 @@
1
+ import { hashContent } from '../indexer/pipeline.js';
2
+ import { errMsg } from '../logger.js';
3
+ import { safeReadIndexedFile } from '../fs-util.js';
4
+ import { qualifiedSymbolName } from './note-store.js';
5
+ const SEVERITY = {
6
+ fresh: 0,
7
+ unverified: 1,
8
+ stale: 2,
9
+ missing: 3,
10
+ };
11
+ // Note-level verdict = the worst of its anchors. A note with NO anchors is
12
+ // "unverified" (stored, but not staleness-tracked).
13
+ export async function computeNoteStatus(note, deps,
14
+ // Optional caches so a whole recall hashes each distinct file, and fetches
15
+ // each file's last commit, at most once across the entire result set.
16
+ fileCache, commitCache) {
17
+ if (note.anchors.length === 0) {
18
+ return { overall: 'unverified', anchors: [] };
19
+ }
20
+ // Anchors are independent — check them concurrently (the caches dedup any
21
+ // shared file probe / git call).
22
+ const anchors = await Promise.all(note.anchors.map((a) => computeAnchorStatus(a, deps, fileCache, commitCache)));
23
+ let overall = 'fresh';
24
+ for (const a of anchors) {
25
+ if (SEVERITY[a.verdict] > SEVERITY[overall])
26
+ overall = a.verdict;
27
+ }
28
+ return { overall, anchors };
29
+ }
30
+ // computeNoteStatus that can never throw out of a tool call: one bad anchor
31
+ // degrades ITS note to unverified (with the cause in the detail) instead of
32
+ // sinking the whole response. Shared by recall and get_context's notes section
33
+ // so the two surfaces keep identical degradation semantics.
34
+ export async function computeNoteStatusSafe(note, deps, fileCache, commitCache) {
35
+ try {
36
+ return await computeNoteStatus(note, deps, fileCache, commitCache);
37
+ }
38
+ catch (err) {
39
+ return {
40
+ overall: 'unverified',
41
+ anchors: note.anchors.map((anchor) => ({
42
+ anchor,
43
+ verdict: 'unverified',
44
+ detail: `staleness check failed: ${errMsg(err)}`,
45
+ })),
46
+ };
47
+ }
48
+ }
49
+ export function newFileProbeCache() {
50
+ return new Map();
51
+ }
52
+ export function newCommitCache() {
53
+ return new Map();
54
+ }
55
+ async function probeFile(relPath, config) {
56
+ try {
57
+ const content = await safeReadIndexedFile(relPath, config);
58
+ return { state: 'ok', liveHash: hashContent(content) };
59
+ }
60
+ catch (err) {
61
+ const code = err?.code;
62
+ if (code === 'ENOENT')
63
+ return { state: 'missing' };
64
+ return { state: 'unreadable', reason: err?.message };
65
+ }
66
+ }
67
+ export async function computeAnchorStatus(anchor, deps, fileCache, commitCache) {
68
+ // 1. Read the file from DISK and hash it — index-independent, so a disabled
69
+ // or lagging watcher can't produce a false "fresh".
70
+ let probePromise = fileCache?.get(anchor.file);
71
+ if (!probePromise) {
72
+ probePromise = probeFile(anchor.file, deps.config);
73
+ fileCache?.set(anchor.file, probePromise);
74
+ }
75
+ const probe = await probePromise;
76
+ if (probe.state === 'missing') {
77
+ return { anchor, verdict: 'missing', detail: 'file no longer exists' };
78
+ }
79
+ if (probe.state === 'unreadable') {
80
+ return {
81
+ anchor,
82
+ verdict: 'unverified',
83
+ detail: `file could not be read (${probe.reason ?? 'unknown'})`,
84
+ };
85
+ }
86
+ if (anchor.fileContentHash === undefined) {
87
+ return {
88
+ anchor,
89
+ verdict: 'unverified',
90
+ detail: 'no baseline captured at note time (file was unindexed)',
91
+ };
92
+ }
93
+ if (probe.liveHash === anchor.fileContentHash) {
94
+ // Byte-identical file ⇒ the anchored symbol is unchanged too.
95
+ return { anchor, verdict: 'fresh', detail: 'unchanged' };
96
+ }
97
+ // 2. File changed. Refine the detail (and attach a live commit) for stale.
98
+ const detail = describeChange(anchor, deps, probe.liveHash);
99
+ let commitPromise = commitCache?.get(anchor.file);
100
+ if (!commitPromise) {
101
+ commitPromise = deps.git.recentCommits(anchor.file, 1).then((c) => c[0]);
102
+ commitCache?.set(anchor.file, commitPromise);
103
+ }
104
+ const lastCommit = await commitPromise;
105
+ return lastCommit
106
+ ? { anchor, verdict: 'stale', detail, lastCommit }
107
+ : { anchor, verdict: 'stale', detail };
108
+ }
109
+ // For a symbol anchor on a changed file, say WHAT changed by consulting the
110
+ // index AS-IS. recall is read-only, so it must NOT re-index (indexFile mutates
111
+ // the shared index — and can DELETE symbols other tools rely on when a file is
112
+ // now unparseable/excluded). When the watcher is on (default) the index is
113
+ // current, so signature-change detection is precise; with the watcher off the
114
+ // index may lag, so this DETAIL is best-effort — the note is still correctly
115
+ // flagged stale by the authoritative disk-hash comparison above.
116
+ function describeChange(anchor, deps, liveHash) {
117
+ if (anchor.symbolId === undefined || anchor.symbol === undefined) {
118
+ return 'file changed since this note';
119
+ }
120
+ // Trust the index's symbols ONLY when they reflect the CURRENT disk bytes. If
121
+ // the index lags disk (watch off / mid-debounce / cap-skipped) its stored
122
+ // symbolId still matches the note's, which would give a WRONG "signature
123
+ // intact" detail for a signature that actually changed — so fall back to the
124
+ // generic detail rather than mislead. (The note is already correctly stale.)
125
+ const indexed = deps.index.getFile(anchor.file);
126
+ if (indexed === undefined) {
127
+ // The file isn't in the index at all — excluded by config, unknown-language,
128
+ // or not re-scanned after a cache wipe. Re-indexing can't surface its symbols
129
+ // (that's exactly why it's absent), so DON'T advise it; say symbol-level
130
+ // detail is unavailable for this file.
131
+ return 'file changed since this note (not indexed — no symbol-level detail)';
132
+ }
133
+ if (indexed.contentHash !== liveHash) {
134
+ return 'file changed since this note (re-index for symbol-level detail)';
135
+ }
136
+ // anchor.symbol is the QUALIFIED name (e.g. "Class.member"), so reconstruct
137
+ // each candidate's qualified name the same way remember stored it — matching
138
+ // the simple Symbol.name here would never hit for a member anchor and would
139
+ // falsely report every member as "renamed or removed".
140
+ const candidates = deps.index
141
+ .getSymbolsInFile(anchor.file)
142
+ .filter((s) => qualifiedSymbolName(s.fqn, anchor.file, s.name) === anchor.symbol);
143
+ if (candidates.length === 0) {
144
+ return `\`${anchor.symbol}\` was renamed or removed`;
145
+ }
146
+ const match = pickSymbol(candidates, anchor);
147
+ if (match.id === anchor.symbolId) {
148
+ // symbolId is body-insensitive, so an intact id means the SIGNATURE is
149
+ // unchanged — the file edit was the body (or elsewhere in the file).
150
+ return `file edited; \`${anchor.symbol}\` signature intact, body may have changed`;
151
+ }
152
+ const was = anchor.signature ? ` (was \`${anchor.signature}\`)` : '';
153
+ return `\`${anchor.symbol}\` signature changed${was}`;
154
+ }
155
+ // Prefer a candidate whose id matches the snapshot (so an overloaded/duplicated
156
+ // name still reports "intact" when the right one survives); otherwise prefer a
157
+ // kind match; otherwise the first.
158
+ function pickSymbol(candidates, anchor) {
159
+ const byId = candidates.find((s) => s.id === anchor.symbolId);
160
+ if (byId)
161
+ return byId;
162
+ if (anchor.symbolKind) {
163
+ const byKind = candidates.find((s) => s.kind === anchor.symbolKind);
164
+ if (byKind)
165
+ return byKind;
166
+ }
167
+ return candidates[0];
168
+ }
@@ -0,0 +1,19 @@
1
+ // Persisted shape for the agent-curated knowledge layer (the `remember` /
2
+ // `recall` / `forget` tools). This store is DELIBERATELY separate from the
3
+ // code index's `index.json`:
4
+ //
5
+ // - index.json is a DISPOSABLE derived cache — CodeIndex.load() fs.unlink's
6
+ // it on any schema/projectRoot mismatch or corruption, because it can be
7
+ // rebuilt from source in seconds.
8
+ // - notes are PRIMARY user/agent-authored data — NOT rebuildable. They must
9
+ // survive every index invalidation, so they live in their own file with
10
+ // their own version, and on corruption they are QUARANTINED (renamed
11
+ // aside), never deleted. See note-store.ts.
12
+ //
13
+ // Consequently this version is INDEPENDENT of the index SCHEMA_VERSION: adding
14
+ // notes did not (and must not) bump the index schema, and a future index-schema
15
+ // bump leaves these notes untouched.
16
+ // Bump ONLY on a backward-incompatible Note/Anchor shape change. Additive
17
+ // OPTIONAL fields (the expected evolution — e.g. a future per-symbol body hash)
18
+ // need NO bump: a missing field already reads as the "unknown" dimension.
19
+ export const NOTES_STORE_VERSION = 1;
package/dist/server.js CHANGED
@@ -1,12 +1,17 @@
1
1
  // SDK v1.29 takes `inputSchema` as a RAW Zod shape ({ k: z.string() }), not
2
2
  // `z.object({...})`. The v2 alpha uses the wrapped form — do not confuse them.
3
+ import { readFileSync } from "node:fs";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { z } from "zod";
6
+ import { errMsg, log } from "./logger.js";
5
7
  import { runFindReferences } from "./tools/find-references.js";
6
8
  import { runFindSymbol } from "./tools/find-symbol.js";
9
+ import { runForget } from "./tools/forget.js";
7
10
  import { runGetContext } from "./tools/get-context.js";
8
11
  import { runImpact } from "./tools/impact.js";
9
12
  import { runOverview } from "./tools/overview.js";
13
+ import { runRecall } from "./tools/recall.js";
14
+ import { runRemember } from "./tools/remember.js";
10
15
  import { runSearchStructure } from "./tools/search-structure.js";
11
16
  const SHARED_ANNOTATIONS = {
12
17
  readOnlyHint: true,
@@ -14,20 +19,58 @@ const SHARED_ANNOTATIONS = {
14
19
  idempotentHint: true,
15
20
  openWorldHint: false,
16
21
  };
22
+ // remember/forget write the .codedeep note store (never source). readOnlyHint
23
+ // is false so MCP clients surface the write; destructiveHint false (append /
24
+ // scoped single-note removal, no source touched). The store write is the only
25
+ // mutation in the server.
26
+ const WRITE_ANNOTATIONS = {
27
+ readOnlyHint: false,
28
+ destructiveHint: false,
29
+ idempotentHint: false,
30
+ openWorldHint: false,
31
+ };
32
+ // Single source of truth for the advertised server version: the package's own
33
+ // version field. Resolves from both src/ (tests) and dist/ (shipped) — each is
34
+ // one directory below the package root. A hardcoded copy here drifted silently
35
+ // (deferred v0.1.0 release note); reading it makes `npm version` sufficient.
36
+ // Guarded: a bundled/relocated dist (no package.json one level up — or a
37
+ // FOREIGN one that legally omits `version`, e.g. a private monorepo root) must
38
+ // fall back to a placeholder STRING, never crash at import time and never let
39
+ // `undefined` reach McpServer (serverInfo.version is required by the spec — an
40
+ // undefined field would be dropped from the initialize response and can fail
41
+ // client-side schema validation). The fallback is warned, not silent: a
42
+ // placeholder version in a bug report should be traceable to its cause.
43
+ const PACKAGE_VERSION = (() => {
44
+ try {
45
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
46
+ if (typeof version === "string" && version.length > 0)
47
+ return version;
48
+ log.warn("server: ../package.json has no usable `version` field (foreign/rootless " +
49
+ "package.json?); advertising 0.0.0-unknown");
50
+ }
51
+ catch (err) {
52
+ log.warn(`server: could not read ../package.json for the server version ` +
53
+ `(${errMsg(err)}); advertising 0.0.0-unknown`);
54
+ }
55
+ return "0.0.0-unknown";
56
+ })();
17
57
  export function createServer(deps) {
18
58
  const server = new McpServer({
19
59
  name: "codedeep-mcp",
20
- version: "0.1.0",
60
+ version: PACKAGE_VERSION,
21
61
  });
22
62
  server.registerTool("overview", {
23
- description: "Get a structural overview of the codebase: language breakdown, top-level directories, entry points, and symbol counts plus branch summary, git hotspots, and risk hotspots (churn × call-graph coupling) when in a git repo.",
63
+ description: "Start here. 'What is this codebase?' language breakdown, top-level structure, entry points, symbol counts, and remembered-knowledge counts; in a git repo, also branch/hotspots, risk ranking (churn × coupling × complexity), and index freshness. Orient with this before grepping; then drill in with find_symbol / get_context.",
24
64
  inputSchema: {
25
- path: z.string().optional().describe("Project root (default: cwd)"),
65
+ path: z
66
+ .string()
67
+ .optional()
68
+ .describe("Sanity check only: must equal the server's configured project root if given (errors otherwise — one server indexes one root). Omit it; it does NOT scope the overview to a subdirectory."),
26
69
  },
27
70
  annotations: SHARED_ANNOTATIONS,
28
71
  }, async (args) => runOverview(args, deps));
29
72
  server.registerTool("find_symbol", {
30
- description: "AST-aware symbol lookup. Returns definitions matching a name (exact, prefix, or fuzzy), each with fan-in (references), fan-out (callees), and complexity — cyclomatic and cognitive, available for all 14 supported languages. Optional kind/scope/limit filters.",
73
+ description: "'Where is X defined?' Use instead of grep when you want the definition of a named symbol (exact/prefix/fuzzy) with fan-in, fan-out, and cyclomatic+cognitive complexity — not every text occurrence. Then get_context for the body or find_references for callers. Optional kind/scope/limit filters.",
31
74
  inputSchema: {
32
75
  name: z.string().describe("Symbol name (exact, prefix, or fuzzy)"),
33
76
  kind: z
@@ -57,7 +100,7 @@ export function createServer(deps) {
57
100
  annotations: SHARED_ANNOTATIONS,
58
101
  }, async (args) => runFindSymbol(args, deps));
59
102
  server.registerTool("get_context", {
60
- description: "Return everything needed to understand a symbol: full body, within-file callers/callees, coupling (fan-in/fan-out/cyclomatic+cognitive complexity/blast radius), and imports — plus co-change partners and recent commits when git is available.",
103
+ description: "'Tell me everything about this symbol.' Full body (verbatim), within-file callers/callees, coupling (fan-in/out, cyclomatic+cognitive complexity, blast radius), imports, and any remembered notes anchored here (staleness-tagged ✓/⚠) — plus co-change partners and recent commits when git is available. Reach for this after find_symbol instead of opening the file by hand.",
61
104
  inputSchema: {
62
105
  file: z.string().describe("File path (relative to project root)"),
63
106
  symbol: z
@@ -78,12 +121,12 @@ export function createServer(deps) {
78
121
  include: z
79
122
  .array(z.string())
80
123
  .optional()
81
- .describe("Sections to include: body, callers, callees, coupling, imports, co_changes, git"),
124
+ .describe("Sections to include: body, callers, callees, coupling, imports, notes, co_changes, git"),
82
125
  },
83
126
  annotations: SHARED_ANNOTATIONS,
84
127
  }, async (args) => runGetContext(args, deps));
85
128
  server.registerTool("find_references", {
86
- description: "Cross-file usage navigation. Returns approximate AST name-matched callers for a symbol, ranked by directory and import proximityplus co-change partners from git history when available. LSP-precise tiers ship in Phase 2.",
129
+ description: "'Who uses X?' Cross-file callers ranked by confidence (directory + import proximity), not raw text matches like grepeach row tagged [name match] or the weaker [member call]. Rows are AST-derived and confidence-tiered, not compiler-verified — verify before asserting. For the transitive blast radius use impact.",
87
130
  inputSchema: {
88
131
  file: z.string().describe("File containing the symbol (relative to project root)"),
89
132
  symbol: z.string().describe("Symbol name"),
@@ -93,13 +136,7 @@ export function createServer(deps) {
93
136
  .optional()
94
137
  .describe("Disambiguate when multiple symbols share a name"),
95
138
  kind: z
96
- .enum([
97
- "callers",
98
- "callees",
99
- "implementations",
100
- "type_references",
101
- "all",
102
- ])
139
+ .enum(["callers", "callees", "all"])
103
140
  .optional()
104
141
  .describe("Result kind (default: 'all')"),
105
142
  limit: z
@@ -112,7 +149,7 @@ export function createServer(deps) {
112
149
  annotations: SHARED_ANNOTATIONS,
113
150
  }, async (args) => runFindReferences(args, deps));
114
151
  server.registerTool("impact", {
115
- description: "Trace the transitive blast radius of changing a symbol: upstream callers grouped by hop (depth 1, 2, …), with co-change partners from git history. Edges are AST name-matches, not compiler-verified; downstream callees and inheritance ship with LSP in Phase 2.",
152
+ description: "'What breaks if I change X?' Transitive upstream caller tree grouped by hop, with a distinct-caller blast count and git co-change partners something grep can't compute. Each edge tagged by confidence. For direct callers only, use find_references. (Edges are AST name-matches, not compiler-verified — verify before asserting; downstream callees and inheritance are not traversed.)",
116
153
  inputSchema: {
117
154
  file: z
118
155
  .string()
@@ -143,7 +180,7 @@ export function createServer(deps) {
143
180
  annotations: SHARED_ANNOTATIONS,
144
181
  }, async (args) => runImpact(args, deps));
145
182
  server.registerTool("search_structure", {
146
- description: "Keyword and structural code search. Fuzzy-matches symbol names, signatures, and docstrings; with `pattern`, runs an ast-grep structural query instead (TypeScript/TSX/JavaScript only for now).",
183
+ description: "'Find code by keyword or shape.' Fuzzy search over symbol names, signatures, and docstrings (git-churn-boosted), or with `pattern` an ast-grep structural query (TS/TSX/JS only). Use over grep for symbol-aware or structural matches; use plain grep for arbitrary string/comment text. To locate a known symbol by name, find_symbol is more direct.",
147
184
  inputSchema: {
148
185
  query: z
149
186
  .string()
@@ -166,5 +203,57 @@ export function createServer(deps) {
166
203
  },
167
204
  annotations: SHARED_ANNOTATIONS,
168
205
  }, async (args) => runSearchStructure(args, deps));
206
+ server.registerTool("remember", {
207
+ description: "Write a durable note about code that grep/AST can't infer — a cross-file router chain, an invariant, a footgun, an architecture decision. Anchor it to the file(s)/symbol(s) it's about; codedeep then tracks STALENESS — recall flags the note when its anchors change (unlike memories that rot silently), and get_context surfaces anchored notes inline. Writes only to the .codedeep note store, never to source.",
208
+ inputSchema: {
209
+ note: z
210
+ .string()
211
+ .describe("The knowledge to store (markdown ok). Be specific."),
212
+ anchors: z
213
+ .array(z.string())
214
+ .optional()
215
+ .describe("Files/symbols this note is about, e.g. 'src/auth.ts' or 'src/auth.ts:authenticate' (add ':<line>' to disambiguate). Strongly recommended — anchors are what make the note staleness-tracked."),
216
+ },
217
+ annotations: WRITE_ANNOTATIONS,
218
+ }, async (args) => runRemember(args, deps));
219
+ server.registerTool("recall", {
220
+ description: "Retrieve previously-remembered notes, each tagged ✓ fresh / ⚠ stale / ? unverified by re-checking its anchors against the current source. Call before editing a file/symbol ('what do I already know here, and is it still true?'). Filter by `file`/`symbol` (what's anchored here) or `query` (keyword); omit all to list every note.",
221
+ inputSchema: {
222
+ query: z
223
+ .string()
224
+ .optional()
225
+ .describe("Keywords matched against note text and anchors"),
226
+ file: z
227
+ .string()
228
+ .optional()
229
+ .describe("Return notes anchored to this file (relative path)"),
230
+ symbol: z
231
+ .string()
232
+ .optional()
233
+ .describe("With `file`, narrow to notes anchored to this symbol"),
234
+ limit: z
235
+ .number()
236
+ .int()
237
+ .positive()
238
+ .optional()
239
+ .describe("Max notes (default: 10, max: 50)"),
240
+ max_tokens: z
241
+ .number()
242
+ .int()
243
+ .positive()
244
+ .optional()
245
+ .describe("Soft response budget (default: 3000)"),
246
+ },
247
+ annotations: SHARED_ANNOTATIONS,
248
+ }, async (args) => runRecall(args, deps));
249
+ server.registerTool("forget", {
250
+ description: "Delete a note by its id (shown by recall) — for superseded or wrong notes. Writes only to the .codedeep note store.",
251
+ inputSchema: {
252
+ noteId: z.string().describe("The note id to delete (from recall)"),
253
+ },
254
+ // destructiveHint: a forget is an irreversible delete of stored data (not
255
+ // additive like remember), so clients may gate it on confirmation.
256
+ annotations: { ...WRITE_ANNOTATIONS, destructiveHint: true, idempotentHint: true },
257
+ }, async (args) => runForget(args, deps));
169
258
  return server;
170
259
  }
@@ -1,6 +1,9 @@
1
- import { promises as fs } from 'node:fs';
2
- import { join, relative, resolve, sep } from 'node:path';
1
+ import { relative, resolve } from 'node:path';
3
2
  import { partnerOf } from '../git/analyzer.js';
3
+ // Re-exported for the tools' convenience — the implementation lives in the
4
+ // neutral fs-util module so lower layers (notes/staleness) can use it without
5
+ // importing the tools layer.
6
+ export { safeReadIndexedFile } from '../fs-util.js';
4
7
  export function textResponse(text) {
5
8
  return { content: [{ type: 'text', text }] };
6
9
  }
@@ -14,44 +17,6 @@ export function normalizeFilePath(input, projectRoot) {
14
17
  return null;
15
18
  return rel;
16
19
  }
17
- // projectRoot is fixed for the process lifetime, so its realpath is too —
18
- // caching it spares one syscall per safeReadIndexedFile call (pattern
19
- // scans call this once per candidate file).
20
- const realRootCache = new Map();
21
- async function realProjectRoot(projectRoot) {
22
- let cached = realRootCache.get(projectRoot);
23
- if (cached === undefined) {
24
- cached = await fs.realpath(projectRoot);
25
- realRootCache.set(projectRoot, cached);
26
- }
27
- return cached;
28
- }
29
- // Re-check scanner admission rules at read time so stale on-disk
30
- // state (symlink-swap, growth past cap, became-directory) can't
31
- // bypass the indexer's contract.
32
- export async function safeReadIndexedFile(relPath, config) {
33
- const abs = join(config.projectRoot, relPath);
34
- const stats = await fs.lstat(abs);
35
- if (stats.isSymbolicLink()) {
36
- throw new Error('refusing to follow symlink');
37
- }
38
- if (!stats.isFile()) {
39
- throw new Error('not a regular file');
40
- }
41
- if (stats.size > config.maxFileSize) {
42
- throw new Error(`exceeds maxFileSize (${stats.size} > ${config.maxFileSize})`);
43
- }
44
- // lstat only checks the final component. Resolve parent-directory
45
- // symlinks so a swap higher up in the path can't escape projectRoot.
46
- const [real, realRoot] = await Promise.all([
47
- fs.realpath(abs),
48
- realProjectRoot(config.projectRoot),
49
- ]);
50
- if (real !== realRoot && !real.startsWith(realRoot + sep)) {
51
- throw new Error('path escapes project root');
52
- }
53
- return fs.readFile(abs, 'utf8');
54
- }
55
20
  // Among ranges that contain `line`, pick the smallest — targets the innermost
56
21
  // match (e.g. a method inside a same-named class). Returns null when no
57
22
  // candidate spans `line`; callers that want a fallback handle it themselves.
@@ -133,7 +98,7 @@ export const INDEXING_BANNER = '⏳ Indexing in progress. Results may be incompl
133
98
  // Caller-row label for refs at file scope (no enclosing source symbol).
134
99
  export const MODULE_LEVEL = '(module-level)';
135
100
  // Per-line tag stamped on every approximate-name-match caller row so
136
- // consumers can distinguish AST-only matches from precise (LSP) refs.
101
+ // consumers never mistake an AST name-match for a compiler-verified ref.
137
102
  // Shared between `find_references` and `get_context`'s file-mode export
138
103
  // caller summary — both render the same data path.
139
104
  export const NAME_MATCH_TAG = '[name match, unverified]';
@@ -177,6 +142,46 @@ export function formatComplexity(sym) {
177
142
  // code structure. Pairs with the design-notes tier vocabulary
178
143
  // ([structural] / [approximate] / [behavioral]).
179
144
  export const BEHAVIORAL_TAG = '[behavioral]';
145
+ // One-line trust distribution that leads a tiered response, e.g.
146
+ // "Confidence: 3 resolved · 2 name-match (verify) · 1 weak", or '' when there
147
+ // are no rows. Omits zero tiers so it never names a tier the response can't
148
+ // show. The inline "(verify)" makes the line self-describing — there is no
149
+ // separate static tag legend (one would disagree with this pruned summary, and
150
+ // the per-row tags like "[name match, unverified]" already carry the meaning).
151
+ // `truncated` appends a "+ more" marker so the line carries the same
152
+ // incompleteness signal as the caller headline (which appends `+`). Used only
153
+ // by `impact` — the one tool with a mixed-tier caller tree.
154
+ export function confidencePreamble(counts, truncated = false) {
155
+ const parts = [];
156
+ if (counts.structural)
157
+ parts.push(`${counts.structural} resolved`);
158
+ if (counts.nameMatch)
159
+ parts.push(`${counts.nameMatch} name-match (verify)`);
160
+ if (counts.weakMember)
161
+ parts.push(`${counts.weakMember} weak`);
162
+ if (parts.length === 0)
163
+ return '';
164
+ const marker = truncated ? ' (+ more callers not shown)' : '';
165
+ return `Confidence: ${parts.join(' · ')}${marker}`;
166
+ }
167
+ // Compact relative age ("4m" / "2h" / "3d", or "<1m" sub-minute) for an
168
+ // elapsed-ms duration. Used by the overview freshness banner to show how long
169
+ // ago the git analysis ran. Tool handlers run at request time, so `Date.now()`
170
+ // is available to the caller computing the elapsed value.
171
+ export function formatRelativeAge(ms) {
172
+ // Clamp negative elapsed (clock skew / a cache written by a faster clock, or
173
+ // a cache dir copied between machines) to 0 so it reads "<1m" deterministically
174
+ // rather than from undefined negative arithmetic.
175
+ const mins = Math.floor(Math.max(0, ms) / 60_000);
176
+ if (mins < 1)
177
+ return '<1m';
178
+ if (mins < 60)
179
+ return `${mins}m`;
180
+ const hours = Math.floor(mins / 60);
181
+ if (hours < 24)
182
+ return `${hours}h`;
183
+ return `${Math.floor(hours / 24)}d`;
184
+ }
180
185
  // The confidence direction is the most invertible bug in the git layer —
181
186
  // confidenceAB is the from-self direction only when the queried file is
182
187
  // fileA. Centralized (sharing analyzer's partnerOf for the orientation
@@ -202,6 +207,11 @@ export function topCoChangePartners(coChanges, selfPath, limit = 5) {
202
207
  export function readinessBanner(ready) {
203
208
  return ready ? '' : `${INDEXING_BANNER}\n\n`;
204
209
  }
210
+ // Token-budget approximation (CLAUDE.md "Token Budget"): chars / 4. Shared so a
211
+ // new budgeted tool reuses it rather than adding another inline copy.
212
+ export function estimate(text) {
213
+ return Math.ceil(text.length / 4);
214
+ }
205
215
  export function plural(word, count) {
206
216
  return count === 1 ? word : `${word}s`;
207
217
  }
@@ -13,7 +13,6 @@ const WEAK_MEMBER_TIER = 5;
13
13
  const WEAK_MEMBER_ROW_CAP = 8;
14
14
  const CALLERS_HEADER = `### Callers ${NAME_MATCH_HEADER_QUALIFIER}`;
15
15
  const CALLEES_HEADER = '### Callees (within-file — from AST resolution)';
16
- const PHASE_2_NOTE = '(none — ships with LSP in Phase 2)';
17
16
  export async function runFindReferences(args, deps) {
18
17
  try {
19
18
  const file = normalizeFilePath(args.file, deps.config.projectRoot);
@@ -51,18 +50,18 @@ export async function runFindReferences(args, deps) {
51
50
  const kind = args.kind ?? 'all';
52
51
  const sections = [];
53
52
  sections.push(`## References for \`${target.name}\` (${target.file}:${target.startLine})`);
53
+ // No confidence summary here: find_references is reference-granular (one
54
+ // row per call SITE, not per distinct caller) and never emits a [structural]
55
+ // row — the section header already announces the approximate tier and each
56
+ // row carries its own tag. The Confidence summary lives in `impact`, where
57
+ // the caller tree mixes all three tiers and reconciles with the
58
+ // distinct-caller headline.
54
59
  if (kind === 'callers' || kind === 'all') {
55
60
  sections.push(renderCallers(target, deps.index, limit));
56
61
  }
57
62
  if (kind === 'callees' || kind === 'all') {
58
63
  sections.push(renderCallees(target, deps.index, limit));
59
64
  }
60
- if (kind === 'implementations' || kind === 'all') {
61
- sections.push(renderPhase2('Implementations'));
62
- }
63
- if (kind === 'type_references' || kind === 'all') {
64
- sections.push(renderPhase2('Type References'));
65
- }
66
65
  // Behavioral coupling rides along with kind 'all' only: it is
67
66
  // file-granularity enrichment, not a reference kind, and it stays a
68
67
  // separate section rather than a rankRefs tier — a co-committing
@@ -143,7 +142,9 @@ function renderCallers(target, index, limit) {
143
142
  return sectionOrNone(CALLERS_HEADER, body);
144
143
  }
145
144
  function renderCallees(target, index, limit) {
146
- // Within-file only — cross-file callee resolution waits for LSP (Phase 2).
145
+ // Within-file only by design callees are bound at extract time from the
146
+ // one file's AST; cross-file callee resolution is not modeled (the header
147
+ // says so, so an empty list reads as a scope limit, not an all-clear).
147
148
  const callees = index.getCallees(target.id);
148
149
  const body = callees
149
150
  .slice(0, limit)
@@ -153,9 +154,6 @@ function renderCallees(target, index, limit) {
153
154
  }
154
155
  return sectionOrNone(CALLEES_HEADER, body);
155
156
  }
156
- function renderPhase2(label) {
157
- return `### ${label}\n${PHASE_2_NOTE}`;
158
- }
159
157
  // Confidence-only rows — find_references is the breadth view; the
160
158
  // shared-commit detail lives in get_context's co-change section.
161
159
  function renderCoChangePartners(file, index) {
@@ -0,0 +1,26 @@
1
+ import { errMsg } from '../logger.js';
2
+ import { textResponse } from './common.js';
3
+ // A note delete is entirely index-independent, so — unlike remember/recall — it
4
+ // does NOT prepend the readiness banner (which would misleadingly imply the
5
+ // delete was deferred while the index is still building).
6
+ export async function runForget(args, deps) {
7
+ try {
8
+ const id = (args.noteId ?? '').trim();
9
+ if (id.length === 0) {
10
+ return textResponse('Error: noteId must be non-empty.');
11
+ }
12
+ // load() first so a prior transient read failure is retried before we read
13
+ // (and act on) the write-block flag.
14
+ await deps.notes.load();
15
+ const blocked = deps.notes.writeBlockReason;
16
+ if (blocked)
17
+ return textResponse(`Error: ${blocked}`);
18
+ const removed = await deps.notes.remove(id);
19
+ return textResponse(removed
20
+ ? `✓ Forgot note ${id}.`
21
+ : `No note ${id} found. Use recall to list note ids.`);
22
+ }
23
+ catch (err) {
24
+ return textResponse(`Error: ${errMsg(err)}`);
25
+ }
26
+ }