pi-plans 0.2.0 → 0.3.1
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 +90 -26
- package/agents/ref-analyst.md +18 -0
- package/index.ts +121 -9
- package/package.json +16 -1
- package/references/pi-planning-workflow.md +21 -6
- package/references/state-and-config.md +52 -5
- package/scripts/validate.ts +5 -0
- package/skills/plan-with-refs/SKILL.md +3 -3
- package/src/code-graph/commands.ts +483 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1125 -371
- package/src/config-command.ts +361 -0
- package/src/exec.ts +508 -693
- package/src/guard.ts +14 -1
- package/src/refine-prompts.ts +109 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +88 -22
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +36 -7
- package/src/subagent.ts +164 -61
- package/src/termination-prompt.ts +22 -0
- package/tests/analyze-refs.test.ts +265 -0
- package/tests/ask-choice.test.ts +264 -0
- package/tests/autocomplete.test.ts +6 -1
- package/tests/code-graph-apply-action.test.ts +173 -0
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +371 -57
- package/tests/config-command.test.ts +263 -0
- package/tests/exec.test.ts +808 -241
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -0
- package/tests/guard.test.ts +27 -1
- package/tests/plans.test.ts +10 -0
- package/tests/refine-prompts.test.ts +101 -2
- package/tests/refine-ui.test.ts +371 -72
- package/tests/state.test.ts +32 -0
- package/tests/subagent.test.ts +48 -20
- package/tools/analyze-refs.ts +263 -0
- package/tools/ask-choice.ts +159 -11
- package/tools/code-graph.ts +277 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +97 -2
- package/tools/refine.ts +61 -15
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-facing graph tool. Eagerly avoids importing node:sqlite or the
|
|
3
|
+
* parsers at module load; both are loaded on first action call.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import {
|
|
10
|
+
loadGraphRuntime,
|
|
11
|
+
describeRuntimeIssues,
|
|
12
|
+
type RuntimeStatus,
|
|
13
|
+
} from "../src/code-graph/runtime.ts";
|
|
14
|
+
import { resolveCanonicalWorktree } from "../src/code-graph/paths.ts";
|
|
15
|
+
import type { WorktreePaths } from "../src/code-graph/paths.ts";
|
|
16
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
17
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
18
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
19
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
20
|
+
import { hashText } from "../src/code-graph/parser.ts";
|
|
21
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
22
|
+
import { screeningQuery } from "../src/code-graph/screening.ts";
|
|
23
|
+
import { deleteFile, listPending, updateFile, updateFunction } from "../src/code-graph/mutations.ts";
|
|
24
|
+
import { applyGraphCore } from "../src/code-graph/commands.ts";
|
|
25
|
+
import { normalizeWorkdir } from "../src/state.ts";
|
|
26
|
+
|
|
27
|
+
const CodeGraphParams = Type.Object({
|
|
28
|
+
action: StringEnum(
|
|
29
|
+
[
|
|
30
|
+
"status",
|
|
31
|
+
"screening",
|
|
32
|
+
"get-function",
|
|
33
|
+
"update-function",
|
|
34
|
+
"update-file",
|
|
35
|
+
"delete-file",
|
|
36
|
+
"list-pending",
|
|
37
|
+
"reindex",
|
|
38
|
+
"manifest",
|
|
39
|
+
"apply",
|
|
40
|
+
] as const,
|
|
41
|
+
{ description: "Code graph action to perform" },
|
|
42
|
+
),
|
|
43
|
+
workdir: Type.Optional(Type.String({ description: "Target workspace directory" })),
|
|
44
|
+
language: Type.Optional(StringEnum(["javascript", "typescript", "tsx", "python"] as const)),
|
|
45
|
+
functionName: Type.Optional(Type.String()),
|
|
46
|
+
fileDir: Type.Optional(Type.String({ description: "File directory (POSIX, '.' for root)" })),
|
|
47
|
+
fileName: Type.Optional(Type.String({ description: "File name without directory" })),
|
|
48
|
+
fullCode: Type.Optional(Type.String({ description: "New function body text for update-function" })),
|
|
49
|
+
text: Type.Optional(Type.String({ description: "New whole-file text for update-file" })),
|
|
50
|
+
limit: Type.Optional(Type.Number()),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type CodeGraphContext = Parameters<Parameters<ExtensionAPI["registerTool"]>[0]["execute"]>[4];
|
|
54
|
+
|
|
55
|
+
export interface RuntimeCacheEntry {
|
|
56
|
+
runtime: Awaited<ReturnType<typeof loadGraphRuntime>>["runtime"];
|
|
57
|
+
parsers: Record<Language, ParserBackend>;
|
|
58
|
+
paths: WorktreePaths;
|
|
59
|
+
store: Store;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let runtimeCache: RuntimeCacheEntry | null = null;
|
|
63
|
+
|
|
64
|
+
export async function ensureRuntime(workdir: string, ctx: CodeGraphContext): Promise<{ entry: RuntimeCacheEntry; status: RuntimeStatus } | null> {
|
|
65
|
+
const { runtime, status } = await loadGraphRuntime();
|
|
66
|
+
if (status.issues.length > 0 && !status.sqliteAvailable && !status.parserAvailable) {
|
|
67
|
+
ctx.ui?.notify?.(`code-graph unavailable: ${describeRuntimeIssues(status).join("; ")}`, "warning");
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const paths = resolveCanonicalWorktree(workdir);
|
|
71
|
+
if (!runtimeCache || runtimeCache.paths.codeGraphDb !== paths.codeGraphDb) {
|
|
72
|
+
if (runtimeCache) {
|
|
73
|
+
runtimeCache.store.close();
|
|
74
|
+
runtimeCache = null;
|
|
75
|
+
}
|
|
76
|
+
const store = new Store({ dbPath: paths.codeGraphDb, worktreeRoot: paths.worktreeRoot, gitCommonDir: paths.gitCommonDir }, runtime.sqlite);
|
|
77
|
+
try {
|
|
78
|
+
store.checkWorktree(paths.worktreeRoot, paths.gitCommonDir);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
store.close();
|
|
81
|
+
ctx.ui?.notify?.(`code-graph: ${(error as Error).message}`, "error");
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const ParserCtor = runtime.parser.Parser as unknown as new () => { parse(input: string | Buffer): unknown; setLanguage(language: unknown): void };
|
|
85
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
86
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.parser.javascript),
|
|
87
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.parser.typescript),
|
|
88
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.parser.tsx),
|
|
89
|
+
python: new PythonBackend(ParserCtor, runtime.parser.python),
|
|
90
|
+
};
|
|
91
|
+
runtimeCache = { runtime, parsers, paths, store };
|
|
92
|
+
}
|
|
93
|
+
return { entry: runtimeCache, status };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function registerCodeGraphTool(pi: ExtensionAPI): void {
|
|
97
|
+
pi.registerTool({
|
|
98
|
+
name: "code_graph",
|
|
99
|
+
label: "Code Graph",
|
|
100
|
+
description:
|
|
101
|
+
"Code-graph actions: read-only queries (status, screening without full_code, function read, manifest summary) plus `apply` — safe non-force materialization of DB-first staged edits into the worktree (same gate as /apply-graph: refused during active planning/accepted runs and for read-only refiner subagents via PI_PLANS_REFINER; returns per-file report with counts and a post-apply drift summary; never changes run status). reindex stays user-only (/init-graph).",
|
|
102
|
+
promptSnippet: "Read-only code graph queries",
|
|
103
|
+
parameters: CodeGraphParams,
|
|
104
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
105
|
+
const workdir = params.workdir ?? ctx.cwd;
|
|
106
|
+
if (params.action === "apply") {
|
|
107
|
+
if (process.env.PI_PLANS_REFINER === "1") {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "code-graph apply refused: read-only refiner subagents cannot materialize worktree edits (PI_PLANS_REFINER)" }) }],
|
|
110
|
+
details: {},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const core = await applyGraphCore(normalizeWorkdir(workdir));
|
|
114
|
+
if (core.refused || core.failed || !core.report) {
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: core.refused ?? core.failed ?? "unknown error" }) }],
|
|
117
|
+
details: {},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const counts: Record<string, number> = { ok: 0, deleted: 0, stale: 0, "skipped-missing": 0, error: 0 };
|
|
121
|
+
for (const file of core.report.files) counts[file.status] = (counts[file.status] ?? 0) + 1;
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, report: { counts, files: core.report.files }, drift: core.drift ?? null }) }],
|
|
124
|
+
details: {},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const ensured = await ensureRuntime(workdir, ctx);
|
|
128
|
+
if (!ensured) {
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "runtime unavailable" }) }],
|
|
131
|
+
details: {},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const { entry } = ensured;
|
|
135
|
+
switch (params.action) {
|
|
136
|
+
case "status":
|
|
137
|
+
return {
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: "text",
|
|
141
|
+
text: JSON.stringify({
|
|
142
|
+
ok: true,
|
|
143
|
+
dbPath: entry.paths.codeGraphDb,
|
|
144
|
+
worktreeRoot: entry.paths.worktreeRoot,
|
|
145
|
+
files: entry.store.read(() => entry.store.db.prepare("SELECT COUNT(*) AS c FROM files").get()) as { c: number } | undefined,
|
|
146
|
+
functions: entry.store.read(() => entry.store.db.prepare("SELECT COUNT(*) AS c FROM functions").get()) as { c: number } | undefined,
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
details: {},
|
|
151
|
+
};
|
|
152
|
+
case "screening": {
|
|
153
|
+
const items = screeningQuery({
|
|
154
|
+
store: entry.store,
|
|
155
|
+
language: params.language,
|
|
156
|
+
functionNameLike: params.functionName,
|
|
157
|
+
limit: params.limit ?? 100,
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, items }) }],
|
|
161
|
+
details: {},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
case "get-function": {
|
|
165
|
+
if (!params.functionName) {
|
|
166
|
+
return {
|
|
167
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "functionName required" }) }],
|
|
168
|
+
details: {},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const row = entry.store.read(() =>
|
|
172
|
+
entry.store.db
|
|
173
|
+
.prepare(
|
|
174
|
+
`SELECT file_dir, file_name, function_name, full_code, render_code,
|
|
175
|
+
full_code_hash, render_code_hash, version, kind
|
|
176
|
+
FROM functions
|
|
177
|
+
WHERE function_name = ? LIMIT 1`,
|
|
178
|
+
)
|
|
179
|
+
.get(params.functionName),
|
|
180
|
+
) as
|
|
181
|
+
| {
|
|
182
|
+
file_dir: string;
|
|
183
|
+
file_name: string;
|
|
184
|
+
function_name: string;
|
|
185
|
+
full_code: string;
|
|
186
|
+
render_code: string;
|
|
187
|
+
full_code_hash: string;
|
|
188
|
+
render_code_hash: string;
|
|
189
|
+
version: number;
|
|
190
|
+
kind: string;
|
|
191
|
+
}
|
|
192
|
+
| undefined;
|
|
193
|
+
if (!row) {
|
|
194
|
+
return {
|
|
195
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "not found" }) }],
|
|
196
|
+
details: {},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, function: row }) }],
|
|
201
|
+
details: {},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
case "update-function": {
|
|
205
|
+
if (!params.fileDir || !params.fileName || !params.functionName || typeof params.fullCode !== "string") {
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "fileDir, fileName, functionName, and fullCode are required" }) }],
|
|
208
|
+
details: {},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const result = updateFunction(entry.store, {
|
|
212
|
+
fileDir: params.fileDir,
|
|
213
|
+
fileName: params.fileName,
|
|
214
|
+
functionName: params.functionName,
|
|
215
|
+
fullCode: params.fullCode,
|
|
216
|
+
});
|
|
217
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: {} };
|
|
218
|
+
}
|
|
219
|
+
case "update-file": {
|
|
220
|
+
if (!params.fileDir || !params.fileName || typeof params.text !== "string") {
|
|
221
|
+
return {
|
|
222
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "fileDir, fileName, and text are required" }) }],
|
|
223
|
+
details: {},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const result = updateFile(entry.store, {
|
|
227
|
+
fileDir: params.fileDir,
|
|
228
|
+
fileName: params.fileName,
|
|
229
|
+
text: params.text,
|
|
230
|
+
...(params.language ? { language: params.language } : {}),
|
|
231
|
+
});
|
|
232
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: {} };
|
|
233
|
+
}
|
|
234
|
+
case "delete-file": {
|
|
235
|
+
if (!params.fileDir || !params.fileName) {
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "fileDir and fileName are required" }) }],
|
|
238
|
+
details: {},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const result = deleteFile(entry.store, { fileDir: params.fileDir, fileName: params.fileName });
|
|
242
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: {} };
|
|
243
|
+
}
|
|
244
|
+
case "list-pending": {
|
|
245
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, pending: listPending(entry.store) }) }], details: {} };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
case "reindex":
|
|
249
|
+
return {
|
|
250
|
+
content: [
|
|
251
|
+
{
|
|
252
|
+
type: "text",
|
|
253
|
+
text: JSON.stringify({
|
|
254
|
+
ok: false,
|
|
255
|
+
reason: "reindex must run via the /init-graph slash command (D-013)",
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
details: {},
|
|
260
|
+
};
|
|
261
|
+
case "manifest": {
|
|
262
|
+
const rows = entry.store.read(() =>
|
|
263
|
+
entry.store.db
|
|
264
|
+
.prepare(
|
|
265
|
+
`SELECT file_dir, file_name, COUNT(*) AS c FROM file_entries GROUP BY file_dir, file_name ORDER BY file_dir, file_name`,
|
|
266
|
+
)
|
|
267
|
+
.all(),
|
|
268
|
+
) as Array<{ file_dir: string; file_name: string; c: number }>;
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, manifest: rows }) }],
|
|
271
|
+
details: {},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_MAX_BYTES,
|
|
4
|
+
DEFAULT_MAX_LINES,
|
|
5
|
+
createEditTool,
|
|
6
|
+
createReadTool,
|
|
7
|
+
createWriteTool,
|
|
8
|
+
truncateHead,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { isIndexablePath } from "../src/code-graph/discovery.ts";
|
|
13
|
+
import { resolveGraphMode, type GraphMode } from "../src/code-graph/mode.ts";
|
|
14
|
+
import { normalizeRelative, PathError } from "../src/code-graph/paths.ts";
|
|
15
|
+
import { updateFile } from "../src/code-graph/mutations.ts";
|
|
16
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
17
|
+
import { ensureRuntime, type CodeGraphContext, type RuntimeCacheEntry } from "./code-graph.ts";
|
|
18
|
+
|
|
19
|
+
interface GraphPathInfo {
|
|
20
|
+
absolutePath: string;
|
|
21
|
+
relativePath: string;
|
|
22
|
+
fileDir: string;
|
|
23
|
+
fileName: string;
|
|
24
|
+
language: Language;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GraphSnapshot {
|
|
28
|
+
info: GraphPathInfo;
|
|
29
|
+
text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FunctionRow {
|
|
33
|
+
function_name: string;
|
|
34
|
+
is_primary: number;
|
|
35
|
+
provenance_start_line: number | null;
|
|
36
|
+
provenance_end_line: number | null;
|
|
37
|
+
provenance_start_byte: number | null;
|
|
38
|
+
provenance_end_byte: number | null;
|
|
39
|
+
summary_description: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type GraphToolSet = ReturnType<typeof createGraphAwareFileTools>;
|
|
43
|
+
|
|
44
|
+
/** Files smaller than this are returned in full by default (digest not worth it). */
|
|
45
|
+
const FULL_FILE_MAX_LINES = 200;
|
|
46
|
+
/** Hard cap for digest output, header/footer lines included. */
|
|
47
|
+
const DIGEST_MAX_LINES = 50;
|
|
48
|
+
/** Native-equivalent full-read truncation hints are provided by truncateHead. */
|
|
49
|
+
|
|
50
|
+
const toolCache = new Map<string, GraphToolSet>();
|
|
51
|
+
|
|
52
|
+
function getBaseTools(cwd: string) {
|
|
53
|
+
return {
|
|
54
|
+
read: createReadTool(cwd),
|
|
55
|
+
write: createWriteTool(cwd),
|
|
56
|
+
edit: createEditTool(cwd),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const GraphReadParams = Type.Object({
|
|
61
|
+
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
|
62
|
+
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
|
|
63
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
|
64
|
+
full: Type.Optional(
|
|
65
|
+
Type.Boolean({
|
|
66
|
+
description:
|
|
67
|
+
"Return the whole file (through the same safety truncation as native read) instead of the default function digest",
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function languageForFileName(fileName: string): Language | null {
|
|
73
|
+
switch (path.extname(fileName).toLowerCase()) {
|
|
74
|
+
case ".js":
|
|
75
|
+
case ".mjs":
|
|
76
|
+
case ".cjs":
|
|
77
|
+
return "javascript";
|
|
78
|
+
case ".ts":
|
|
79
|
+
return "typescript";
|
|
80
|
+
case ".tsx":
|
|
81
|
+
return "tsx";
|
|
82
|
+
case ".py":
|
|
83
|
+
return "python";
|
|
84
|
+
default:
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveGraphPath(cwd: string, worktreeRoot: string, target: string): GraphPathInfo | null {
|
|
90
|
+
const absolutePath = path.resolve(cwd, target);
|
|
91
|
+
const relativePath = path.relative(worktreeRoot, absolutePath).split(path.sep).join("/");
|
|
92
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return null;
|
|
93
|
+
if (!isIndexablePath(relativePath)) return null;
|
|
94
|
+
try {
|
|
95
|
+
const { fileDir, fileName } = normalizeRelative(worktreeRoot, absolutePath);
|
|
96
|
+
const language = languageForFileName(fileName);
|
|
97
|
+
if (!language) return null;
|
|
98
|
+
return { absolutePath, relativePath, fileDir, fileName, language };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof PathError) return null;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sliceByLines(text: string, offset?: number, limit?: number): string {
|
|
106
|
+
const lines = text.split("\n");
|
|
107
|
+
const start = offset ? Math.max(0, offset - 1) : 0;
|
|
108
|
+
const end = limit !== undefined ? start + Math.max(0, limit) : lines.length;
|
|
109
|
+
return lines.slice(start, end).join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function countLines(text: string): number {
|
|
113
|
+
return text.split("\n").length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function textContent(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
117
|
+
const part = result.content.find((entry) => entry.type === "text" && typeof entry.text === "string");
|
|
118
|
+
return part?.text ?? "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function loadGraphSnapshot(entry: RuntimeCacheEntry, info: GraphPathInfo): GraphSnapshot | null {
|
|
122
|
+
const row = entry.store.read(() =>
|
|
123
|
+
entry.store.db
|
|
124
|
+
.prepare(`SELECT source_text, pending_kind FROM files WHERE file_dir = ? AND file_name = ?`)
|
|
125
|
+
.get(info.fileDir, info.fileName),
|
|
126
|
+
) as { source_text: string; pending_kind: string | null } | undefined;
|
|
127
|
+
if (!row || row.pending_kind === "delete") return null;
|
|
128
|
+
return {
|
|
129
|
+
info,
|
|
130
|
+
text: row.source_text,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function loadFunctionRows(entry: RuntimeCacheEntry, info: GraphPathInfo): FunctionRow[] {
|
|
135
|
+
return entry.store.read(() =>
|
|
136
|
+
entry.store.db
|
|
137
|
+
.prepare(
|
|
138
|
+
`SELECT function_name, is_primary, provenance_start_line, provenance_end_line,
|
|
139
|
+
provenance_start_byte, provenance_end_byte, summary_description
|
|
140
|
+
FROM functions WHERE file_dir = ? AND file_name = ?`,
|
|
141
|
+
)
|
|
142
|
+
.all(info.fileDir, info.fileName),
|
|
143
|
+
) as FunctionRow[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Provenance offsets are byte offsets: decode via Buffer, never string.slice. */
|
|
147
|
+
function describeSlice(text: string, startByte: number | null, endByte: number | null): string {
|
|
148
|
+
if (startByte === null || endByte === null || endByte <= startByte) return "";
|
|
149
|
+
try {
|
|
150
|
+
const buf = Buffer.from(text, "utf8");
|
|
151
|
+
const slice = buf.subarray(startByte, Math.min(endByte, buf.length)).toString("utf8");
|
|
152
|
+
return slice.split("\n")[0]?.trim() ?? "";
|
|
153
|
+
} catch {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function truncatedDetails(
|
|
159
|
+
truncation: ReturnType<typeof truncateHead>,
|
|
160
|
+
totalLines: number,
|
|
161
|
+
): Record<string, unknown> {
|
|
162
|
+
// Counts only — never the content copy; the text already lives in `content`.
|
|
163
|
+
return {
|
|
164
|
+
truncated: truncation.truncated === true,
|
|
165
|
+
truncatedBy: truncation.truncatedBy ?? null,
|
|
166
|
+
totalLines,
|
|
167
|
+
outputLines: truncation.outputLines,
|
|
168
|
+
outputBytes: truncation.outputBytes,
|
|
169
|
+
maxLines: truncation.maxLines,
|
|
170
|
+
maxBytes: truncation.maxBytes,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function fullTextResult(text: string, offset?: number, limit?: number) {
|
|
175
|
+
const totalLines = countLines(text);
|
|
176
|
+
if (offset !== undefined && offset > totalLines) {
|
|
177
|
+
// Align with native read: an offset past EOF is a caller error.
|
|
178
|
+
throw new Error(`offset ${offset} beyond end of file (${totalLines} lines)`);
|
|
179
|
+
}
|
|
180
|
+
const selected = sliceByLines(text, offset, limit);
|
|
181
|
+
const truncation = truncateHead(selected, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: truncation.content }],
|
|
184
|
+
details: truncatedDetails(truncation, totalLines),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Compact function digest: one line per named function, synthetic anonymous
|
|
190
|
+
* entries folded into a single count, capped at DIGEST_MAX_LINES with a tail
|
|
191
|
+
* pointer to the low-token graph tools.
|
|
192
|
+
*/
|
|
193
|
+
function buildDigest(info: GraphPathInfo, text: string, rows: FunctionRow[]) {
|
|
194
|
+
const totalLines = countLines(text);
|
|
195
|
+
const named: FunctionRow[] = [];
|
|
196
|
+
let anonymous = 0;
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
if (row.function_name.includes("<anonymous")) {
|
|
199
|
+
anonymous++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
named.push(row);
|
|
203
|
+
}
|
|
204
|
+
named.sort((a, b) => {
|
|
205
|
+
const primary = (b.is_primary ? 1 : 0) - (a.is_primary ? 1 : 0);
|
|
206
|
+
if (primary !== 0) return primary;
|
|
207
|
+
return (a.provenance_start_line ?? 0) - (b.provenance_start_line ?? 0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const lines: string[] = [
|
|
211
|
+
`${info.relativePath} · ${info.language} · ${named.length} functions${anonymous ? ` (+${anonymous} anonymous)` : ""}`,
|
|
212
|
+
];
|
|
213
|
+
const bodyBudget = DIGEST_MAX_LINES - 2; // header + footer
|
|
214
|
+
for (const row of named) {
|
|
215
|
+
if (lines.length >= bodyBudget - 1) break; // reserve one line for the "+M more" tail
|
|
216
|
+
const description = (row.summary_description ?? "").trim() || describeSlice(text, row.provenance_start_byte, row.provenance_end_byte);
|
|
217
|
+
const range =
|
|
218
|
+
row.provenance_start_line && row.provenance_end_line ? ` (${row.provenance_start_line}-${row.provenance_end_line})` : "";
|
|
219
|
+
lines.push(`${row.function_name}${range}${description ? ` ${description}` : ""}`);
|
|
220
|
+
}
|
|
221
|
+
const hidden = named.length - (lines.length - 1);
|
|
222
|
+
if (hidden > 0) {
|
|
223
|
+
lines.push(`…+${hidden} more (code_graph screening / get-function)`);
|
|
224
|
+
}
|
|
225
|
+
lines.push(`Use full:true for the whole file (${totalLines} lines, safety-truncated), or code_graph get-function for one function body.`);
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
228
|
+
details: { functions: named.length, anonymous, shown: Math.max(0, lines.length - 2), hidden: Math.max(0, hidden), totalLines },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function createGraphReadTool(cwd: string) {
|
|
233
|
+
const base = getBaseTools(cwd).read;
|
|
234
|
+
return {
|
|
235
|
+
...base,
|
|
236
|
+
parameters: GraphReadParams,
|
|
237
|
+
async execute(
|
|
238
|
+
toolCallId: string,
|
|
239
|
+
params: { path: string; offset?: number; limit?: number; full?: boolean },
|
|
240
|
+
signal: AbortSignal | undefined,
|
|
241
|
+
onUpdate: any,
|
|
242
|
+
ctx: CodeGraphContext,
|
|
243
|
+
) {
|
|
244
|
+
const native = async (marker: string | null) => {
|
|
245
|
+
const result = await getBaseTools(ctx.cwd).read.execute(toolCallId, params, signal, onUpdate);
|
|
246
|
+
if (!marker) return result;
|
|
247
|
+
return {
|
|
248
|
+
...result,
|
|
249
|
+
content: [{ type: "text", text: `[graph-read fallback: ${marker} → native]\n${textContent(result)}` }],
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
const mode: GraphMode = resolveGraphMode(ctx.cwd);
|
|
253
|
+
if (mode === "off") return native(null);
|
|
254
|
+
if (mode === "config-unavailable") return native("config read failed");
|
|
255
|
+
const ensured = await ensureRuntime(ctx.cwd, ctx);
|
|
256
|
+
if (!ensured) return native("runtime unavailable");
|
|
257
|
+
const info = resolveGraphPath(ctx.cwd, ensured.entry.paths.worktreeRoot, params.path);
|
|
258
|
+
if (!info) return native(null); // not an indexable source file: native by design
|
|
259
|
+
const snapshot = loadGraphSnapshot(ensured.entry, info);
|
|
260
|
+
if (!snapshot) return native("not indexed");
|
|
261
|
+
const wantsFull = params.full === true || params.offset !== undefined || params.limit !== undefined;
|
|
262
|
+
if (wantsFull) return fullTextResult(snapshot.text, params.offset, params.limit);
|
|
263
|
+
const rows = loadFunctionRows(ensured.entry, info);
|
|
264
|
+
if (countLines(snapshot.text) < FULL_FILE_MAX_LINES || rows.length === 0) {
|
|
265
|
+
return fullTextResult(snapshot.text, params.offset, params.limit);
|
|
266
|
+
}
|
|
267
|
+
return buildDigest(info, snapshot.text, rows);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function createGraphWriteTool(cwd: string) {
|
|
273
|
+
const base = getBaseTools(cwd).write;
|
|
274
|
+
return {
|
|
275
|
+
...base,
|
|
276
|
+
async execute(toolCallId: string, params: { path: string; content: string }, signal: AbortSignal | undefined, onUpdate: any, ctx: CodeGraphContext) {
|
|
277
|
+
const stage = async (marker: string | null) => {
|
|
278
|
+
const result = await getBaseTools(ctx.cwd).write.execute(toolCallId, params, signal, onUpdate);
|
|
279
|
+
if (!marker) return result;
|
|
280
|
+
return {
|
|
281
|
+
...result,
|
|
282
|
+
content: [{ type: "text", text: `[graph-write fallback: ${marker} → native]\n${textContent(result)}` }],
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
const mode: GraphMode = resolveGraphMode(ctx.cwd);
|
|
286
|
+
if (mode === "off") return stage(null);
|
|
287
|
+
if (mode === "config-unavailable") return stage("config read failed");
|
|
288
|
+
const ensured = await ensureRuntime(ctx.cwd, ctx);
|
|
289
|
+
if (!ensured) return stage("runtime unavailable");
|
|
290
|
+
const info = resolveGraphPath(ctx.cwd, ensured.entry.paths.worktreeRoot, params.path);
|
|
291
|
+
if (!info) return stage(null);
|
|
292
|
+
const mutation = updateFile(ensured.entry.store, {
|
|
293
|
+
fileDir: info.fileDir,
|
|
294
|
+
fileName: info.fileName,
|
|
295
|
+
text: params.content,
|
|
296
|
+
language: info.language,
|
|
297
|
+
});
|
|
298
|
+
if (!mutation.ok) {
|
|
299
|
+
throw new Error(`code graph write failed: ${mutation.reason ?? "unknown error"}`);
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
content: [
|
|
303
|
+
{
|
|
304
|
+
type: "text",
|
|
305
|
+
text: `code graph: staged ${mutation.created ? "new" : "updated"} file ${info.relativePath}; run code_graph apply to materialize`,
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
details: {},
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function createGraphEditTool(cwd: string) {
|
|
315
|
+
const base = getBaseTools(cwd).edit;
|
|
316
|
+
return {
|
|
317
|
+
...base,
|
|
318
|
+
async execute(toolCallId: string, params: { path: string; edits: Array<{ oldText: string; newText: string }> }, signal: AbortSignal | undefined, onUpdate: any, ctx: CodeGraphContext) {
|
|
319
|
+
const stage = async (marker: string | null) => {
|
|
320
|
+
const result = await getBaseTools(ctx.cwd).edit.execute(toolCallId, params, signal, onUpdate);
|
|
321
|
+
if (!marker) return result;
|
|
322
|
+
return {
|
|
323
|
+
...result,
|
|
324
|
+
content: [{ type: "text", text: `[graph-edit fallback: ${marker} → native]\n${textContent(result)}` }],
|
|
325
|
+
};
|
|
326
|
+
};
|
|
327
|
+
const mode: GraphMode = resolveGraphMode(ctx.cwd);
|
|
328
|
+
if (mode === "off") return stage(null);
|
|
329
|
+
if (mode === "config-unavailable") return stage("config read failed");
|
|
330
|
+
const ensured = await ensureRuntime(ctx.cwd, ctx);
|
|
331
|
+
if (!ensured) return stage("runtime unavailable");
|
|
332
|
+
const info = resolveGraphPath(ctx.cwd, ensured.entry.paths.worktreeRoot, params.path);
|
|
333
|
+
if (!info) return stage(null);
|
|
334
|
+
const snapshot = loadGraphSnapshot(ensured.entry, info);
|
|
335
|
+
if (!snapshot) {
|
|
336
|
+
throw new Error(`code graph: ${info.relativePath} is not indexed; run /update-graph or /init-graph first`);
|
|
337
|
+
}
|
|
338
|
+
let stagedText: string | null = null;
|
|
339
|
+
const graphEdit = createEditTool(ctx.cwd, {
|
|
340
|
+
operations: {
|
|
341
|
+
access: async () => {},
|
|
342
|
+
readFile: async () => Buffer.from(snapshot.text, "utf8"),
|
|
343
|
+
writeFile: async (_absolutePath: string, content: string) => {
|
|
344
|
+
stagedText = content;
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
const result = await graphEdit.execute(toolCallId, params, signal, onUpdate);
|
|
349
|
+
if (stagedText === null) {
|
|
350
|
+
throw new Error(`code graph edit failed: no staged content captured for ${info.relativePath}`);
|
|
351
|
+
}
|
|
352
|
+
const mutation = updateFile(ensured.entry.store, {
|
|
353
|
+
fileDir: info.fileDir,
|
|
354
|
+
fileName: info.fileName,
|
|
355
|
+
text: stagedText,
|
|
356
|
+
language: info.language,
|
|
357
|
+
});
|
|
358
|
+
if (!mutation.ok) {
|
|
359
|
+
throw new Error(`code graph edit failed: ${mutation.reason ?? "unknown error"}`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
...result,
|
|
363
|
+
content: [
|
|
364
|
+
{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: `${textContent(result)} (staged in code graph; run code_graph apply to materialize)`,
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function createGraphAwareFileTools(cwd: string) {
|
|
375
|
+
let tools = toolCache.get(cwd);
|
|
376
|
+
if (!tools) {
|
|
377
|
+
tools = {
|
|
378
|
+
read: createGraphReadTool(cwd),
|
|
379
|
+
write: createGraphWriteTool(cwd),
|
|
380
|
+
edit: createGraphEditTool(cwd),
|
|
381
|
+
};
|
|
382
|
+
toolCache.set(cwd, tools);
|
|
383
|
+
}
|
|
384
|
+
return tools;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function registerGraphAwareFileTools(pi: ExtensionAPI, cwd = process.cwd()): void {
|
|
388
|
+
const tools = createGraphAwareFileTools(cwd);
|
|
389
|
+
pi.registerTool(tools.read);
|
|
390
|
+
pi.registerTool(tools.write);
|
|
391
|
+
pi.registerTool(tools.edit);
|
|
392
|
+
}
|