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
|
@@ -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:
|
|
60
|
+
version: PACKAGE_VERSION,
|
|
21
61
|
});
|
|
22
62
|
server.registerTool("overview", {
|
|
23
|
-
description: "
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
|
129
|
+
description: "'Who uses X?' Cross-file callers ranked by confidence (directory + import proximity), not raw text matches like grep — each 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: "
|
|
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: "
|
|
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
|
}
|
package/dist/tools/common.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 —
|
|
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
|
+
}
|