@piagent/platform 1.2.5 → 1.2.6

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 (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +3 -2
  3. package/catalog/capabilities.json +4 -4
  4. package/docs/command-reference-vietnamese.md +9 -8
  5. package/docs/context-engine.md +177 -0
  6. package/docs/context-window-policy.md +13 -8
  7. package/docs/distribution-standard.md +7 -7
  8. package/docs/operator-manual-vietnamese.md +7 -7
  9. package/docs/publishing-for-teams.md +4 -0
  10. package/docs/quality-benchmark.md +14 -1
  11. package/docs/release-install-policy.md +6 -6
  12. package/docs/runtime-harness-standard.md +16 -7
  13. package/docs/security-threat-model.md +1 -1
  14. package/docs/task-lifecycle-vietnamese.md +4 -4
  15. package/docs/team-onboarding.md +2 -2
  16. package/docs/usage-observability.md +6 -0
  17. package/docs-site/architecture.html +4 -3
  18. package/docs-site/commands.html +2 -1
  19. package/docs-site/content/context-engine.html +47 -0
  20. package/docs-site/context-engine.html +171 -0
  21. package/docs-site/index.html +2 -1
  22. package/docs-site/mcp-approval.html +2 -1
  23. package/docs-site/mcp-auth.html +2 -1
  24. package/docs-site/mcp-servers.html +2 -1
  25. package/docs-site/mcp.html +2 -1
  26. package/docs-site/permissions.html +2 -1
  27. package/docs-site/profiles.html +2 -1
  28. package/docs-site/quickstart.html +2 -1
  29. package/docs-site/runtime.html +4 -3
  30. package/docs-site/scope.html +2 -1
  31. package/docs-site/security.html +2 -1
  32. package/docs-site/team.html +2 -1
  33. package/docs-site/workflows.html +2 -1
  34. package/package.json +3 -2
  35. package/packages/piagent-core/README.md +8 -2
  36. package/packages/piagent-core/capabilities/capability-core.js +4 -0
  37. package/packages/piagent-core/extensions/context-engine.js +1175 -0
  38. package/packages/piagent-core/extensions/piagent-guard.ts +785 -17
  39. package/packages/piagent-core/package.json +1 -1
  40. package/packages/piagent-core/policies/base-policy.json +2 -0
  41. package/packages/piagent-core/prompts/scout.md +10 -24
  42. package/packages/piagent-core/prompts/task.md +25 -45
  43. package/scripts/build-docs-site.mjs +6 -0
  44. package/scripts/context-engine.mjs +115 -0
  45. package/scripts/install-global.sh +14 -1
  46. package/scripts/piagent-cli.mjs +2 -1
  47. package/scripts/quality-benchmark.sh +71 -1
  48. package/scripts/verify-local.sh +8 -0
@@ -0,0 +1,1175 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ const INDEX_SCHEMA_VERSION = 2;
7
+ const TELEMETRY_SCHEMA_VERSION = 1;
8
+ const RRF_K = 60;
9
+ const DEFAULT_MAX_FILE_BYTES = 512 * 1024;
10
+ const DEFAULT_MAX_FILES = 8_000;
11
+ const DEFAULT_PACK_TOKENS = 6_000;
12
+ const MAX_TELEMETRY_EVENTS = 50_000;
13
+ const MAX_TELEMETRY_READ_BYTES = 32 * 1024 * 1024;
14
+ const SOURCE_EXTENSIONS = new Set([
15
+ ".c", ".cc", ".cpp", ".cs", ".css", ".dart", ".ex", ".exs", ".go", ".h", ".hpp",
16
+ ".html", ".java", ".js", ".jsx", ".kt", ".kts", ".lua", ".m", ".md", ".mdx",
17
+ ".php", ".proto", ".py", ".rb", ".rs", ".scala", ".scss", ".sh", ".sql", ".svelte",
18
+ ".swift", ".toml", ".ts", ".tsx", ".vue", ".xml", ".yaml", ".yml"
19
+ ]);
20
+ const SOURCE_NAMES = new Set([
21
+ "AGENTS.md", "Dockerfile", "Makefile", "Procfile", "README", "README.md", "go.mod",
22
+ "package.json", "pyproject.toml", "requirements.txt", "tsconfig.json"
23
+ ]);
24
+ const IGNORED_SEGMENTS = new Set([
25
+ ".git", ".next", ".nuxt", ".output", ".turbo", ".venv", "build", "coverage", "dist",
26
+ "node_modules", "out", "target", "vendor"
27
+ ]);
28
+ const SECRET_FILE_PATTERNS = [
29
+ /(^|\/)\.env(?:\.|$)/i,
30
+ /(^|\/)auth\.json$/i,
31
+ /(^|\/)(?:credentials?|secrets?|tokens?)\.json$/i,
32
+ /(^|\/)\.pi\/settings\.json$/i,
33
+ /(^|\/)\.pi\/piagent-profile(?:\.lock)?\.json$/i,
34
+ /(^|\/)\.pi\/piagent-state(?:\/|$)/i
35
+ ];
36
+ const CONTROL_WORDS = new Set([
37
+ "catch", "else", "for", "if", "return", "switch", "while", "with"
38
+ ]);
39
+ const STOP_TERMS = new Set([
40
+ "about", "after", "agent", "before", "build", "change", "check", "code", "could", "file",
41
+ "from", "have", "into", "just", "make", "need", "please", "project", "should", "task",
42
+ "that", "them", "then", "there", "these", "this", "through", "update", "using", "want",
43
+ "what", "when", "where", "which", "with", "would"
44
+ ]);
45
+
46
+ let sqliteModulePromise;
47
+ const retrievalFeedbackCache = new Map();
48
+
49
+ function nowIso() {
50
+ return new Date().toISOString();
51
+ }
52
+
53
+ function sha256(value) {
54
+ return crypto.createHash("sha256").update(value).digest("hex");
55
+ }
56
+
57
+ function normalizeRelative(value) {
58
+ return String(value ?? "").replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/\/+/g, "/");
59
+ }
60
+
61
+ function clampInteger(value, fallback, min, max) {
62
+ const number = Number(value);
63
+ if (!Number.isFinite(number)) return fallback;
64
+ return Math.max(min, Math.min(max, Math.trunc(number)));
65
+ }
66
+
67
+ export function estimateContextTokens(value) {
68
+ return Math.max(0, Math.ceil(String(value ?? "").length / 4));
69
+ }
70
+
71
+ export function contextEnginePaths(cwd) {
72
+ const root = path.join(cwd, ".pi", "piagent-state", "context-engine");
73
+ return {
74
+ root,
75
+ database: path.join(root, "context-v2.sqlite"),
76
+ telemetry: path.join(root, "events.jsonl"),
77
+ report: path.join(root, "efficiency-report.json")
78
+ };
79
+ }
80
+
81
+ async function loadSqlite() {
82
+ if (!sqliteModulePromise) {
83
+ sqliteModulePromise = (async () => {
84
+ // Node 22 labels node:sqlite experimental even though its API is stable
85
+ // enough for this local cache. Avoid leaking that one import notice into
86
+ // the Pi TUI while preserving every other process warning.
87
+ const emitWarning = process.emitWarning;
88
+ process.emitWarning = function filteredWarning(warning, ...args) {
89
+ const message = warning instanceof Error ? warning.message : String(warning);
90
+ if (message.includes("SQLite is an experimental feature")) return;
91
+ return emitWarning.call(process, warning, ...args);
92
+ };
93
+ try {
94
+ return await import("node:sqlite");
95
+ } finally {
96
+ process.emitWarning = emitWarning;
97
+ }
98
+ })();
99
+ }
100
+ return sqliteModulePromise;
101
+ }
102
+
103
+ async function openDatabase(cwd, { create = true } = {}) {
104
+ const paths = contextEnginePaths(cwd);
105
+ if (!create && !fs.existsSync(paths.database)) return undefined;
106
+ fs.mkdirSync(paths.root, { recursive: true });
107
+ const { DatabaseSync } = await loadSqlite();
108
+ const db = new DatabaseSync(paths.database);
109
+ db.exec(`
110
+ PRAGMA journal_mode = WAL;
111
+ PRAGMA synchronous = NORMAL;
112
+ PRAGMA foreign_keys = ON;
113
+ CREATE TABLE IF NOT EXISTS metadata (
114
+ key TEXT PRIMARY KEY,
115
+ value TEXT NOT NULL
116
+ );
117
+ CREATE TABLE IF NOT EXISTS files (
118
+ path TEXT PRIMARY KEY,
119
+ hash TEXT NOT NULL,
120
+ bytes INTEGER NOT NULL,
121
+ mtime_ms REAL NOT NULL,
122
+ language TEXT NOT NULL,
123
+ indexed_at TEXT NOT NULL
124
+ );
125
+ CREATE TABLE IF NOT EXISTS symbols (
126
+ id INTEGER PRIMARY KEY,
127
+ file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
128
+ name TEXT NOT NULL,
129
+ kind TEXT NOT NULL,
130
+ line INTEGER NOT NULL,
131
+ end_line INTEGER NOT NULL,
132
+ signature TEXT NOT NULL
133
+ );
134
+ CREATE INDEX IF NOT EXISTS symbols_name_idx ON symbols(name);
135
+ CREATE INDEX IF NOT EXISTS symbols_file_idx ON symbols(file_path);
136
+ CREATE TABLE IF NOT EXISTS imports (
137
+ id INTEGER PRIMARY KEY,
138
+ file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
139
+ specifier TEXT NOT NULL,
140
+ target_path TEXT,
141
+ line INTEGER NOT NULL
142
+ );
143
+ CREATE INDEX IF NOT EXISTS imports_file_idx ON imports(file_path);
144
+ CREATE INDEX IF NOT EXISTS imports_target_idx ON imports(target_path);
145
+ CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts5(
146
+ path UNINDEXED,
147
+ body,
148
+ symbol_names,
149
+ tokenize = 'unicode61 remove_diacritics 2'
150
+ );
151
+ `);
152
+ return db;
153
+ }
154
+
155
+ function metadataMap(db) {
156
+ const result = {};
157
+ for (const row of db.prepare("SELECT key, value FROM metadata").all()) {
158
+ result[row.key] = row.value;
159
+ }
160
+ return result;
161
+ }
162
+
163
+ function setMetadata(db, values) {
164
+ const statement = db.prepare(`
165
+ INSERT INTO metadata(key, value) VALUES (?, ?)
166
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
167
+ `);
168
+ for (const [key, value] of Object.entries(values)) statement.run(key, String(value));
169
+ }
170
+
171
+ function isTextBuffer(buffer) {
172
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8_192));
173
+ let suspicious = 0;
174
+ for (const byte of sample) {
175
+ if (byte === 0) return false;
176
+ if (byte < 7 || (byte > 13 && byte < 32)) suspicious += 1;
177
+ }
178
+ return sample.length === 0 || suspicious / sample.length < 0.02;
179
+ }
180
+
181
+ function shouldIndexPath(relativePath, options) {
182
+ const rel = normalizeRelative(relativePath);
183
+ if (!rel || path.isAbsolute(rel) || rel.startsWith("../")) return false;
184
+ const segments = rel.split("/");
185
+ if (segments.some((segment) => IGNORED_SEGMENTS.has(segment))) return false;
186
+ if (SECRET_FILE_PATTERNS.some((pattern) => pattern.test(rel))) return false;
187
+ if ((options.excludePatterns ?? []).some((pattern) => simpleGlobMatch(rel, pattern))) return false;
188
+ const name = path.posix.basename(rel);
189
+ return SOURCE_NAMES.has(name) || SOURCE_EXTENSIONS.has(path.posix.extname(name).toLowerCase());
190
+ }
191
+
192
+ function simpleGlobMatch(relativePath, pattern) {
193
+ const rel = normalizeRelative(relativePath);
194
+ const normalized = normalizeRelative(pattern);
195
+ let source = "";
196
+ for (let index = 0; index < normalized.length; index += 1) {
197
+ const char = normalized[index];
198
+ if (char === "*" && normalized[index + 1] === "*") {
199
+ if (normalized[index + 2] === "/") {
200
+ source += "(?:.*/)?";
201
+ index += 2;
202
+ } else {
203
+ source += ".*";
204
+ index += 1;
205
+ }
206
+ continue;
207
+ }
208
+ if (char === "*") {
209
+ source += "[^/]*";
210
+ continue;
211
+ }
212
+ if (char === "?") {
213
+ source += "[^/]";
214
+ continue;
215
+ }
216
+ source += /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
217
+ }
218
+ try {
219
+ return new RegExp(`^${source}$`, "i").test(rel);
220
+ } catch {
221
+ return rel === normalized;
222
+ }
223
+ }
224
+
225
+ function gitFileList(cwd) {
226
+ try {
227
+ const output = execFileSync("git", ["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
228
+ encoding: "utf8",
229
+ maxBuffer: 32 * 1024 * 1024,
230
+ stdio: ["ignore", "pipe", "ignore"]
231
+ });
232
+ return output.split("\0").filter(Boolean).map(normalizeRelative);
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ }
237
+
238
+ function walkedFileList(cwd, maxFiles) {
239
+ const files = [];
240
+ const pending = [cwd];
241
+ while (pending.length > 0 && files.length < maxFiles) {
242
+ const directory = pending.pop();
243
+ let entries = [];
244
+ try {
245
+ entries = fs.readdirSync(directory, { withFileTypes: true });
246
+ } catch {
247
+ continue;
248
+ }
249
+ entries.sort((left, right) => left.name.localeCompare(right.name));
250
+ for (const entry of entries) {
251
+ if (entry.isSymbolicLink()) continue;
252
+ const absolute = path.join(directory, entry.name);
253
+ const relative = normalizeRelative(path.relative(cwd, absolute));
254
+ if (entry.isDirectory()) {
255
+ if (!IGNORED_SEGMENTS.has(entry.name) && !relative.startsWith(".pi/piagent-state")) pending.push(absolute);
256
+ } else if (entry.isFile()) {
257
+ files.push(relative);
258
+ if (files.length >= maxFiles) break;
259
+ }
260
+ }
261
+ }
262
+ return files;
263
+ }
264
+
265
+ function languageForPath(filePath) {
266
+ const extension = path.posix.extname(filePath).toLowerCase();
267
+ const names = {
268
+ ".c": "c", ".cc": "cpp", ".cpp": "cpp", ".cs": "csharp", ".dart": "dart",
269
+ ".ex": "elixir", ".exs": "elixir", ".go": "go", ".h": "c", ".hpp": "cpp",
270
+ ".java": "java", ".js": "javascript", ".jsx": "javascript", ".kt": "kotlin",
271
+ ".kts": "kotlin", ".lua": "lua", ".m": "objective-c", ".php": "php", ".py": "python",
272
+ ".rb": "ruby", ".rs": "rust", ".scala": "scala", ".sh": "shell", ".sql": "sql",
273
+ ".svelte": "svelte", ".swift": "swift", ".ts": "typescript", ".tsx": "typescript",
274
+ ".vue": "vue"
275
+ };
276
+ return names[extension] ?? ([".md", ".mdx"].includes(extension) ? "markdown" : "text");
277
+ }
278
+
279
+ function symbolMatchers(language) {
280
+ const common = [
281
+ { kind: 1, regex: /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(class|interface|type|enum|function|namespace)\s+([A-Za-z_$][\w$]*)/ },
282
+ { kind: 1, regex: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/ }
283
+ ];
284
+ if (["typescript", "javascript", "svelte", "vue"].includes(language)) return common;
285
+ if (language === "python") return [
286
+ { kind: 1, regex: /^\s*(class|def|async\s+def)\s+([A-Za-z_]\w*)/ }
287
+ ];
288
+ if (language === "go") return [
289
+ { kind: 1, regex: /^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+)([A-Za-z_]\w*)/ }
290
+ ];
291
+ if (language === "rust") return [
292
+ { kind: 1, regex: /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(fn|struct|enum|trait|type|mod)\s+([A-Za-z_]\w*)/ }
293
+ ];
294
+ if (["java", "kotlin", "csharp", "cpp", "c", "swift", "dart", "scala"].includes(language)) return [
295
+ { kind: 1, regex: /^\s*(?:(?:public|private|protected|internal|static|final|abstract|sealed|open|data|async|virtual|override)\s+)*(class|interface|enum|struct|record|trait|protocol|func|fun)\s+([A-Za-z_]\w*)/ },
296
+ { kind: 2, regex: /^\s*(?:(?:public|private|protected|internal|static|final|async|virtual|override|inline|extern)\s+)*(?:[A-Za-z_][\w:<>,?[\].]*\s+)+([A-Za-z_]\w*)\s*\([^;]*\)\s*(?:\{|=>|$)/ }
297
+ ];
298
+ if (language === "ruby") return [
299
+ { kind: 1, regex: /^\s*(class|module|def)\s+(?:self\.)?([A-Za-z_]\w*[!?=]?)/ }
300
+ ];
301
+ if (language === "php") return [
302
+ { kind: 1, regex: /^\s*(?:(?:public|private|protected|abstract|final|static)\s+)*(class|interface|trait|enum|function)\s+([A-Za-z_]\w*)/ }
303
+ ];
304
+ if (language === "elixir") return [
305
+ { kind: 1, regex: /^\s*(defmodule|defprotocol|def|defp|defmacro)\s+([A-Za-z_][\w.!?]*)/ }
306
+ ];
307
+ if (language === "lua") return [
308
+ { kind: 1, regex: /^\s*(?:local\s+)?function\s+([A-Za-z_][\w.:]*)/ }
309
+ ];
310
+ if (language === "sql") return [
311
+ { kind: 1, regex: /^\s*create\s+(?:or\s+replace\s+)?(table|view|function|procedure|trigger)\s+(?:if\s+not\s+exists\s+)?([A-Za-z_][\w.]*)/i }
312
+ ];
313
+ if (language === "markdown") return [
314
+ { kind: 1, regex: /^(#{1,4})\s+(.{1,120})$/ }
315
+ ];
316
+ return [];
317
+ }
318
+
319
+ function kindAndName(match, matcher, language) {
320
+ if (language === "markdown") return { kind: `heading-${match[1].length}`, name: match[2].trim() };
321
+ if (matcher.kind === 2) return { kind: "function", name: match[1] };
322
+ if (match.length >= 3) return { kind: String(match[1]).replace(/\s+/g, "-"), name: match[2] };
323
+ return { kind: "symbol", name: match[1] };
324
+ }
325
+
326
+ function extractSymbols(text, language) {
327
+ const lines = text.split(/\r?\n/);
328
+ const matchers = symbolMatchers(language);
329
+ const symbols = [];
330
+ for (let index = 0; index < lines.length && symbols.length < 400; index += 1) {
331
+ const line = lines[index];
332
+ for (const matcher of matchers) {
333
+ const match = line.match(matcher.regex);
334
+ if (!match) continue;
335
+ const parsed = kindAndName(match, matcher, language);
336
+ if (!parsed.name || CONTROL_WORDS.has(parsed.name)) continue;
337
+ symbols.push({
338
+ name: parsed.name.slice(0, 160),
339
+ kind: parsed.kind.slice(0, 40),
340
+ line: index + 1,
341
+ endLine: index + 1,
342
+ signature: line.trim().slice(0, 300)
343
+ });
344
+ break;
345
+ }
346
+ }
347
+ for (let index = 0; index < symbols.length; index += 1) {
348
+ symbols[index].endLine = Math.max(symbols[index].line, (symbols[index + 1]?.line ?? lines.length + 1) - 1);
349
+ }
350
+ return symbols;
351
+ }
352
+
353
+ function extractImports(text, language) {
354
+ const lines = text.split(/\r?\n/);
355
+ const imports = [];
356
+ const patterns = [];
357
+ if (["typescript", "javascript", "svelte", "vue"].includes(language)) {
358
+ patterns.push(/\bfrom\s+["']([^"']+)["']/, /\brequire\(\s*["']([^"']+)["']\s*\)/, /\bimport\(\s*["']([^"']+)["']\s*\)/);
359
+ } else if (language === "python") {
360
+ patterns.push(/^\s*from\s+([A-Za-z_][\w.]*)\s+import\b/, /^\s*import\s+([A-Za-z_][\w.]*)/);
361
+ } else if (["java", "kotlin", "scala"].includes(language)) {
362
+ patterns.push(/^\s*import\s+([A-Za-z_][\w.]*)/);
363
+ } else if (language === "go") {
364
+ patterns.push(/^\s*(?:import\s+)?["']([^"']+)["']/);
365
+ } else if (language === "rust") {
366
+ patterns.push(/^\s*use\s+([A-Za-z_][\w:]*)/);
367
+ } else if (language === "csharp") {
368
+ patterns.push(/^\s*using\s+([A-Za-z_][\w.]*)/);
369
+ } else if (language === "ruby") {
370
+ patterns.push(/^\s*require(?:_relative)?\s+["']([^"']+)["']/);
371
+ }
372
+ for (let index = 0; index < lines.length && imports.length < 300; index += 1) {
373
+ for (const pattern of patterns) {
374
+ const match = lines[index].match(pattern);
375
+ if (!match) continue;
376
+ imports.push({ specifier: match[1].slice(0, 300), line: index + 1 });
377
+ break;
378
+ }
379
+ }
380
+ return imports;
381
+ }
382
+
383
+ function resolveImportTarget(sourcePath, specifier, files) {
384
+ const sourceDir = path.posix.dirname(sourcePath);
385
+ const extension = path.posix.extname(sourcePath);
386
+ const candidates = [];
387
+ if (specifier.startsWith(".")) {
388
+ const base = path.posix.normalize(path.posix.join(sourceDir, specifier));
389
+ const specifierExtension = path.posix.extname(base);
390
+ const bases = [base];
391
+ if ([".js", ".mjs", ".cjs"].includes(specifierExtension)) bases.push(base.slice(0, -specifierExtension.length));
392
+ for (const candidateBase of bases) {
393
+ candidates.push(candidateBase, `${candidateBase}${extension}`);
394
+ for (const suffix of [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java", ".kt", ".swift"]) {
395
+ candidates.push(`${candidateBase}${suffix}`, path.posix.join(candidateBase, `index${suffix}`));
396
+ }
397
+ }
398
+ } else {
399
+ const modulePath = specifier.replaceAll(".", "/").replaceAll("::", "/");
400
+ candidates.push(modulePath, `${modulePath}${extension}`);
401
+ for (const file of files) {
402
+ if (file.endsWith(`/${modulePath}${path.posix.extname(file)}`) || file.endsWith(`/${modulePath}/index${path.posix.extname(file)}`)) {
403
+ candidates.push(file);
404
+ }
405
+ }
406
+ }
407
+ return candidates.map(normalizeRelative).find((candidate) => files.has(candidate));
408
+ }
409
+
410
+ function tokenizeQuery(query) {
411
+ const expanded = String(query ?? "")
412
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
413
+ .replaceAll("_", " ")
414
+ .replaceAll("-", " ");
415
+ const terms = expanded.match(/[A-Za-z][A-Za-z0-9]{1,63}/g) ?? [];
416
+ return [...new Set(terms.map((term) => term.toLowerCase()).filter((term) => !STOP_TERMS.has(term)))].slice(0, 16);
417
+ }
418
+
419
+ function queryPathCandidates(query) {
420
+ const values = String(query ?? "").match(/(?:^|[\s"'`(])((?:\.{0,2}\/)?[A-Za-z0-9_.@-]+(?:\/[A-Za-z0-9_.@-]+)+|[A-Za-z0-9_.@-]+\.[A-Za-z0-9]{1,8})(?=$|[\s"'`),:])/g) ?? [];
421
+ return values.map((value) => normalizeRelative(value.trim().replace(/^["'`(]+|["'`),:]+$/g, ""))).filter(Boolean);
422
+ }
423
+
424
+ function ftsQuery(terms) {
425
+ return terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
426
+ }
427
+
428
+ function gitChangedFiles(cwd) {
429
+ try {
430
+ const output = execFileSync("git", ["-C", cwd, "diff", "--name-only", "--diff-filter=ACMR", "HEAD"], {
431
+ encoding: "utf8",
432
+ stdio: ["ignore", "pipe", "ignore"]
433
+ });
434
+ return output.split(/\r?\n/).map(normalizeRelative).filter(Boolean);
435
+ } catch {
436
+ return [];
437
+ }
438
+ }
439
+
440
+ export function classifyContextTask(prompt) {
441
+ const text = String(prompt ?? "").trim();
442
+ const lower = text.toLowerCase();
443
+ const paths = queryPathCandidates(text);
444
+ const terms = tokenizeQuery(text);
445
+ let workflow = "task";
446
+ if (/^\/?(?:scout|review|plan|discuss)\b/.test(lower)) workflow = lower.match(/^\/?([a-z-]+)/)?.[1] ?? "task";
447
+ if (/\b(onboard|profile setup|first-read)\b/.test(lower)) workflow = "onboard";
448
+ if (/\b(commit|pull request|\bpr\b|release|publish|deploy)\b/.test(lower)) workflow = "release";
449
+ const usageIntent = /^\/?(?:usage|session)\b/.test(lower)
450
+ || /\b(?:show|check|view|report|current|session|how many)\b.{0,48}\b(?:usage|tokens?|cost|context stats|efficiency)\b/.test(lower)
451
+ || /\b(?:usage|tokens?|cost|context stats|efficiency)\b.{0,48}\b(?:show|check|view|report|current|session)\b/.test(lower);
452
+ if (usageIntent) workflow = "usage";
453
+ const highRisk = /\b(auth|authorization|credential|database|deploy|encryption|migration|payment|permission|production|release|secret|security|token)\b/.test(lower);
454
+ const tiny = text.length < 220 && (paths.length > 0 || /\b(rename|typo|label|copy|one line|small)\b/.test(lower));
455
+ return {
456
+ workflow,
457
+ lane: highRisk ? "high-risk" : tiny ? "tiny" : "normal",
458
+ terms,
459
+ paths,
460
+ promptHash: sha256(text),
461
+ promptChars: text.length
462
+ };
463
+ }
464
+
465
+ export async function buildContextIndexV2(cwd, options = {}) {
466
+ const startedAt = Date.now();
467
+ const maxFiles = clampInteger(options.maxFiles, DEFAULT_MAX_FILES, 1, 50_000);
468
+ const maxFileBytes = clampInteger(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 1_024, 2 * 1024 * 1024);
469
+ const listed = gitFileList(cwd) ?? walkedFileList(cwd, maxFiles);
470
+ const candidates = listed.filter((file) => shouldIndexPath(file, options)).slice(0, maxFiles).sort();
471
+ const db = await openDatabase(cwd);
472
+ const existing = new Map(db.prepare("SELECT path, hash, bytes, mtime_ms FROM files").all().map((row) => [row.path, row]));
473
+ const current = new Set();
474
+ const currentHashes = new Map();
475
+ const changed = [];
476
+ const restatted = [];
477
+ let reused = 0;
478
+ let skippedLarge = 0;
479
+ let skippedBinary = 0;
480
+ const skippedPaths = [];
481
+
482
+ for (const relativePath of candidates) {
483
+ const absolute = path.resolve(cwd, relativePath);
484
+ if (!absolute.startsWith(`${path.resolve(cwd)}${path.sep}`) && absolute !== path.resolve(cwd)) continue;
485
+ let stat;
486
+ try {
487
+ stat = fs.statSync(absolute);
488
+ if (!stat.isFile()) continue;
489
+ if (stat.size > maxFileBytes) {
490
+ skippedLarge += 1;
491
+ skippedPaths.push(relativePath);
492
+ continue;
493
+ }
494
+ } catch {
495
+ continue;
496
+ }
497
+ const previous = existing.get(relativePath);
498
+ if (
499
+ previous
500
+ && Number(previous.bytes) === stat.size
501
+ && Math.abs(Number(previous.mtime_ms) - stat.mtimeMs) < 0.5
502
+ ) {
503
+ current.add(relativePath);
504
+ currentHashes.set(relativePath, previous.hash);
505
+ reused += 1;
506
+ continue;
507
+ }
508
+ let buffer;
509
+ try {
510
+ buffer = fs.readFileSync(absolute);
511
+ } catch {
512
+ continue;
513
+ }
514
+ if (!isTextBuffer(buffer)) {
515
+ skippedBinary += 1;
516
+ skippedPaths.push(relativePath);
517
+ continue;
518
+ }
519
+ const contentHash = sha256(buffer);
520
+ current.add(relativePath);
521
+ currentHashes.set(relativePath, contentHash);
522
+ if (previous?.hash !== contentHash) {
523
+ changed.push({ relativePath, contentHash, stat, text: buffer.toString("utf8") });
524
+ } else if (previous) {
525
+ restatted.push({ relativePath, stat });
526
+ }
527
+ }
528
+
529
+ const removed = [...existing.keys()].filter((file) => !current.has(file));
530
+ db.exec("BEGIN IMMEDIATE");
531
+ try {
532
+ const deleteFile = db.prepare("DELETE FROM files WHERE path = ?");
533
+ const deleteFts = db.prepare("DELETE FROM file_fts WHERE path = ?");
534
+ for (const relativePath of [...removed, ...changed.map((item) => item.relativePath)]) {
535
+ deleteFts.run(relativePath);
536
+ deleteFile.run(relativePath);
537
+ }
538
+ const updateStat = db.prepare("UPDATE files SET bytes = ?, mtime_ms = ? WHERE path = ?");
539
+ for (const item of restatted) updateStat.run(item.stat.size, item.stat.mtimeMs, item.relativePath);
540
+
541
+ const insertFile = db.prepare("INSERT INTO files(path, hash, bytes, mtime_ms, language, indexed_at) VALUES (?, ?, ?, ?, ?, ?)");
542
+ const insertFts = db.prepare("INSERT INTO file_fts(path, body, symbol_names) VALUES (?, ?, ?)");
543
+ const insertSymbol = db.prepare("INSERT INTO symbols(file_path, name, kind, line, end_line, signature) VALUES (?, ?, ?, ?, ?, ?)");
544
+ const insertImport = db.prepare("INSERT INTO imports(file_path, specifier, target_path, line) VALUES (?, ?, NULL, ?)");
545
+ const indexedAt = nowIso();
546
+ for (const item of changed) {
547
+ const language = languageForPath(item.relativePath);
548
+ const symbols = extractSymbols(item.text, language);
549
+ const imports = extractImports(item.text, language);
550
+ insertFile.run(item.relativePath, item.contentHash, item.stat.size, item.stat.mtimeMs, language, indexedAt);
551
+ insertFts.run(item.relativePath, item.text, symbols.map((symbol) => symbol.name).join(" "));
552
+ for (const symbol of symbols) {
553
+ insertSymbol.run(item.relativePath, symbol.name, symbol.kind, symbol.line, symbol.endLine, symbol.signature);
554
+ }
555
+ for (const imported of imports) insertImport.run(item.relativePath, imported.specifier, imported.line);
556
+ }
557
+
558
+ const allFiles = new Set(db.prepare("SELECT path FROM files").all().map((row) => row.path));
559
+ const unresolved = db.prepare("SELECT id, file_path, specifier FROM imports").all();
560
+ const updateImport = db.prepare("UPDATE imports SET target_path = ? WHERE id = ?");
561
+ for (const imported of unresolved) {
562
+ updateImport.run(resolveImportTarget(imported.file_path, imported.specifier, allFiles) ?? null, imported.id);
563
+ }
564
+
565
+ const counts = {
566
+ files: Number(db.prepare("SELECT COUNT(*) count FROM files").get().count),
567
+ symbols: Number(db.prepare("SELECT COUNT(*) count FROM symbols").get().count),
568
+ imports: Number(db.prepare("SELECT COUNT(*) count FROM imports").get().count)
569
+ };
570
+ setMetadata(db, {
571
+ schemaVersion: INDEX_SCHEMA_VERSION,
572
+ rootHash: sha256([...current].sort().map((file) => `${file}:${currentHashes.get(file) ?? ""}`).join("\n")),
573
+ builtAt: indexedAt,
574
+ fileCount: counts.files,
575
+ symbolCount: counts.symbols,
576
+ importCount: counts.imports,
577
+ maxFileBytes,
578
+ excludePatterns: JSON.stringify(options.excludePatterns ?? []),
579
+ skippedPaths: JSON.stringify(skippedPaths)
580
+ });
581
+ db.exec("COMMIT");
582
+ db.close();
583
+ return {
584
+ schemaVersion: INDEX_SCHEMA_VERSION,
585
+ builtAt: indexedAt,
586
+ durationMs: Date.now() - startedAt,
587
+ scanned: candidates.length,
588
+ changed: changed.length,
589
+ reused,
590
+ removed: removed.length,
591
+ skippedLarge,
592
+ skippedBinary,
593
+ ...counts
594
+ };
595
+ } catch (error) {
596
+ db.exec("ROLLBACK");
597
+ db.close();
598
+ throw error;
599
+ }
600
+ }
601
+
602
+ export async function contextIndexV2Status(cwd) {
603
+ const paths = contextEnginePaths(cwd);
604
+ if (!fs.existsSync(paths.database)) {
605
+ return { schemaVersion: INDEX_SCHEMA_VERSION, exists: false, path: normalizeRelative(path.relative(cwd, paths.database)), warnings: ["index-v2 missing"] };
606
+ }
607
+ const db = await openDatabase(cwd, { create: false });
608
+ try {
609
+ const metadata = metadataMap(db);
610
+ const fileRows = db.prepare("SELECT path, bytes, mtime_ms FROM files").all();
611
+ const files = fileRows.length;
612
+ const symbols = Number(db.prepare("SELECT COUNT(*) count FROM symbols").get().count);
613
+ const imports = Number(db.prepare("SELECT COUNT(*) count FROM imports").get().count);
614
+ const indexedPaths = new Set(fileRows.map((row) => row.path));
615
+ const statChanged = [];
616
+ for (const row of fileRows) {
617
+ try {
618
+ const stat = fs.statSync(path.resolve(cwd, row.path));
619
+ if (!stat.isFile() || stat.size !== Number(row.bytes) || Math.abs(stat.mtimeMs - Number(row.mtime_ms)) >= 0.5) {
620
+ statChanged.push(row.path);
621
+ }
622
+ } catch {
623
+ statChanged.push(row.path);
624
+ }
625
+ }
626
+ let excludePatterns = [];
627
+ try {
628
+ const parsed = JSON.parse(metadata.excludePatterns ?? "[]");
629
+ if (Array.isArray(parsed)) excludePatterns = parsed.filter((value) => typeof value === "string");
630
+ } catch {
631
+ excludePatterns = [];
632
+ }
633
+ let skippedPaths = new Set();
634
+ try {
635
+ const parsed = JSON.parse(metadata.skippedPaths ?? "[]");
636
+ if (Array.isArray(parsed)) skippedPaths = new Set(parsed.filter((value) => typeof value === "string"));
637
+ } catch {
638
+ skippedPaths = new Set();
639
+ }
640
+ const listed = gitFileList(cwd);
641
+ const newlyVisible = listed
642
+ ? listed.filter((file) => !indexedPaths.has(file) && !skippedPaths.has(file) && shouldIndexPath(file, { excludePatterns }))
643
+ : [];
644
+ const stalePaths = [...new Set([...statChanged, ...newlyVisible])];
645
+ return {
646
+ schemaVersion: Number(metadata.schemaVersion ?? INDEX_SCHEMA_VERSION),
647
+ exists: true,
648
+ path: normalizeRelative(path.relative(cwd, paths.database)),
649
+ builtAt: metadata.builtAt,
650
+ files,
651
+ symbols,
652
+ imports,
653
+ stale: stalePaths.length > 0,
654
+ stalePaths: stalePaths.slice(0, 20),
655
+ warnings: stalePaths.length > 0 ? [`${stalePaths.length} changed file(s) since the last index build`] : []
656
+ };
657
+ } finally {
658
+ db.close();
659
+ }
660
+ }
661
+
662
+ function addRankedList(scores, rows, source, weight = 1) {
663
+ rows.forEach((row, index) => {
664
+ const filePath = row.path ?? row.file_path;
665
+ if (!filePath) return;
666
+ const current = scores.get(filePath) ?? { path: filePath, score: 0, sources: new Set(), rows: [] };
667
+ current.score += weight / (RRF_K + index + 1);
668
+ current.sources.add(source);
669
+ current.rows.push({ source, ...row });
670
+ scores.set(filePath, current);
671
+ });
672
+ }
673
+
674
+ function personalizedPageRank(importRows, seeds, iterations = 8) {
675
+ const nodes = new Set();
676
+ const outgoing = new Map();
677
+ for (const row of importRows) {
678
+ if (!row.target_path) continue;
679
+ nodes.add(row.file_path);
680
+ nodes.add(row.target_path);
681
+ const targets = outgoing.get(row.file_path) ?? new Set();
682
+ targets.add(row.target_path);
683
+ outgoing.set(row.file_path, targets);
684
+ }
685
+ for (const seed of seeds.keys()) nodes.add(seed);
686
+ if (nodes.size === 0) return [];
687
+ const seedTotal = [...seeds.values()].reduce((sum, value) => sum + Math.max(value, 0), 0) || 1;
688
+ const teleport = new Map([...nodes].map((node) => [node, (seeds.get(node) ?? 0) / seedTotal]));
689
+ let rank = new Map(teleport);
690
+ const alpha = 0.85;
691
+ for (let iteration = 0; iteration < iterations; iteration += 1) {
692
+ const next = new Map([...nodes].map((node) => [node, (1 - alpha) * (teleport.get(node) ?? 0)]));
693
+ let dangling = 0;
694
+ for (const node of nodes) {
695
+ const targets = outgoing.get(node);
696
+ if (!targets?.size) {
697
+ dangling += rank.get(node) ?? 0;
698
+ continue;
699
+ }
700
+ const share = alpha * (rank.get(node) ?? 0) / targets.size;
701
+ for (const target of targets) next.set(target, (next.get(target) ?? 0) + share);
702
+ }
703
+ for (const node of nodes) {
704
+ next.set(node, (next.get(node) ?? 0) + alpha * dangling * (teleport.get(node) ?? 1 / nodes.size));
705
+ }
706
+ rank = next;
707
+ }
708
+ return [...rank.entries()].sort((left, right) => right[1] - left[1]).map(([filePath, score]) => ({ path: filePath, graphScore: score }));
709
+ }
710
+
711
+ function retrievalFeedback(cwd) {
712
+ const target = contextEnginePaths(cwd).telemetry;
713
+ let signature = "missing";
714
+ try {
715
+ const stat = fs.statSync(target);
716
+ signature = `${stat.size}:${stat.mtimeMs}`;
717
+ } catch {
718
+ return {
719
+ rows: [],
720
+ selected: 0,
721
+ used: 0,
722
+ unused: 0,
723
+ utilizationRate: 0
724
+ };
725
+ }
726
+ const cached = retrievalFeedbackCache.get(cwd);
727
+ if (cached?.signature === signature) return cached.value;
728
+
729
+ const events = readContextTelemetry(cwd, { limit: 12_000, maxBytes: 12 * 1024 * 1024 });
730
+ const selectedCounts = new Map();
731
+ const usedCounts = new Map();
732
+ const latestPackBySession = new Map();
733
+ let selected = 0;
734
+ let used = 0;
735
+
736
+ for (const event of events) {
737
+ const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
738
+ if (event.event === "context_pack" && sessionId) {
739
+ const selectedPaths = [...new Set(
740
+ (Array.isArray(event.selectedPaths) ? event.selectedPaths : [])
741
+ .map(normalizeRelative)
742
+ .filter((filePath) => shouldIndexPath(filePath, {}))
743
+ )];
744
+ const pack = { selectedPaths: new Set(selectedPaths), usedPaths: new Set() };
745
+ latestPackBySession.set(sessionId, pack);
746
+ for (const filePath of selectedPaths) {
747
+ selectedCounts.set(filePath, (selectedCounts.get(filePath) ?? 0) + 1);
748
+ selected += 1;
749
+ }
750
+ continue;
751
+ }
752
+ if (event.event !== "tool_call" || !sessionId) continue;
753
+ const targetPath = normalizeRelative(event.targetPath);
754
+ const pack = latestPackBySession.get(sessionId);
755
+ if (!targetPath || !pack?.selectedPaths.has(targetPath) || pack.usedPaths.has(targetPath)) continue;
756
+ pack.usedPaths.add(targetPath);
757
+ usedCounts.set(targetPath, (usedCounts.get(targetPath) ?? 0) + 1);
758
+ used += 1;
759
+ }
760
+
761
+ const rows = [...usedCounts.entries()]
762
+ .map(([filePath, usedCount]) => {
763
+ const selectedCount = selectedCounts.get(filePath) ?? usedCount;
764
+ const utilization = ratio(usedCount, selectedCount);
765
+ const evidence = Math.min(1, selectedCount / 3);
766
+ return {
767
+ path: filePath,
768
+ selectedCount,
769
+ usedCount,
770
+ feedbackScore: utilization * evidence
771
+ };
772
+ })
773
+ .sort((left, right) => right.feedbackScore - left.feedbackScore || right.usedCount - left.usedCount || left.path.localeCompare(right.path));
774
+ const value = {
775
+ rows,
776
+ selected,
777
+ used,
778
+ unused: Math.max(0, selected - used),
779
+ utilizationRate: ratio(used, selected)
780
+ };
781
+ retrievalFeedbackCache.set(cwd, { signature, value });
782
+ return value;
783
+ }
784
+
785
+ export async function searchContextIndexV2(cwd, query, options = {}) {
786
+ const limit = clampInteger(options.limit, 12, 1, 50);
787
+ const status = await contextIndexV2Status(cwd);
788
+ if (!status.exists) return { query, terms: [], confidence: "none", results: [], status };
789
+ const terms = tokenizeQuery(query);
790
+ const explicitPaths = queryPathCandidates(query);
791
+ if (terms.length === 0 && explicitPaths.length === 0) return { query, terms, confidence: "none", results: [], status };
792
+ const db = await openDatabase(cwd, { create: false });
793
+ try {
794
+ const scores = new Map();
795
+ const allFiles = db.prepare("SELECT path, mtime_ms FROM files").all();
796
+ const explicitRows = [];
797
+ for (const file of allFiles) {
798
+ const basename = path.posix.basename(file.path).toLowerCase();
799
+ const matched = explicitPaths.some((candidate) => {
800
+ const normalized = candidate.toLowerCase();
801
+ return file.path.toLowerCase() === normalized || file.path.toLowerCase().endsWith(`/${normalized}`) || basename === path.posix.basename(normalized);
802
+ });
803
+ if (matched) explicitRows.push({ path: file.path, exact: true });
804
+ }
805
+ addRankedList(scores, explicitRows, "explicit", 3);
806
+
807
+ let lexicalRows = [];
808
+ if (terms.length > 0) {
809
+ lexicalRows = db.prepare(`
810
+ SELECT path, bm25(file_fts, 0.0, 1.0, 2.2) lexical_score
811
+ FROM file_fts WHERE file_fts MATCH ? ORDER BY lexical_score LIMIT ?
812
+ `).all(ftsQuery(terms), Math.max(limit * 4, 30));
813
+ addRankedList(scores, lexicalRows, "lexical", 1.5);
814
+ }
815
+
816
+ const symbolRows = [];
817
+ const symbolStatement = db.prepare(`
818
+ SELECT file_path, name, kind, line, end_line, signature
819
+ FROM symbols WHERE lower(name) = ? OR lower(name) LIKE ?
820
+ ORDER BY CASE WHEN lower(name) = ? THEN 0 ELSE 1 END, length(name) LIMIT ?
821
+ `);
822
+ for (const term of terms.slice(0, 8)) {
823
+ symbolRows.push(...symbolStatement.all(term, `%${term}%`, term, Math.max(limit * 2, 20)));
824
+ }
825
+ const dedupedSymbols = [...new Map(symbolRows.map((row) => [`${row.file_path}:${row.line}`, row])).values()];
826
+ addRankedList(scores, dedupedSymbols, "symbol", 2.2);
827
+
828
+ const changedRows = gitChangedFiles(cwd).map((filePath) => ({ path: filePath })).filter((row) => scores.has(row.path));
829
+ addRankedList(scores, changedRows, "git-change", 0.8);
830
+
831
+ const testRows = allFiles
832
+ .filter((file) => /(^|\/)(?:test|tests|__tests__)(\/|$)|(?:\.test|\.spec|_test)\./i.test(file.path))
833
+ .filter((file) => terms.some((term) => file.path.toLowerCase().includes(term)))
834
+ .map((file) => ({ path: file.path }));
835
+ addRankedList(scores, testRows, "test", 1.1);
836
+
837
+ // Feedback is deliberately positive-only and weak. A file is boosted only
838
+ // after a prior context pack selected it and the same session actually used
839
+ // it. New files and unused historical candidates are never penalized.
840
+ const feedbackRows = retrievalFeedback(cwd).rows.filter((row) => scores.has(row.path));
841
+ addRankedList(scores, feedbackRows, "feedback", 0.45);
842
+
843
+ const graphSeeds = new Map([...scores.entries()].map(([filePath, value]) => [filePath, value.score]));
844
+ const importRows = db.prepare("SELECT file_path, target_path FROM imports WHERE target_path IS NOT NULL").all();
845
+ const graphRows = personalizedPageRank(importRows, graphSeeds).slice(0, Math.max(limit * 3, 30));
846
+ addRankedList(scores, graphRows, "graph", 1);
847
+
848
+ const ranked = [...scores.values()].sort((left, right) => right.score - left.score).slice(0, limit);
849
+ const symbolLookup = db.prepare("SELECT name, kind, line, end_line, signature FROM symbols WHERE file_path = ? ORDER BY line LIMIT 12");
850
+ const results = ranked.map((candidate) => {
851
+ const matchingSymbols = candidate.rows
852
+ .filter((row) => row.source === "symbol")
853
+ .map((row) => ({ name: row.name, kind: row.kind, line: row.line, endLine: row.end_line, signature: row.signature }));
854
+ const symbols = matchingSymbols.length > 0 ? matchingSymbols : symbolLookup.all(candidate.path).slice(0, 6).map((row) => ({
855
+ name: row.name, kind: row.kind, line: row.line, endLine: row.end_line, signature: row.signature
856
+ }));
857
+ return {
858
+ path: candidate.path,
859
+ score: Number(candidate.score.toFixed(6)),
860
+ sources: [...candidate.sources],
861
+ symbols
862
+ };
863
+ });
864
+ const top = results[0];
865
+ const exact = Boolean(top?.sources.includes("explicit")) || Boolean(top?.sources.includes("symbol") && top.sources.length >= 2);
866
+ const confidence = !top ? "none" : exact ? "high" : top.sources.length >= 2 ? "medium" : "low";
867
+ return { query, terms, confidence, results, status };
868
+ } finally {
869
+ db.close();
870
+ }
871
+ }
872
+
873
+ function lineRangesForFile(text, result, terms, includeCode) {
874
+ if (!includeCode) return [];
875
+ const lines = text.split(/\r?\n/);
876
+ const centers = new Set();
877
+ for (const symbol of result.symbols ?? []) centers.add(symbol.line);
878
+ for (let index = 0; index < lines.length && centers.size < 8; index += 1) {
879
+ const lower = lines[index].toLowerCase();
880
+ if (terms.some((term) => lower.includes(term))) centers.add(index + 1);
881
+ }
882
+ const ranges = [...centers].sort((left, right) => left - right).map((line) => ({
883
+ start: Math.max(1, line - 3),
884
+ end: Math.min(lines.length, line + 5)
885
+ }));
886
+ const merged = [];
887
+ for (const range of ranges) {
888
+ const previous = merged.at(-1);
889
+ if (previous && range.start <= previous.end + 2) previous.end = Math.max(previous.end, range.end);
890
+ else merged.push({ ...range });
891
+ if (merged.length >= 3) break;
892
+ }
893
+ return merged.map((range) => ({
894
+ ...range,
895
+ text: lines.slice(range.start - 1, range.end).map((line, index) => `${range.start + index}: ${line}`).join("\n")
896
+ }));
897
+ }
898
+
899
+ export async function buildContextPack(cwd, query, options = {}) {
900
+ const budgetTokens = clampInteger(options.budgetTokens, DEFAULT_PACK_TOKENS, 200, 50_000);
901
+ const includeCode = options.includeCode !== false;
902
+ const search = await searchContextIndexV2(cwd, query, { limit: clampInteger(options.limit, 16, 1, 50) });
903
+ const selected = [];
904
+ const mapBudget = Math.min(1_200, Math.max(40, Math.trunc(budgetTokens * 0.2)));
905
+ const mapLines = [];
906
+ for (const result of search.results) {
907
+ const symbolNames = result.symbols.slice(0, 5).map((symbol) => `${symbol.kind} ${symbol.name}`).join(", ");
908
+ const line = `- ${result.path}${symbolNames ? `: ${symbolNames}` : ""} [${result.sources.join("+")}]`;
909
+ const cost = estimateContextTokens(line);
910
+ if (estimateContextTokens(mapLines.join("\n")) + cost > mapBudget) break;
911
+ mapLines.push(line);
912
+ }
913
+ const footer = "Use this as navigation evidence. Re-read current files before editing; index hits are advisory.";
914
+ const renderHeader = () => [
915
+ "Pi Context Pack v2",
916
+ `queryHash: ${sha256(String(query)).slice(0, 12)}`,
917
+ `confidence: ${search.confidence}`,
918
+ `index: ${search.status.files ?? 0} files / ${search.status.symbols ?? 0} symbols${search.status.stale ? " / stale" : ""}`,
919
+ "",
920
+ "Repository map:",
921
+ ...(mapLines.length > 0 ? mapLines : ["- no ranked matches"])
922
+ ].join("\n");
923
+ let header = renderHeader();
924
+ const renderPack = (items) => [
925
+ header,
926
+ ...(items.length > 0 ? ["", ...items.map((item) => item.text)] : []),
927
+ "",
928
+ footer
929
+ ].join("\n");
930
+ while (mapLines.length > 0 && estimateContextTokens(renderPack([])) > budgetTokens) {
931
+ mapLines.pop();
932
+ header = renderHeader();
933
+ }
934
+
935
+ for (const result of search.results) {
936
+ let text;
937
+ try {
938
+ text = fs.readFileSync(path.resolve(cwd, result.path), "utf8");
939
+ } catch {
940
+ continue;
941
+ }
942
+ const snippets = lineRangesForFile(text, result, search.terms, includeCode);
943
+ const body = snippets.length > 0
944
+ ? snippets.map((snippet) => `lines ${snippet.start}-${snippet.end}\n${snippet.text}`).join("\n\n")
945
+ : result.symbols.slice(0, 8).map((symbol) => `line ${symbol.line}: ${symbol.signature}`).join("\n");
946
+ const itemText = [`### ${result.path}`, `why: ${result.sources.join(", ")}`, body].filter(Boolean).join("\n");
947
+ const tokens = estimateContextTokens(itemText);
948
+ const item = { ...result, text: itemText, estimatedTokens: tokens, truncated: false };
949
+ if (estimateContextTokens(renderPack([...selected, item])) > budgetTokens) {
950
+ if (selected.length === 0) {
951
+ const remaining = budgetTokens - estimateContextTokens(renderPack([]));
952
+ let chars = Math.max(0, remaining * 4 - 16);
953
+ let truncated = { ...result, text: itemText.slice(0, chars), estimatedTokens: remaining, truncated: true };
954
+ while (chars > 0 && estimateContextTokens(renderPack([truncated])) > budgetTokens) {
955
+ chars = Math.max(0, chars - 16);
956
+ truncated = { ...truncated, text: itemText.slice(0, chars) };
957
+ }
958
+ if (chars > 0) selected.push(truncated);
959
+ }
960
+ continue;
961
+ }
962
+ selected.push(item);
963
+ }
964
+
965
+ const text = renderPack(selected);
966
+ return {
967
+ queryHash: sha256(String(query)),
968
+ confidence: search.confidence,
969
+ status: search.status,
970
+ candidates: search.results.length,
971
+ selected: selected.map(({ text: _text, ...item }) => item),
972
+ estimatedTokens: estimateContextTokens(text),
973
+ finderRecommended: search.confidence === "none" || search.confidence === "low",
974
+ finderRequest: search.confidence === "none" || search.confidence === "low"
975
+ ? `Run one bounded read-only finder pass for: ${String(query).slice(0, 600)}. Return only paths, symbols, line ranges, evidence, and unknowns.`
976
+ : undefined,
977
+ text
978
+ };
979
+ }
980
+
981
+ export async function buildTestImpact(cwd, changedFiles = [], options = {}) {
982
+ const status = await contextIndexV2Status(cwd);
983
+ const normalizedChanged = [...new Set((changedFiles.length > 0 ? changedFiles : gitChangedFiles(cwd)).map(normalizeRelative).filter(Boolean))];
984
+ if (!status.exists) return { changedFiles: normalizedChanged, impactedFiles: [], tests: [], status };
985
+ const db = await openDatabase(cwd, { create: false });
986
+ try {
987
+ const reverseStatement = db.prepare("SELECT file_path FROM imports WHERE target_path = ?");
988
+ const queue = normalizedChanged.map((filePath) => ({ filePath, depth: 0 }));
989
+ const seen = new Set(normalizedChanged);
990
+ const impacted = [];
991
+ const maxDepth = clampInteger(options.maxDepth, 3, 0, 8);
992
+ while (queue.length > 0 && seen.size < 500) {
993
+ const current = queue.shift();
994
+ if (current.depth >= maxDepth) continue;
995
+ for (const row of reverseStatement.all(current.filePath)) {
996
+ if (seen.has(row.file_path)) continue;
997
+ seen.add(row.file_path);
998
+ impacted.push({ path: row.file_path, via: current.filePath, depth: current.depth + 1 });
999
+ queue.push({ filePath: row.file_path, depth: current.depth + 1 });
1000
+ }
1001
+ }
1002
+ const allFiles = db.prepare("SELECT path FROM files").all().map((row) => row.path);
1003
+ const stemTerms = normalizedChanged.flatMap((filePath) => {
1004
+ const basename = path.posix.basename(filePath, path.posix.extname(filePath)).replace(/\.(?:test|spec)$/, "");
1005
+ return basename.split(/[-_.]/).filter((term) => term.length >= 3);
1006
+ });
1007
+ const tests = allFiles
1008
+ .filter((filePath) => /(^|\/)(?:test|tests|__tests__)(\/|$)|(?:\.test|\.spec|_test)\./i.test(filePath))
1009
+ .filter((filePath) => stemTerms.some((term) => filePath.toLowerCase().includes(term.toLowerCase())) || seen.has(filePath))
1010
+ .slice(0, 80);
1011
+ return { changedFiles: normalizedChanged, impactedFiles: impacted, tests, status };
1012
+ } finally {
1013
+ db.close();
1014
+ }
1015
+ }
1016
+
1017
+ export function appendContextTelemetry(cwd, event) {
1018
+ const paths = contextEnginePaths(cwd);
1019
+ fs.mkdirSync(paths.root, { recursive: true });
1020
+ const record = {
1021
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
1022
+ source: "piagent",
1023
+ recordedAt: nowIso(),
1024
+ ...event
1025
+ };
1026
+ fs.appendFileSync(paths.telemetry, `${JSON.stringify(record)}\n`, { mode: 0o600 });
1027
+ return record;
1028
+ }
1029
+
1030
+ export function readContextTelemetry(cwd, options = {}) {
1031
+ const target = contextEnginePaths(cwd).telemetry;
1032
+ if (!fs.existsSync(target)) return [];
1033
+ const limit = clampInteger(options.limit, MAX_TELEMETRY_EVENTS, 1, MAX_TELEMETRY_EVENTS);
1034
+ const maxBytes = clampInteger(options.maxBytes, MAX_TELEMETRY_READ_BYTES, 64 * 1024, 256 * 1024 * 1024);
1035
+ const stat = fs.statSync(target);
1036
+ const start = Math.max(0, stat.size - maxBytes);
1037
+ const length = stat.size - start;
1038
+ const buffer = Buffer.allocUnsafe(length);
1039
+ const descriptor = fs.openSync(target, "r");
1040
+ try {
1041
+ fs.readSync(descriptor, buffer, 0, length, start);
1042
+ } finally {
1043
+ fs.closeSync(descriptor);
1044
+ }
1045
+ let text = buffer.toString("utf8");
1046
+ if (start > 0) text = text.slice(Math.max(0, text.indexOf("\n") + 1));
1047
+ const lines = text.split(/\r?\n/).filter(Boolean);
1048
+ const events = [];
1049
+ for (const line of lines.slice(-limit)) {
1050
+ try {
1051
+ events.push(JSON.parse(line));
1052
+ } catch {
1053
+ // Ignore one incomplete tail record after an interrupted append.
1054
+ }
1055
+ }
1056
+ return events;
1057
+ }
1058
+
1059
+ function ratio(numerator, denominator) {
1060
+ return denominator > 0 ? numerator / denominator : 0;
1061
+ }
1062
+
1063
+ export function buildContextEfficiencyReport(cwd, options = {}) {
1064
+ const events = readContextTelemetry(cwd, options);
1065
+ const prompts = events.filter((event) => event.event === "agent_prompt");
1066
+ const toolCalls = events.filter((event) => event.event === "tool_call");
1067
+ const toolResults = events.filter((event) => event.event === "tool_result");
1068
+ const packs = events.filter((event) => event.event === "context_pack");
1069
+ const compactions = events.filter((event) => event.event === "session_compact");
1070
+ const seenReads = new Set();
1071
+ let duplicateReads = 0;
1072
+ let readCalls = 0;
1073
+ for (const event of toolCalls) {
1074
+ if (!["read", "grep", "find", "ls"].includes(event.toolName)) continue;
1075
+ readCalls += 1;
1076
+ const key = `${event.toolName}:${event.targetHash ?? event.inputHash ?? ""}`;
1077
+ if (seenReads.has(key)) duplicateReads += 1;
1078
+ else seenReads.add(key);
1079
+ }
1080
+ const outputChars = toolResults.reduce((sum, event) => sum + Number(event.outputChars ?? 0), 0);
1081
+ const duplicateOutputChars = toolResults.reduce((sum, event) => sum + (event.repeated ? Number(event.outputChars ?? 0) : 0), 0);
1082
+ const averageActiveTools = prompts.length > 0
1083
+ ? prompts.reduce((sum, event) => sum + Number(event.activeTools ?? 0), 0) / prompts.length
1084
+ : 0;
1085
+ const averageSystemPromptTokens = prompts.length > 0
1086
+ ? prompts.reduce((sum, event) => sum + Number(event.systemPromptTokens ?? 0), 0) / prompts.length
1087
+ : 0;
1088
+ const averageToolSchemaTokens = prompts.length > 0
1089
+ ? prompts.reduce((sum, event) => sum + Number(event.toolSchemaTokens ?? 0), 0) / prompts.length
1090
+ : 0;
1091
+ const lowConfidencePacks = packs.filter((event) => ["none", "low"].includes(event.confidence)).length;
1092
+ const feedback = retrievalFeedback(cwd);
1093
+ const duplicateReadRate = ratio(duplicateReads, readCalls);
1094
+ const duplicateOutputRate = ratio(duplicateOutputChars, outputChars);
1095
+ const schemaShare = ratio(averageToolSchemaTokens, averageSystemPromptTokens);
1096
+ const lowConfidenceRate = ratio(lowConfidencePacks, packs.length);
1097
+ const activeToolPenalty = Math.max(0, Math.min(1, (averageActiveTools - 12) / 24));
1098
+ const wasteScore = Math.round(100 * (
1099
+ duplicateReadRate * 0.3
1100
+ + duplicateOutputRate * 0.25
1101
+ + Math.min(1, schemaShare * 3) * 0.2
1102
+ + lowConfidenceRate * 0.15
1103
+ + activeToolPenalty * 0.1
1104
+ ));
1105
+ const recommendations = [];
1106
+ if (duplicateReadRate > 0.2) recommendations.push("Repeated reads are high; reuse the current working set before searching again.");
1107
+ if (duplicateOutputRate > 0.15) recommendations.push("Repeated tool output is high; prefer delta results and narrower verification.");
1108
+ if (schemaShare > 0.15 || averageActiveTools > 20) recommendations.push("Tool surface is large; activate only the workflow groups needed for the next turn.");
1109
+ if (lowConfidenceRate > 0.4) recommendations.push("Retrieval confidence is low; rebuild the index or run one bounded finder pass.");
1110
+ if (feedback.selected >= 4 && feedback.utilizationRate < 0.45) recommendations.push("Context-pack utilization is low; reduce pack breadth or improve task-specific ranking signals.");
1111
+ if (recommendations.length === 0) recommendations.push("No dominant context waste signal was detected in the sampled events.");
1112
+ const report = {
1113
+ schemaVersion: 1,
1114
+ source: "piagent",
1115
+ generatedAt: nowIso(),
1116
+ sample: {
1117
+ events: events.length,
1118
+ prompts: prompts.length,
1119
+ toolCalls: toolCalls.length,
1120
+ toolResults: toolResults.length,
1121
+ contextPacks: packs.length,
1122
+ compactions: compactions.length
1123
+ },
1124
+ metrics: {
1125
+ averageActiveTools: Number(averageActiveTools.toFixed(2)),
1126
+ averageSystemPromptTokens: Math.round(averageSystemPromptTokens),
1127
+ averageToolSchemaTokens: Math.round(averageToolSchemaTokens),
1128
+ toolSchemaShare: Number(schemaShare.toFixed(4)),
1129
+ readCalls,
1130
+ duplicateReads,
1131
+ duplicateReadRate: Number(duplicateReadRate.toFixed(4)),
1132
+ outputChars,
1133
+ duplicateOutputChars,
1134
+ duplicateOutputRate: Number(duplicateOutputRate.toFixed(4)),
1135
+ lowConfidencePacks,
1136
+ lowConfidenceRate: Number(lowConfidenceRate.toFixed(4)),
1137
+ contextSelections: feedback.selected,
1138
+ contextSelectionsUsed: feedback.used,
1139
+ contextSelectionsUnused: feedback.unused,
1140
+ contextUtilizationRate: Number(feedback.utilizationRate.toFixed(4)),
1141
+ contextWasteScore: wasteScore
1142
+ },
1143
+ methodology: {
1144
+ scoreRange: "0-100; lower is better",
1145
+ weights: {
1146
+ duplicateReads: 0.3,
1147
+ duplicateOutput: 0.25,
1148
+ toolSchemaShare: 0.2,
1149
+ lowConfidenceRetrieval: 0.15,
1150
+ activeToolExcess: 0.1
1151
+ },
1152
+ retrievalFeedback: "Positive-only reranking: a path receives a weak boost only when a prior pack selected it and the same session later used it. Unseen and unused paths are not penalized.",
1153
+ note: "This is an operational signal, not a quality verdict. Compare it with task acceptance and verification results."
1154
+ },
1155
+ recommendations
1156
+ };
1157
+ const paths = contextEnginePaths(cwd);
1158
+ fs.mkdirSync(paths.root, { recursive: true });
1159
+ fs.writeFileSync(paths.report, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
1160
+ return report;
1161
+ }
1162
+
1163
+ export function toolResultFingerprint(toolName, input, content) {
1164
+ const inputHash = sha256(JSON.stringify(input ?? {}));
1165
+ const text = Array.isArray(content)
1166
+ ? content.filter((block) => block && typeof block === "object" && block.type === "text").map((block) => block.text ?? "").join("\n")
1167
+ : String(content ?? "");
1168
+ return {
1169
+ key: `${toolName}:${inputHash}`,
1170
+ inputHash,
1171
+ outputHash: sha256(text),
1172
+ outputChars: text.length,
1173
+ outputLines: text ? text.split(/\r?\n/).length : 0
1174
+ };
1175
+ }