pi-plans 0.2.0 → 0.3.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.
Files changed (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. package/tools/refine.ts +61 -15
@@ -0,0 +1,278 @@
1
+ /**
2
+ * SQLite handle, prepared statements and BEGIN IMMEDIATE write transactions
3
+ * for the code-graph module.
4
+ */
5
+
6
+ import type { DatabaseSync, StatementSync } from "node:sqlite";
7
+ import { CURRENT_SCHEMA_VERSION, FUNCTION_RECORDS_VIEW, SCHEMA_STATEMENTS } from "./schema.ts";
8
+ import { PathError } from "./paths.ts";
9
+ import type { CodeGraphSnapshot, GraphMeta } from "./types.ts";
10
+
11
+ export interface StoreOptions {
12
+ dbPath: string;
13
+ worktreeRoot: string;
14
+ gitCommonDir: string;
15
+ parserVersions?: Record<string, string>;
16
+ readonly?: boolean;
17
+ }
18
+
19
+ export class Store {
20
+ readonly db: DatabaseSync;
21
+ readonly dbPath: string;
22
+ readonly worktreeRoot: string;
23
+ readonly gitCommonDir: string;
24
+
25
+ private prepared: Map<string, StatementSync> = new Map();
26
+ private readonly mode: "rw" | "ro";
27
+
28
+ constructor(opts: StoreOptions, sqlite: typeof import("node:sqlite")) {
29
+ this.dbPath = opts.dbPath;
30
+ this.worktreeRoot = opts.worktreeRoot;
31
+ this.gitCommonDir = opts.gitCommonDir;
32
+ this.mode = opts.readonly ? "ro" : "rw";
33
+ this.db = new sqlite.DatabaseSync(opts.dbPath, {
34
+ open: true,
35
+ readOnly: opts.readonly === true,
36
+ enableForeignKeyConstraints: true,
37
+ });
38
+ this.bootstrap(opts);
39
+ }
40
+
41
+ private bootstrap(opts: StoreOptions): void {
42
+ this.db.exec("PRAGMA foreign_keys = ON");
43
+ this.db.exec("PRAGMA journal_mode = WAL");
44
+ this.db.exec("PRAGMA busy_timeout = 5000");
45
+ const existing = this.db
46
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='graph_meta'")
47
+ .get();
48
+ if (!existing) {
49
+ this.applyMigrations();
50
+ this.upsertMeta({
51
+ schemaVersion: CURRENT_SCHEMA_VERSION,
52
+ worktreeRoot: opts.worktreeRoot,
53
+ gitCommonDir: opts.gitCommonDir,
54
+ parserVersions: opts.parserVersions ?? {},
55
+ updatedAt: new Date().toISOString(),
56
+ });
57
+ return;
58
+ }
59
+ const row = this.db.prepare("SELECT MAX(schema_version) AS v FROM graph_meta").get() as { v: number } | undefined;
60
+ const version = row?.v ?? 0;
61
+ if (version < CURRENT_SCHEMA_VERSION) {
62
+ this.applyIncrementalMigrations(version, opts);
63
+ this.upsertMeta({
64
+ schemaVersion: CURRENT_SCHEMA_VERSION,
65
+ worktreeRoot: opts.worktreeRoot,
66
+ gitCommonDir: opts.gitCommonDir,
67
+ parserVersions: opts.parserVersions ?? {},
68
+ updatedAt: new Date().toISOString(),
69
+ });
70
+ }
71
+ }
72
+
73
+ /** Step-level idempotent incremental migration: each step checks its target
74
+ * artifact (PRAGMA table_info / sqlite_master) before applying, so a crash
75
+ * mid-upgrade can be safely re-entered. schema_version advances only after
76
+ * all steps complete. */
77
+ private applyIncrementalMigrations(fromVersion: number, opts: StoreOptions): void {
78
+ this.tx(() => {
79
+ if (fromVersion < 2) {
80
+ const snapshotTable = this.db
81
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='code_graph_snapshot'")
82
+ .get();
83
+ if (!snapshotTable) {
84
+ this.db.exec(
85
+ `CREATE TABLE code_graph_snapshot (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ head_commit TEXT NOT NULL,
88
+ uncommitted_paths TEXT NOT NULL,
89
+ recorded_at TEXT NOT NULL
90
+ )`,
91
+ );
92
+ }
93
+ const columns = this.db.prepare("PRAGMA table_info(files)").all() as Array<{ name: string }>;
94
+ if (!columns.some((column) => column.name === "pending_kind")) {
95
+ this.db.exec("ALTER TABLE files ADD COLUMN pending_kind TEXT");
96
+ }
97
+ const initialSnapshot = this.db
98
+ .prepare("SELECT COUNT(*) AS c FROM code_graph_snapshot")
99
+ .get() as { c: number };
100
+ if (initialSnapshot.c === 0) {
101
+ this.db
102
+ .prepare(
103
+ `INSERT INTO code_graph_snapshot (head_commit, uncommitted_paths, recorded_at)
104
+ VALUES (?, ?, ?)`,
105
+ )
106
+ .run("", "[]", new Date().toISOString());
107
+ }
108
+ }
109
+ void opts;
110
+ });
111
+ }
112
+
113
+ private applyMigrations(): void {
114
+ for (const stmt of SCHEMA_STATEMENTS) this.db.exec(stmt);
115
+ this.db.exec(FUNCTION_RECORDS_VIEW);
116
+ this.db
117
+ .prepare(
118
+ `INSERT INTO graph_meta (schema_version, worktree_root, git_common_dir, parser_versions, updated_at)
119
+ VALUES (?, ?, ?, ?, ?)
120
+ ON CONFLICT(schema_version) DO UPDATE SET
121
+ worktree_root = excluded.worktree_root,
122
+ git_common_dir = excluded.git_common_dir,
123
+ parser_versions = excluded.parser_versions,
124
+ updated_at = excluded.updated_at`,
125
+ )
126
+ .run(
127
+ CURRENT_SCHEMA_VERSION,
128
+ this.worktreeRoot,
129
+ this.gitCommonDir,
130
+ JSON.stringify({}),
131
+ new Date().toISOString(),
132
+ );
133
+ }
134
+
135
+ upsertMeta(meta: GraphMeta): void {
136
+ this.db
137
+ .prepare(
138
+ `INSERT INTO graph_meta (schema_version, worktree_root, git_common_dir, parser_versions, updated_at)
139
+ VALUES (?, ?, ?, ?, ?)
140
+ ON CONFLICT(schema_version) DO UPDATE SET
141
+ worktree_root = excluded.worktree_root,
142
+ git_common_dir = excluded.git_common_dir,
143
+ parser_versions = excluded.parser_versions,
144
+ updated_at = excluded.updated_at`,
145
+ )
146
+ .run(
147
+ meta.schemaVersion,
148
+ meta.worktreeRoot,
149
+ meta.gitCommonDir,
150
+ JSON.stringify(meta.parserVersions),
151
+ meta.updatedAt,
152
+ );
153
+ }
154
+
155
+ upsertSnapshot(headCommit: string, uncommittedPaths: string[]): void {
156
+ this.tx(() => {
157
+ this.db
158
+ .prepare(
159
+ `INSERT INTO code_graph_snapshot (head_commit, uncommitted_paths, recorded_at)
160
+ VALUES (?, ?, ?)`,
161
+ )
162
+ .run(headCommit, JSON.stringify(uncommittedPaths), new Date().toISOString());
163
+ });
164
+ }
165
+
166
+ readLatestSnapshot(): CodeGraphSnapshot | null {
167
+ const row = this.db
168
+ .prepare(
169
+ `SELECT id, head_commit, uncommitted_paths, recorded_at
170
+ FROM code_graph_snapshot ORDER BY id DESC LIMIT 1`,
171
+ )
172
+ .get() as
173
+ | { id: number; head_commit: string; uncommitted_paths: string; recorded_at: string }
174
+ | undefined;
175
+ if (!row) return null;
176
+ return {
177
+ id: row.id,
178
+ headCommit: row.head_commit,
179
+ uncommittedPaths: JSON.parse(row.uncommitted_paths) as string[],
180
+ recordedAt: row.recorded_at,
181
+ };
182
+ }
183
+
184
+ readMeta(): GraphMeta | null {
185
+ const row = this.db
186
+ .prepare(
187
+ `SELECT schema_version, worktree_root, git_common_dir, parser_versions, updated_at
188
+ FROM graph_meta ORDER BY schema_version DESC LIMIT 1`,
189
+ )
190
+ .get() as {
191
+ schema_version: number;
192
+ worktree_root: string;
193
+ git_common_dir: string;
194
+ parser_versions: string;
195
+ updated_at: string;
196
+ } | undefined;
197
+ if (!row) return null;
198
+ return {
199
+ schemaVersion: row.schema_version,
200
+ worktreeRoot: row.worktree_root,
201
+ gitCommonDir: row.git_common_dir,
202
+ parserVersions: JSON.parse(row.parser_versions) as Record<string, string>,
203
+ updatedAt: row.updated_at,
204
+ };
205
+ }
206
+
207
+ checkWorktree(currentWorktreeRoot: string, currentGitCommonDir: string): void {
208
+ const meta = this.readMeta();
209
+ if (!meta) return;
210
+ if (meta.worktreeRoot !== currentWorktreeRoot || meta.gitCommonDir !== currentGitCommonDir) {
211
+ throw new PathError(
212
+ `code_graph.db belongs to ${meta.worktreeRoot} (${meta.gitCommonDir}); current worktree is ${currentWorktreeRoot} (${currentGitCommonDir}). Refusing to share the DB across worktrees.`,
213
+ );
214
+ }
215
+ }
216
+
217
+ /** Run a function inside a write transaction. Uses BEGIN IMMEDIATE so that
218
+ * reads cannot later upgrade to writes and bypass busy timeouts. */
219
+ tx<T>(fn: () => T): T {
220
+ if (this.mode === "ro") throw new Error("store is opened read-only; cannot begin a write transaction");
221
+ this.db.exec("BEGIN IMMEDIATE");
222
+ try {
223
+ const result = fn();
224
+ this.db.exec("COMMIT");
225
+ return result;
226
+ } catch (error) {
227
+ try {
228
+ this.db.exec("ROLLBACK");
229
+ } catch {
230
+ /* rollback failure is non-fatal after a write error */
231
+ }
232
+ throw error;
233
+ }
234
+ }
235
+
236
+ read<T>(fn: () => T): T {
237
+ this.db.exec("BEGIN");
238
+ try {
239
+ const result = fn();
240
+ this.db.exec("COMMIT");
241
+ return result;
242
+ } catch (error) {
243
+ try {
244
+ this.db.exec("ROLLBACK");
245
+ } catch {
246
+ /* ignore */
247
+ }
248
+ throw error;
249
+ }
250
+ }
251
+
252
+ prepare(key: string, sql: string): StatementSync {
253
+ let stmt = this.prepared.get(key);
254
+ if (!stmt) {
255
+ stmt = this.db.prepare(sql);
256
+ this.prepared.set(key, stmt);
257
+ }
258
+ return stmt;
259
+ }
260
+
261
+ close(): void {
262
+ try {
263
+ this.db.close();
264
+ } catch {
265
+ /* best effort */
266
+ }
267
+ }
268
+
269
+ exists(): boolean {
270
+ return true;
271
+ }
272
+ }
273
+
274
+ /** Helper for tests: run a function inside a fresh in-memory store. */
275
+ export async function openStore(opts: StoreOptions): Promise<Store> {
276
+ const sqlite = await import("node:sqlite");
277
+ return new Store(opts, sqlite);
278
+ }
@@ -0,0 +1,435 @@
1
+ /**
2
+ * LLM screening pipeline for code-graph. Records explicit consent and
3
+ * gracefully degrades to pending/declined summaries when the host has no UI
4
+ * or no model.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { hashText } from "./parser.ts";
10
+ import { Store } from "./store.ts";
11
+ import type { FunctionRecord, SummaryRecord } from "./types.ts";
12
+
13
+ export interface CompletionRequest {
14
+ messages: Array<{ role: "user"; content: string }>;
15
+ }
16
+
17
+ export interface CompletionHandle {
18
+ complete: (request: CompletionRequest) => Promise<{
19
+ content: Array<{ type: "text"; text: string }>;
20
+ stopReason?: string;
21
+ }>;
22
+ model?: () => { provider?: string; id?: string; api?: string; reasoning?: boolean };
23
+ thinkingLevel?: () => string | undefined;
24
+ hasUI?: boolean;
25
+ confirm?: (title: string, body: string) => Promise<boolean>;
26
+ notify?: (message: string, kind?: "info" | "warning" | "error") => void;
27
+ }
28
+
29
+ export interface SummaryOptions {
30
+ store: Store;
31
+ ctx: CompletionHandle;
32
+ batchTokens?: number;
33
+ skipConsent?: boolean;
34
+ }
35
+
36
+ export interface SummaryReport {
37
+ processed: number;
38
+ ok: number;
39
+ failed: number;
40
+ declined: number;
41
+ batches: number;
42
+ }
43
+
44
+ const SUMMARY_SCHEMA = {
45
+ type: "object",
46
+ properties: {
47
+ description: { type: "string", maxLength: 280 },
48
+ inputs: { type: "array", items: { type: "string", maxLength: 80 }, maxItems: 8 },
49
+ outputs: { type: "array", items: { type: "string", maxLength: 80 }, maxItems: 8 },
50
+ },
51
+ required: ["description", "inputs", "outputs"],
52
+ additionalProperties: false,
53
+ } as const;
54
+
55
+ /** Conservative default prompt budget per completion call. Provider token
56
+ * counts vary; character-based estimates here intentionally bias low so the
57
+ * system cannot exceed typical context windows even with prompt overhead. */
58
+ const DEFAULT_BATCH_TOKENS = 8_000;
59
+ /** Characters-per-token ratio used for the conservative estimate. */
60
+ const CHARS_PER_TOKEN = 4;
61
+ /** Per-entry overhead added to the estimate (path, name, separator). */
62
+ const PER_ENTRY_OVERHEAD_TOKENS = 32;
63
+ /** Maximum allowed characters for a persisted `summary_error` value. */
64
+ const SUMMARY_ERROR_MAX_LENGTH = 240;
65
+
66
+ const SYSTEM_PROMPT = `You summarize code functions in structured JSON. Each input block starts with a "ref:" line. For each input return exactly one JSON object matching {ref: string, description: string, inputs: string[], outputs: string[]} where ref is the exact ref line value copied verbatim, description <= 280 chars, and arrays of short strings (<= 80 chars, <= 8 entries). Do not include any explanation or additional fields. Output one JSON object per input.`;
67
+
68
+ /** Opaque alignment key echoed back by the model. Built in ONE place so
69
+ * prompt, alignment, and DB writes can never disagree. */
70
+ export function buildRef(fileDir: string, fileName: string, functionName: string): string {
71
+ return `${fileDir}/${fileName}::${functionName}`;
72
+ }
73
+
74
+ export interface PendingSummary {
75
+ fileDir: string;
76
+ fileName: string;
77
+ functionName: string;
78
+ fullCodeHash: string;
79
+ language: string;
80
+ fullCode: string;
81
+ }
82
+
83
+ export function pendingFunctions(store: Store): PendingSummary[] {
84
+ const rows = store
85
+ .read(() =>
86
+ store.db
87
+ .prepare(
88
+ `SELECT file_dir, file_name, function_name, full_code_hash, language, full_code
89
+ FROM functions
90
+ WHERE summary_status IS NULL OR summary_status = 'pending' OR summary_status = 'failed'`,
91
+ )
92
+ .all(),
93
+ ) as Array<{ file_dir: string; file_name: string; function_name: string; full_code_hash: string; language: string; full_code: string }>;
94
+ return rows.map((row) => ({
95
+ fileDir: row.file_dir,
96
+ fileName: row.file_name,
97
+ functionName: row.function_name,
98
+ fullCodeHash: row.full_code_hash,
99
+ language: row.language,
100
+ fullCode: row.full_code,
101
+ }));
102
+ }
103
+
104
+ function cacheKey(input: PendingSummary): string {
105
+ return `${input.language}:${input.fullCodeHash}`;
106
+ }
107
+
108
+ interface CacheEntry {
109
+ summary: SummaryRecord;
110
+ }
111
+
112
+ const cache = new Map<string, CacheEntry>();
113
+
114
+ function boundedLength(value: string, max: number): string {
115
+ return value.length > max ? value.slice(0, max) : value;
116
+ }
117
+
118
+ function boundedErrorMessage(message: string): string {
119
+ const trimmed = message.replace(/[\r\n\t]+/g, " ").trim();
120
+ return boundedLength(trimmed, SUMMARY_ERROR_MAX_LENGTH);
121
+ }
122
+
123
+ function estimateEntryTokens(entry: PendingSummary): number {
124
+ const header = `${entry.fileDir}/${entry.fileName}::${entry.functionName}\n`;
125
+ const chars = header.length + entry.fullCode.length;
126
+ return Math.ceil(chars / CHARS_PER_TOKEN) + PER_ENTRY_OVERHEAD_TOKENS;
127
+ }
128
+
129
+ /** Greedy, deterministic batch builder. Each batch keeps a running token
130
+ * estimate; an entry that does not fit becomes the start of the next batch.
131
+ * A single oversized entry still forms its own batch (no batching progress
132
+ * must silently drop or split an entry). */
133
+ export function buildBatches(pending: PendingSummary[], batchTokens: number): PendingSummary[][] {
134
+ const limit = Math.max(1, batchTokens | 0);
135
+ const batches: PendingSummary[][] = [];
136
+ let current: PendingSummary[] = [];
137
+ let currentTokens = 0;
138
+ for (const entry of pending) {
139
+ const entryTokens = estimateEntryTokens(entry);
140
+ if (current.length === 0) {
141
+ current = [entry];
142
+ currentTokens = entryTokens;
143
+ continue;
144
+ }
145
+ if (currentTokens + entryTokens <= limit) {
146
+ current.push(entry);
147
+ currentTokens += entryTokens;
148
+ } else {
149
+ batches.push(current);
150
+ current = [entry];
151
+ currentTokens = entryTokens;
152
+ }
153
+ }
154
+ if (current.length > 0) batches.push(current);
155
+ return batches;
156
+ }
157
+
158
+ function validate(record: unknown): SummaryRecord | null {
159
+ if (!record || typeof record !== "object") return null;
160
+ const obj = record as Record<string, unknown>;
161
+ if (typeof obj.description !== "string") return null;
162
+ if (!Array.isArray(obj.inputs) || !Array.isArray(obj.outputs)) return null;
163
+ if (!obj.inputs.every((s) => typeof s === "string")) return null;
164
+ if (!obj.outputs.every((s) => typeof s === "string")) return null;
165
+ const description = boundedLength(obj.description, 280);
166
+ const inputs = (obj.inputs as string[]).slice(0, 8).map((s) => boundedLength(s, 80));
167
+ const outputs = (obj.outputs as string[]).slice(0, 8).map((s) => boundedLength(s, 80));
168
+ return {
169
+ description,
170
+ inputs,
171
+ outputs,
172
+ status: "ok",
173
+ schemaVersion: 1,
174
+ };
175
+ }
176
+
177
+ /** Quote-aware balanced-brace scanner: extracts top-level {...} object
178
+ * substrings from raw model output, tolerating pretty-printed objects that
179
+ * span lines, multiple objects on one line, and garbage between objects.
180
+ * A truncated final object is dropped (never mis-parsed). Strings and
181
+ * escapes are skipped so braces inside literals cannot split an object. */
182
+ export function parseSummaryObjects(raw: string): unknown[] {
183
+ const objects: unknown[] = [];
184
+ let depth = 0;
185
+ let start = -1;
186
+ let inString = false;
187
+ let escaped = false;
188
+ for (let i = 0; i < raw.length; i++) {
189
+ const ch = raw[i]!;
190
+ if (inString) {
191
+ if (escaped) escaped = false;
192
+ else if (ch === "\\") escaped = true;
193
+ else if (ch === '"') inString = false;
194
+ continue;
195
+ }
196
+ if (ch === '"') {
197
+ inString = true;
198
+ continue;
199
+ }
200
+ if (ch === "{") {
201
+ if (depth === 0) start = i;
202
+ depth++;
203
+ } else if (ch === "}") {
204
+ if (depth > 0) {
205
+ depth--;
206
+ if (depth === 0 && start >= 0) {
207
+ try {
208
+ objects.push(JSON.parse(raw.slice(start, i + 1)));
209
+ } catch {
210
+ /* malformed object: skip */
211
+ }
212
+ start = -1;
213
+ }
214
+ }
215
+ }
216
+ }
217
+ return objects;
218
+ }
219
+
220
+ export interface AlignOutcome {
221
+ /** Per-input-function resolution, aligned with `updates` order. */
222
+ aligned: Array<{ entry: PendingSummary; record: unknown } | { entry: PendingSummary; record: null }>;
223
+ /** true when order fallback was used (zero ref-carrying records, count equal). */
224
+ orderFallback: boolean;
225
+ }
226
+
227
+ /** Align parsed records back to batch functions by their echoed `ref`.
228
+ * Duplicates: first wins. Unknown refs are dropped (uncounted). When NO
229
+ * record carries a usable ref AND counts match exactly, fall back to order
230
+ * alignment (the pre-ref contract) so legacy responses keep working. */
231
+ export function alignByRef(updates: PendingSummary[], records: unknown[]): AlignOutcome {
232
+ const byRef = new Map<string, unknown>();
233
+ let refRecords = 0;
234
+ for (const record of records) {
235
+ const ref = record && typeof record === "object" && typeof (record as Record<string, unknown>).ref === "string"
236
+ ? (record as Record<string, unknown>).ref
237
+ : null;
238
+ if (ref === null) continue;
239
+ refRecords++;
240
+ if (!byRef.has(ref)) byRef.set(ref, record);
241
+ }
242
+ const orderFallback = refRecords === 0 && records.length === updates.length;
243
+ const aligned: AlignOutcome["aligned"] = updates.map((entry, index) => {
244
+ if (orderFallback) return { entry, record: records[index] ?? null };
245
+ return { entry, record: byRef.get(buildRef(entry.fileDir, entry.fileName, entry.functionName)) ?? null };
246
+ });
247
+ return { aligned, orderFallback };
248
+ }
249
+
250
+ function effectiveEffort(ctx: CompletionHandle): string | undefined {
251
+ const model = ctx.model?.();
252
+ if (!model) return undefined;
253
+ const api = model.api ?? "";
254
+ if (api.includes("openai-completions") || api.includes("openai-responses") || api.includes("anthropic")) {
255
+ return "low";
256
+ }
257
+ return undefined;
258
+ }
259
+
260
+ async function userConsent(opts: SummaryOptions, pending: PendingSummary[]): Promise<boolean> {
261
+ if (opts.skipConsent) return true;
262
+ if (!opts.ctx.hasUI) return false;
263
+ if (!opts.ctx.confirm) return false;
264
+ const model = opts.ctx.model?.();
265
+ const body = [
266
+ `Functions awaiting summary: ${pending.length}`,
267
+ `Model: ${model ? `${model.provider ?? "?"}/${model.id ?? "?"}` : "unknown"}`,
268
+ `Thinking level: ${opts.ctx.thinkingLevel?.() ?? "default"}`,
269
+ `Reasoning capability: ${model?.reasoning ? "yes" : "no"}`,
270
+ `Send source code for each function to the current model?`,
271
+ ].join("\n");
272
+ return await opts.ctx.confirm("Generate code summaries with LLM?", body);
273
+ }
274
+
275
+ export async function generateSummaries(opts: SummaryOptions): Promise<SummaryReport> {
276
+ const pending = pendingFunctions(opts.store);
277
+ if (pending.length === 0) {
278
+ return { processed: 0, ok: 0, failed: 0, declined: 0, batches: 0 };
279
+ }
280
+ const consent = await userConsent(opts, pending);
281
+ if (!consent) {
282
+ markDeclined(opts.store, pending);
283
+ return { processed: pending.length, ok: 0, failed: 0, declined: pending.length, batches: 0 };
284
+ }
285
+ const effort = effectiveEffort(opts.ctx);
286
+ const batchTokens = opts.batchTokens ?? DEFAULT_BATCH_TOKENS;
287
+ const batches = buildBatches(pending, batchTokens);
288
+ const report: SummaryReport = {
289
+ processed: 0,
290
+ ok: 0,
291
+ failed: 0,
292
+ declined: 0,
293
+ batches: batches.length,
294
+ };
295
+ for (let index = 0; index < batches.length; index++) {
296
+ const updates = batches[index];
297
+ opts.ctx.notify?.(
298
+ `code-graph summary batch ${index + 1}/${batches.length} (${updates.length} function(s))`,
299
+ "info",
300
+ );
301
+ const prompts = updates
302
+ .map((entry) => `ref: ${buildRef(entry.fileDir, entry.fileName, entry.functionName)}\n${entry.fullCode}`)
303
+ .join("\n---\n");
304
+ try {
305
+ const response = await opts.ctx.complete({
306
+ messages: [{ role: "user", content: `${SYSTEM_PROMPT}\n\n${prompts}` }],
307
+ });
308
+ const texts = response.content
309
+ .filter((part) => part.type === "text")
310
+ .map((part) => part.text)
311
+ .join("\n");
312
+ const records = parseSummaryObjects(texts);
313
+ const { aligned, orderFallback } = alignByRef(updates, records);
314
+ if (orderFallback) {
315
+ opts.ctx.notify?.("code-graph summary: no refs echoed; aligned by order (legacy response shape)", "info");
316
+ }
317
+ const applied = applyAligned(opts.store, aligned, effort);
318
+ report.processed += applied.processed;
319
+ report.ok += applied.ok;
320
+ report.failed += applied.failed;
321
+ } catch (error) {
322
+ const message = boundedErrorMessage((error as Error).message || "completion failed");
323
+ markFailed(opts.store, updates, message);
324
+ report.processed += updates.length;
325
+ report.failed += updates.length;
326
+ }
327
+ }
328
+ return report;
329
+ }
330
+
331
+ function markDeclined(store: Store, pending: PendingSummary[]): void {
332
+ const stmt = store.prepare(
333
+ "update_declined",
334
+ `UPDATE functions SET summary_status = 'declined', summary_updated_at = ?,
335
+ summary_description = NULL, summary_inputs = NULL, summary_outputs = NULL
336
+ WHERE file_dir = ? AND file_name = ? AND function_name = ?`,
337
+ );
338
+ store.tx(() => {
339
+ const now = new Date().toISOString();
340
+ for (const entry of pending) {
341
+ stmt.run(now, entry.fileDir, entry.fileName, entry.functionName);
342
+ }
343
+ });
344
+ }
345
+
346
+ function markFailed(store: Store, pending: PendingSummary[], message: string): void {
347
+ const stmt = store.prepare(
348
+ "update_failed",
349
+ `UPDATE functions SET summary_status = 'failed', summary_error = ?, summary_updated_at = ?
350
+ WHERE file_dir = ? AND file_name = ? AND function_name = ?`,
351
+ );
352
+ const text = boundedErrorMessage(message);
353
+ store.tx(() => {
354
+ const now = new Date().toISOString();
355
+ for (const entry of pending) {
356
+ stmt.run(text, now, entry.fileDir, entry.fileName, entry.functionName);
357
+ }
358
+ });
359
+ }
360
+
361
+ function applyAligned(
362
+ store: Store,
363
+ aligned: AlignOutcome["aligned"],
364
+ effort: string | undefined,
365
+ ): { processed: number; ok: number; failed: number } {
366
+ const stmt = store.prepare(
367
+ "update_summary",
368
+ `UPDATE functions SET
369
+ summary_description = ?,
370
+ summary_inputs = ?,
371
+ summary_outputs = ?,
372
+ summary_status = ?,
373
+ summary_model = ?,
374
+ summary_schema_version = ?,
375
+ summary_effective_effort = ?,
376
+ summary_error = NULL,
377
+ summary_updated_at = ?
378
+ WHERE file_dir = ? AND file_name = ? AND function_name = ?`,
379
+ );
380
+ const failStmt = store.prepare(
381
+ "update_failed_single",
382
+ `UPDATE functions SET summary_status = 'failed', summary_error = ?, summary_updated_at = ?
383
+ WHERE file_dir = ? AND file_name = ? AND function_name = ?`,
384
+ );
385
+ let ok = 0;
386
+ let failed = 0;
387
+ store.tx(() => {
388
+ const now = new Date().toISOString();
389
+ for (const slot of aligned) {
390
+ const validated = slot.record === null ? null : validate(slot.record);
391
+ if (!validated) {
392
+ const ref = buildRef(slot.entry.fileDir, slot.entry.fileName, slot.entry.functionName);
393
+ const reason = slot.record === null
394
+ ? `no aligned summary record for ${ref} (missing/unknown ref or unparseable object)`
395
+ : `invalid summary fields for ${ref}`;
396
+ failStmt.run(boundedErrorMessage(reason), now, slot.entry.fileDir, slot.entry.fileName, slot.entry.functionName);
397
+ failed++;
398
+ continue;
399
+ }
400
+ stmt.run(
401
+ validated.description,
402
+ JSON.stringify(validated.inputs),
403
+ JSON.stringify(validated.outputs),
404
+ "ok",
405
+ "(current-model)",
406
+ 1,
407
+ effort ?? null,
408
+ now,
409
+ slot.entry.fileDir,
410
+ slot.entry.fileName,
411
+ slot.entry.functionName,
412
+ );
413
+ cache.set(cacheKey(slot.entry), { summary: validated });
414
+ ok++;
415
+ }
416
+ });
417
+ return { processed: aligned.length, ok, failed };
418
+ }
419
+
420
+ export function clearSummaryCache(): void {
421
+ cache.clear();
422
+ }
423
+
424
+ export function summaryCacheStats(): { size: number } {
425
+ return { size: cache.size };
426
+ }
427
+
428
+ export { SUMMARY_SCHEMA };
429
+
430
+ // Minimal smoke: ensures the schema object remains usable as a JSON Schema
431
+ // description for tests and documentation.
432
+ if (process.env.PI_PLANS_GRAPH_DUMP_SCHEMA === "1") {
433
+ const dumpPath = path.join(fs.realpathSync("."), "code-graph-summary.schema.json");
434
+ fs.writeFileSync(dumpPath, JSON.stringify(SUMMARY_SCHEMA, null, 2));
435
+ }