pi-context-engine 0.1.1 → 0.1.3
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 +16 -1
- package/docs/ARCHITECTURE.md +6 -0
- package/package.json +1 -1
- package/src/config.ts +8 -12
- package/src/engine.ts +22 -2
- package/src/extension.ts +7 -0
- package/src/guidance.ts +114 -0
- package/src/indexer/pipeline.ts +98 -17
- package/src/memory/db.ts +46 -0
package/README.md
CHANGED
|
@@ -58,6 +58,20 @@ Ten tools the agent can call, plus three slash commands.
|
|
|
58
58
|
|
|
59
59
|
Commands: `/context-index [--full]`, `/context-status`, `/context-savings`.
|
|
60
60
|
|
|
61
|
+
**Shipped guidance.** The package also injects a ~900-token guidance section
|
|
62
|
+
into the system prompt while `context_search` is active: how to formulate
|
|
63
|
+
queries, when *not* to search, how to read confidence and overflow markers,
|
|
64
|
+
and the index lifecycle.
|
|
65
|
+
|
|
66
|
+
- **Why it ships:** retrieval only saves tokens when queries are good, and
|
|
67
|
+
these rules describe this package's own semantics. Keeping them in the tool
|
|
68
|
+
description alone would leave every user guessing.
|
|
69
|
+
- **Cost-aware gate:** the section appears only while the tool is selected, and
|
|
70
|
+
`guidance.enabled: false` turns it off entirely for prompt-lean setups.
|
|
71
|
+
- **Structured, not forced:** it is written to `systemPromptOptions.sections`,
|
|
72
|
+
so pi replays and diffs it like any other section instead of freezing the
|
|
73
|
+
whole prompt.
|
|
74
|
+
|
|
61
75
|
## How it works
|
|
62
76
|
|
|
63
77
|
1. **Chunk**: tree-sitter parses source into functions, classes and modules.
|
|
@@ -93,6 +107,8 @@ retrieval:
|
|
|
93
107
|
top_k: 20
|
|
94
108
|
confidence_threshold: 0.2
|
|
95
109
|
marginal_ratio: 0.75 # stop adding chunks below this fraction of the top score
|
|
110
|
+
guidance:
|
|
111
|
+
enabled: true # inject context_search guidance into the system prompt
|
|
96
112
|
compression:
|
|
97
113
|
level: standard # minimal | standard | full
|
|
98
114
|
ollama_url: http://localhost:11434
|
|
@@ -100,7 +116,6 @@ embedding:
|
|
|
100
116
|
model: local # or a specific transformers.js model id
|
|
101
117
|
ollama_model: nomic-embed-text
|
|
102
118
|
indexer:
|
|
103
|
-
watch: true
|
|
104
119
|
redact_secrets: true
|
|
105
120
|
memory:
|
|
106
121
|
redact_pii: true
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -26,6 +26,7 @@ pi session
|
|
|
26
26
|
| `indexer/embedder.ts` | Ollama + local ONNX backends, selection | `indexer/embedder.py` |
|
|
27
27
|
| `indexer/secrets.ts` | Secret filename skip, content redaction, PII scrub | `indexer/secrets.py` |
|
|
28
28
|
| `retrieval/retriever.ts` | RRF merge, confidence blend, graph expansion | `retrieval/retriever.py` |
|
|
29
|
+
| `guidance.ts` | Query-formulation system-prompt section (config-gated) | upstream's session-start tool instructions |
|
|
29
30
|
| `compression/compressor.ts` | Truncation signatures, Ollama summaries, quality gate | `compression/*` |
|
|
30
31
|
| `memory/db.ts` | Schema, vec recall, savings ledger, retention | `memory/db.py` |
|
|
31
32
|
| `memory/grammar.ts` | Deterministic prose compression | `memory/grammar.py` |
|
|
@@ -71,6 +72,11 @@ contending with embedding writes. Upstream made the same split.
|
|
|
71
72
|
- **Best-effort everywhere it must not crash.** Savings accounting, audit logs,
|
|
72
73
|
session capture and shutdown all swallow their own errors: an instrumentation
|
|
73
74
|
problem must never break a tool response.
|
|
75
|
+
- **Guidance pays for its tokens or does not ship.** The ~900-token section is
|
|
76
|
+
injected only when `context_search` is in the run's selected tools *and*
|
|
77
|
+
`guidance.enabled` is true, through `sections` (replayable, diffable) rather
|
|
78
|
+
than a forced prompt replacement. Users who count prompt tokens opt out with
|
|
79
|
+
one config line.
|
|
74
80
|
|
|
75
81
|
## Degradation paths
|
|
76
82
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-context-engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Semantic code context for pi: tree-sitter index + hybrid vector/BM25 retrieval + cross-session memory, all local. Port of code-context-engine (MIT, elara-labs) as a native pi extension.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/config.ts
CHANGED
|
@@ -51,16 +51,18 @@ export interface Config {
|
|
|
51
51
|
ollamaUrl: string;
|
|
52
52
|
embeddingModel: string;
|
|
53
53
|
ollamaEmbedModel: string;
|
|
54
|
+
/**
|
|
55
|
+
* Inject the query-formulation guidance section into the system prompt
|
|
56
|
+
* while `context_search` is active. Default true: retrieval quality depends
|
|
57
|
+
* on how queries are written. Set false for the leanest possible prompt.
|
|
58
|
+
*/
|
|
59
|
+
guidanceEnabled: boolean;
|
|
54
60
|
retrievalConfidenceThreshold: number;
|
|
55
61
|
retrievalTopK: number;
|
|
56
62
|
retrievalMarginalRatio: number;
|
|
57
|
-
bootstrapMaxTokens: number;
|
|
58
|
-
indexerWatch: boolean;
|
|
59
|
-
indexerDebounceMs: number;
|
|
60
63
|
indexerIgnore: string[];
|
|
61
64
|
indexerRedactSecrets: boolean;
|
|
62
65
|
memoryRedactPii: boolean;
|
|
63
|
-
auditLogEnabled: boolean;
|
|
64
66
|
pricingModel: string;
|
|
65
67
|
pricingInput: number | null;
|
|
66
68
|
pricingOutput: number | null;
|
|
@@ -74,16 +76,13 @@ export function defaultConfig(): Config {
|
|
|
74
76
|
ollamaUrl: "http://localhost:11434",
|
|
75
77
|
embeddingModel: "local",
|
|
76
78
|
ollamaEmbedModel: "nomic-embed-text",
|
|
79
|
+
guidanceEnabled: true,
|
|
77
80
|
retrievalConfidenceThreshold: 0.2,
|
|
78
81
|
retrievalTopK: 20,
|
|
79
82
|
retrievalMarginalRatio: 0.75,
|
|
80
|
-
bootstrapMaxTokens: 10000,
|
|
81
|
-
indexerWatch: true,
|
|
82
|
-
indexerDebounceMs: 500,
|
|
83
83
|
indexerIgnore: [...DEFAULT_IGNORE],
|
|
84
84
|
indexerRedactSecrets: true,
|
|
85
85
|
memoryRedactPii: true,
|
|
86
|
-
auditLogEnabled: false,
|
|
87
86
|
pricingModel: "opus",
|
|
88
87
|
pricingInput: null,
|
|
89
88
|
pricingOutput: null,
|
|
@@ -96,18 +95,15 @@ const MAPPING: Record<string, keyof Config> = {
|
|
|
96
95
|
"compression.level": "compressionLevel",
|
|
97
96
|
"compression.model": "compressionModel",
|
|
98
97
|
"compression.ollama_url": "ollamaUrl",
|
|
98
|
+
"guidance.enabled": "guidanceEnabled",
|
|
99
99
|
"embedding.model": "embeddingModel",
|
|
100
100
|
"embedding.ollama_model": "ollamaEmbedModel",
|
|
101
101
|
"retrieval.confidence_threshold": "retrievalConfidenceThreshold",
|
|
102
102
|
"retrieval.top_k": "retrievalTopK",
|
|
103
103
|
"retrieval.marginal_ratio": "retrievalMarginalRatio",
|
|
104
|
-
"retrieval.bootstrap_max_tokens": "bootstrapMaxTokens",
|
|
105
|
-
"indexer.watch": "indexerWatch",
|
|
106
|
-
"indexer.debounce_ms": "indexerDebounceMs",
|
|
107
104
|
"indexer.ignore": "indexerIgnore",
|
|
108
105
|
"indexer.redact_secrets": "indexerRedactSecrets",
|
|
109
106
|
"memory.redact_pii": "memoryRedactPii",
|
|
110
|
-
"audit.enabled": "auditLogEnabled",
|
|
111
107
|
"storage.path": "storagePath",
|
|
112
108
|
"pricing.model": "pricingModel",
|
|
113
109
|
"pricing.input": "pricingInput",
|
package/src/engine.ts
CHANGED
|
@@ -7,7 +7,7 @@ import fs from "node:fs";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import type { Config } from "./config.js";
|
|
9
9
|
import { loadConfigForProject, resolveOllamaUrl } from "./config.js";
|
|
10
|
-
import { epochSeconds, humaniseRelativeTime, projectStorageDir, sha256Hex } from "./utils.js";
|
|
10
|
+
import { atomicWriteText, epochSeconds, humaniseRelativeTime, projectStorageDir, sha256Hex } from "./utils.js";
|
|
11
11
|
import { LocalBackend } from "./storage/local-backend.js";
|
|
12
12
|
import { Embedder, selectBackend } from "./indexer/embedder.js";
|
|
13
13
|
import { HybridRetriever } from "./retrieval/retriever.js";
|
|
@@ -146,6 +146,7 @@ export class ContextEngine {
|
|
|
146
146
|
draining = false;
|
|
147
147
|
private searchesSinceDecision = 0;
|
|
148
148
|
private hasRecordedDecision = false;
|
|
149
|
+
private vecBackfillStarted = false;
|
|
149
150
|
|
|
150
151
|
constructor(options: EngineOptions) {
|
|
151
152
|
this.projectDir = fs.realpathSync(path.resolve(options.projectDir));
|
|
@@ -247,7 +248,7 @@ export class ContextEngine {
|
|
|
247
248
|
|
|
248
249
|
private saveStats(): void {
|
|
249
250
|
try {
|
|
250
|
-
|
|
251
|
+
atomicWriteText(this.statsPath, JSON.stringify(this.stats));
|
|
251
252
|
} catch {
|
|
252
253
|
// stats are best-effort
|
|
253
254
|
}
|
|
@@ -426,6 +427,25 @@ export class ContextEngine {
|
|
|
426
427
|
void this.drainCompressionQueue();
|
|
427
428
|
}, 5000);
|
|
428
429
|
this.drainTimer.unref?.();
|
|
430
|
+
void this.backfillMemoryVec().catch(() => undefined);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* One-shot semantic-memory backfill (upstream #137 lesson). After an
|
|
435
|
+
* embedder change the vec tables are recreated empty; without this, old
|
|
436
|
+
* decisions silently lose semantic recall (FTS still works, so the loss is
|
|
437
|
+
* easy to miss). Guarded by a cheap missing-row count so the steady state
|
|
438
|
+
* never initialises an embedder for this. Recall also self-heals via
|
|
439
|
+
* `MemoryDb.healVecTables`, covering wipes that happen after this runs.
|
|
440
|
+
*/
|
|
441
|
+
private async backfillMemoryVec(): Promise<void> {
|
|
442
|
+
if (this.vecBackfillStarted) return;
|
|
443
|
+
this.vecBackfillStarted = true;
|
|
444
|
+
const memory = this.getMemory();
|
|
445
|
+
if (!memory || !memory.vecAvailable || !memory.hasVecTables()) return;
|
|
446
|
+
const pending = memory.vecBackfillPending();
|
|
447
|
+
if (pending.decisions + pending.turnSummaries === 0) return;
|
|
448
|
+
await memory.backfillVecTables(await this.getEmbedder());
|
|
429
449
|
}
|
|
430
450
|
|
|
431
451
|
async shutdown(): Promise<void> {
|
package/src/extension.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { Type } from "typebox";
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { ContextEngine, isPceConfig } from "./engine.js";
|
|
14
|
+
import { applyContextSearchGuidance } from "./guidance.js";
|
|
14
15
|
|
|
15
16
|
/** Tools whose payloads are worth capturing for session timelines. */
|
|
16
17
|
const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
|
|
@@ -305,6 +306,12 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
|
|
|
305
306
|
promptNumber = eng.recordPrompt(event.prompt ?? "");
|
|
306
307
|
eng.enqueueTurnCompression(promptNumber);
|
|
307
308
|
|
|
309
|
+
// Query-formulation guidance for context_search: gated on the tool being
|
|
310
|
+
// active and on `guidance.enabled` (config), so prompt-lean setups pay
|
|
311
|
+
// nothing. Section, not a forced prompt override: pi diffs it like any
|
|
312
|
+
// other section and the transcript stays stable.
|
|
313
|
+
applyContextSearchGuidance(event.systemPromptOptions, eng.config.guidanceEnabled);
|
|
314
|
+
|
|
308
315
|
// First prompt of the session: inject a compact "where you left off"
|
|
309
316
|
// block as a non-displayed custom message (upstream's SessionStart hook).
|
|
310
317
|
if (!bootstrapSent) {
|
package/src/guidance.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-prompt guidance for the package's own tools.
|
|
3
|
+
*
|
|
4
|
+
* Why this ships with the package: retrieval quality depends on how the query
|
|
5
|
+
* is written, and the tool's `promptSnippet`/`promptGuidelines` only say "use
|
|
6
|
+
* this instead of grep/read". Everything specific to *this* package's
|
|
7
|
+
* semantics (confidence scores, overflow references, `/context-index`, the
|
|
8
|
+
* empty-index background build) has to travel with the package, or it drifts
|
|
9
|
+
* the moment a tool parameter changes.
|
|
10
|
+
*
|
|
11
|
+
* Injection is gated on three conditions so the ~900-token block never lands
|
|
12
|
+
* where it does not pay for itself:
|
|
13
|
+
* 1. `context_search` must be in the run's selected tools
|
|
14
|
+
* 2. `guidance.enabled` must be true in config (default true)
|
|
15
|
+
* 3. only runs that start from a user prompt get the bootstrap message;
|
|
16
|
+
* guidance itself applies to every run while the tool is active
|
|
17
|
+
*
|
|
18
|
+
* Registered through `systemPromptOptions.sections` rather than a forced
|
|
19
|
+
* `systemPrompt`: pi replays and diffs structured sections like any other, so
|
|
20
|
+
* the transcript stays stable instead of freezing a whole-prompt override.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const CONTEXT_SEARCH_SECTION_KEY = "context_search_guidelines";
|
|
24
|
+
|
|
25
|
+
/** Minimal shape of the mutable prompt options this module touches. */
|
|
26
|
+
export interface GuidanceTarget {
|
|
27
|
+
selectedTools: string[];
|
|
28
|
+
sections: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const CONTEXT_SEARCH_GUIDELINES = `You have context_search, a semantic code search over this project's index (tree-sitter chunks, hybrid vector + BM25, confidence-scored). Use it well.
|
|
32
|
+
|
|
33
|
+
### When to search vs. when not to
|
|
34
|
+
|
|
35
|
+
SEARCH when:
|
|
36
|
+
- Starting a new task and you need to understand the relevant code area
|
|
37
|
+
- You need to find where a concept, pattern, or feature is implemented
|
|
38
|
+
- Before editing code you haven't recently read
|
|
39
|
+
- You need cross-file relationships (callers, imports, dependents)
|
|
40
|
+
|
|
41
|
+
SKIP the search when:
|
|
42
|
+
- You already have the file contents in context from a recent read
|
|
43
|
+
- You were given an exact file path and line numbers
|
|
44
|
+
- You need a simple exact-string match (use bash with grep/rg instead)
|
|
45
|
+
- You are editing a file you just read in the current turn
|
|
46
|
+
|
|
47
|
+
### Writing effective queries
|
|
48
|
+
|
|
49
|
+
GOOD queries - specific, scoped, with concrete symbols or behaviour:
|
|
50
|
+
- "How does the AuthenticationMiddleware validate JWT tokens? Show the verify method and its error handling"
|
|
51
|
+
- "Where is the UserService.createUser method defined? Show the full implementation and parameters"
|
|
52
|
+
- "Find all HTTP route handlers that accept file uploads"
|
|
53
|
+
- "How does the EventBus dispatch events to subscribers?"
|
|
54
|
+
|
|
55
|
+
POOR queries - vague, broad, or just a symbol dump:
|
|
56
|
+
- "Show me the codebase"
|
|
57
|
+
- "Authentication"
|
|
58
|
+
- "Find UserService"
|
|
59
|
+
|
|
60
|
+
RULES:
|
|
61
|
+
1. Describe WHAT you want in natural language - the index is semantic, prose works better than keywords
|
|
62
|
+
2. Include CONTEXT: what it is for (implementation? debugging? refactoring?)
|
|
63
|
+
3. Ask for the LEVEL OF DETAIL: "full method", "the interface and its implementors", "the call chain"
|
|
64
|
+
4. Combine related lookups into ONE query instead of several calls
|
|
65
|
+
|
|
66
|
+
### Reading the results
|
|
67
|
+
|
|
68
|
+
- Each hit is a compressed chunk: \`[path:start] (confidence: 0.xx)\` followed by the code
|
|
69
|
+
- A "\`expand_chunk\`" reference list means results were cut by the token budget - expand only the ones you actually need, never every entry
|
|
70
|
+
- Confidence below ~0.5 usually means the index has nothing better; rephrase rather than trusting a weak hit
|
|
71
|
+
- Results come from the index, not the live file: before editing, open the file with \`read\` to see its current state
|
|
72
|
+
|
|
73
|
+
### Before editing code
|
|
74
|
+
|
|
75
|
+
Search once with a query covering ALL of:
|
|
76
|
+
- The function/method/class you are modifying (full implementation)
|
|
77
|
+
- Its types and constants
|
|
78
|
+
- Callers that could break
|
|
79
|
+
|
|
80
|
+
Example: "I need to add a retry parameter to HttpClient.request. Show the HttpClient class with the request method, HttpClientConfig, every caller of request(), and error types it throws."
|
|
81
|
+
|
|
82
|
+
### Iterative search
|
|
83
|
+
|
|
84
|
+
If the first search misses:
|
|
85
|
+
1. REPHRASE - the codebase may use different names than you expect
|
|
86
|
+
2. BROADEN - add surroundings ("and the module that defines it")
|
|
87
|
+
3. NARROW - add specifics ("the async version returning a Promise")
|
|
88
|
+
4. Change ANGLE - search for tests, the type definition, or a usage example
|
|
89
|
+
|
|
90
|
+
### Combining with other tools
|
|
91
|
+
|
|
92
|
+
- \`related_context\` - walk import/defines edges from a hit to reach neighbouring code
|
|
93
|
+
- \`read\` - full current file contents when you are about to edit (search returns excerpts)
|
|
94
|
+
- \`bash\` with grep/rg - exact strings (TODO markers, error messages, literal identifiers)
|
|
95
|
+
- \`bash\` with git - history: \`git log -p -- path/to/file\`
|
|
96
|
+
|
|
97
|
+
### Index lifecycle
|
|
98
|
+
|
|
99
|
+
- If a search says the index is empty, a background index is already running - retry shortly, or run \`/context-index\` for a synchronous build
|
|
100
|
+
- After large refactors, \`reindex\` (or \`/context-index\`) keeps results current; \`index_status\` reports freshness and measured token savings
|
|
101
|
+
- Incremental re-index only re-reads changed files, so it is cheap
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Apply the guidance section to mutable prompt options.
|
|
106
|
+
* No-op (and safe to call on every run) when the tool is inactive or the
|
|
107
|
+
* user turned guidance off. Returns whether it injected, for tests.
|
|
108
|
+
*/
|
|
109
|
+
export function applyContextSearchGuidance(options: GuidanceTarget, enabled: boolean): boolean {
|
|
110
|
+
if (!enabled) return false;
|
|
111
|
+
if (!options.selectedTools.includes("context_search")) return false;
|
|
112
|
+
options.sections[CONTEXT_SEARCH_SECTION_KEY] = CONTEXT_SEARCH_GUIDELINES;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
package/src/indexer/pipeline.ts
CHANGED
|
@@ -139,6 +139,40 @@ function resolveWithin(projectDir: string, target: string): string {
|
|
|
139
139
|
return resolved;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** A directory target is excluded when it or any ancestor matches a dir rule (upstream #165b). */
|
|
143
|
+
function cceignoreMatchesDir(relPosix: string, patterns: string[]): boolean {
|
|
144
|
+
const parts = relPosix.split("/");
|
|
145
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
146
|
+
if (matchesAny(parts.slice(0, i).join("/"), true, patterns)) return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Target-path policy gate, mirroring what the full walker enforces while
|
|
153
|
+
* descending: every path segment must pass `indexer.ignore`, files must pass
|
|
154
|
+
* the secret-filename check, and `.cceignore` is matched against the
|
|
155
|
+
* project-relative path (dir rules for directories, file + ancestor rules for
|
|
156
|
+
* files). The project root (`rel === ""`) is exempt — rules apply to entries
|
|
157
|
+
* below the root, not to the root itself (a checkout named `build` must still
|
|
158
|
+
* reindex via `path: "."`).
|
|
159
|
+
*/
|
|
160
|
+
function targetExcluded(
|
|
161
|
+
relPosix: string,
|
|
162
|
+
opts: { isDir: boolean; ignoreSet: Set<string>; redactSecrets: boolean; patterns: string[] },
|
|
163
|
+
): boolean {
|
|
164
|
+
if (relPosix === "") return false;
|
|
165
|
+
const segments = relPosix.split("/");
|
|
166
|
+
for (const seg of segments) {
|
|
167
|
+
if (opts.ignoreSet.has(seg)) return true;
|
|
168
|
+
}
|
|
169
|
+
// The walker only classifies secret names on file entries; a secret-named
|
|
170
|
+
// directory's contents are still gated per file.
|
|
171
|
+
if (!opts.isDir && opts.redactSecrets && isSecretFile(segments[segments.length - 1]!)) return true;
|
|
172
|
+
if (opts.patterns.length === 0) return false;
|
|
173
|
+
return opts.isDir ? cceignoreMatchesDir(relPosix, opts.patterns) : cceignoreMatchesFile(relPosix, opts.patterns);
|
|
174
|
+
}
|
|
175
|
+
|
|
142
176
|
function safeRead(filePath: string): string | null {
|
|
143
177
|
try {
|
|
144
178
|
if (fs.statSync(filePath).size > MAX_FILE_BYTES) return null;
|
|
@@ -149,7 +183,9 @@ function safeRead(filePath: string): string | null {
|
|
|
149
183
|
}
|
|
150
184
|
|
|
151
185
|
function* iterProjectFiles(
|
|
152
|
-
|
|
186
|
+
walkRoot: string,
|
|
187
|
+
/** Project root: `.cceignore` patterns are matched against project-relative paths even when walking from a target. */
|
|
188
|
+
relRoot: string,
|
|
153
189
|
ignoreSet: Set<string>,
|
|
154
190
|
opts: { redactSecrets: boolean; cceignorePatterns: string[] },
|
|
155
191
|
): Generator<string> {
|
|
@@ -175,7 +211,7 @@ function* iterProjectFiles(
|
|
|
175
211
|
}
|
|
176
212
|
if (seen.has(resolved)) continue;
|
|
177
213
|
seen.add(resolved);
|
|
178
|
-
const rel = path.relative(
|
|
214
|
+
const rel = path.relative(relRoot, full).replace(/\\/g, "/");
|
|
179
215
|
if (opts.cceignorePatterns.length > 0 && matchesAny(rel, entry.isDirectory(), opts.cceignorePatterns)) {
|
|
180
216
|
continue;
|
|
181
217
|
}
|
|
@@ -188,7 +224,7 @@ function* iterProjectFiles(
|
|
|
188
224
|
}
|
|
189
225
|
}
|
|
190
226
|
};
|
|
191
|
-
yield* walk(
|
|
227
|
+
yield* walk(walkRoot);
|
|
192
228
|
}
|
|
193
229
|
|
|
194
230
|
/** `{embeddings, cacheHits, cacheMisses}` for a batch, using the content cache. */
|
|
@@ -385,32 +421,77 @@ async function runIndexingLocked(
|
|
|
385
421
|
if (cceignorePatterns.length > 0) log?.(` [.cceignore] ${cceignorePatterns.length} pattern(s) loaded`);
|
|
386
422
|
|
|
387
423
|
// ── Determine the file set ─────────────────────────────────────────────
|
|
424
|
+
/**
|
|
425
|
+
* A file the inclusion policy now rejects must not leave stale chunks
|
|
426
|
+
* behind (upstream #127): purge its manifest entry and indexed rows so a
|
|
427
|
+
* targeted reindex is enough to enforce a new secret/ignore rule.
|
|
428
|
+
*/
|
|
429
|
+
const purgeIndexed = (rels: string[]): void => {
|
|
430
|
+
const indexed = rels.filter((rel) => manifest.getHash(rel) !== undefined);
|
|
431
|
+
if (indexed.length === 0) return;
|
|
432
|
+
backend.deleteByFiles(indexed);
|
|
433
|
+
for (const rel of indexed) {
|
|
434
|
+
manifest.remove(rel);
|
|
435
|
+
result.deletedFiles.push(rel);
|
|
436
|
+
log?.(` [delete] ${rel} (excluded by policy)`);
|
|
437
|
+
}
|
|
438
|
+
manifest.save();
|
|
439
|
+
};
|
|
388
440
|
let fileList: string[];
|
|
389
441
|
if (targetPath) {
|
|
390
442
|
const target = resolveWithin(projectDir, targetPath);
|
|
391
443
|
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
|
|
392
444
|
const rel = path.relative(projectDir, target).replace(/\\/g, "/");
|
|
393
445
|
if (SKIP_EXTENSIONS.has(path.extname(target).toLowerCase())) {
|
|
446
|
+
purgeIndexed([rel]);
|
|
394
447
|
fileList = [];
|
|
395
|
-
} else if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
448
|
+
} else if (
|
|
449
|
+
targetExcluded(rel, {
|
|
450
|
+
isDir: false,
|
|
451
|
+
ignoreSet,
|
|
452
|
+
redactSecrets: config.indexerRedactSecrets,
|
|
453
|
+
patterns: cceignorePatterns,
|
|
454
|
+
})
|
|
455
|
+
) {
|
|
456
|
+
// Ancestor segments included: the full walker would have pruned
|
|
457
|
+
// the path before reaching this file (upstream #165b parity).
|
|
402
458
|
result.skippedFiles.push(rel);
|
|
459
|
+
purgeIndexed([rel]);
|
|
403
460
|
fileList = [];
|
|
404
461
|
} else {
|
|
405
462
|
fileList = [target];
|
|
406
463
|
}
|
|
407
464
|
} else if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
465
|
+
// Upstream #165b: the walk starts inside the target, so the target
|
|
466
|
+
// path itself must first pass the same policy the full walker
|
|
467
|
+
// enforces while descending — every segment vs `indexer.ignore`
|
|
468
|
+
// (e.g. `dist/nested` is rejected because `dist` is), plus
|
|
469
|
+
// `.cceignore` dir rules for the target and its ancestors.
|
|
470
|
+
const rel = path.relative(projectDir, target).replace(/\\/g, "/");
|
|
471
|
+
const excluded = targetExcluded(rel, {
|
|
472
|
+
isDir: true,
|
|
473
|
+
ignoreSet,
|
|
474
|
+
redactSecrets: config.indexerRedactSecrets,
|
|
475
|
+
patterns: cceignorePatterns,
|
|
476
|
+
});
|
|
477
|
+
if (excluded) {
|
|
478
|
+
result.skippedFiles.push(rel);
|
|
479
|
+
fileList = [];
|
|
480
|
+
} else {
|
|
481
|
+
fileList = [
|
|
482
|
+
...iterProjectFiles(target, projectDir, ignoreSet, {
|
|
483
|
+
redactSecrets: config.indexerRedactSecrets,
|
|
484
|
+
cceignorePatterns,
|
|
485
|
+
}),
|
|
486
|
+
];
|
|
487
|
+
}
|
|
488
|
+
// Targeted runs skip the full-run prune below, so purge manifest
|
|
489
|
+
// entries under this subtree the walk no longer yields: newly
|
|
490
|
+
// excluded files, secret files, and deleted files (upstream
|
|
491
|
+
// #127/#165b). Scoped to the target so siblings are untouched.
|
|
492
|
+
const yielded = new Set(fileList.map((f) => path.relative(projectDir, f).replace(/\\/g, "/")));
|
|
493
|
+
const underScope = (p: string): boolean => (rel === "" ? true : p === rel || p.startsWith(`${rel}/`));
|
|
494
|
+
purgeIndexed(Object.keys(manifest.fileEntries).filter((p) => underScope(p) && !yielded.has(p)));
|
|
414
495
|
} else {
|
|
415
496
|
// Watcher deletion path: prune instead of erroring out.
|
|
416
497
|
const rel = path.relative(projectDir, target).replace(/\\/g, "/");
|
|
@@ -426,7 +507,7 @@ async function runIndexingLocked(
|
|
|
426
507
|
}
|
|
427
508
|
} else {
|
|
428
509
|
fileList = [
|
|
429
|
-
...iterProjectFiles(projectDir, ignoreSet, {
|
|
510
|
+
...iterProjectFiles(projectDir, projectDir, ignoreSet, {
|
|
430
511
|
redactSecrets: config.indexerRedactSecrets,
|
|
431
512
|
cceignorePatterns,
|
|
432
513
|
}),
|
package/src/memory/db.ts
CHANGED
|
@@ -309,9 +309,31 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
|
|
|
309
309
|
this.writeVecRow("turn_summaries_vec", turnId, vec);
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Count rows still missing a vec entry, without touching an embedder.
|
|
314
|
+
* Lets callers skip the backfill (and its model init) in the steady state.
|
|
315
|
+
*/
|
|
316
|
+
vecBackfillPending(): { decisions: number; turnSummaries: number } {
|
|
317
|
+
if (!this.hasVecTables()) return { decisions: 0, turnSummaries: 0 };
|
|
318
|
+
try {
|
|
319
|
+
const d = this.db
|
|
320
|
+
.prepare("SELECT COUNT(*) AS n FROM decisions d WHERE NOT EXISTS (SELECT 1 FROM decisions_vec v WHERE v.rowid = d.id)")
|
|
321
|
+
.get() as { n: number };
|
|
322
|
+
const t = this.db
|
|
323
|
+
.prepare("SELECT COUNT(*) AS n FROM turn_summaries t WHERE NOT EXISTS (SELECT 1 FROM turn_summaries_vec v WHERE v.rowid = t.id)")
|
|
324
|
+
.get() as { n: number };
|
|
325
|
+
return { decisions: Number(d?.n ?? 0), turnSummaries: Number(t?.n ?? 0) };
|
|
326
|
+
} catch {
|
|
327
|
+
return { decisions: 0, turnSummaries: 0 };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
312
331
|
async backfillVecTables(embedder: Embedder): Promise<{ decisions: number; turnSummaries: number }> {
|
|
313
332
|
const counts = { decisions: 0, turnSummaries: 0 };
|
|
314
333
|
if (!this.hasVecTables()) return counts;
|
|
334
|
+
// Steady state: nothing missing — skip the embedder entirely.
|
|
335
|
+
const pending = this.vecBackfillPending();
|
|
336
|
+
if (pending.decisions + pending.turnSummaries === 0) return counts;
|
|
315
337
|
try {
|
|
316
338
|
this.ensureVecDim((await embedder.embedQuery("dimension probe")).length);
|
|
317
339
|
} catch {
|
|
@@ -348,6 +370,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
|
|
|
348
370
|
} catch {
|
|
349
371
|
return [];
|
|
350
372
|
}
|
|
373
|
+
await this.healVecTables(embedder, vec);
|
|
351
374
|
return this.vecSearch("decisions_vec", vec, k, maxDistance);
|
|
352
375
|
}
|
|
353
376
|
|
|
@@ -359,9 +382,32 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
|
|
|
359
382
|
} catch {
|
|
360
383
|
return [];
|
|
361
384
|
}
|
|
385
|
+
await this.healVecTables(embedder, vec);
|
|
362
386
|
return this.vecSearch("turn_summaries_vec", vec, k, maxDistance);
|
|
363
387
|
}
|
|
364
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Self-heal at the point of recall (upstream #137), covering both failure
|
|
391
|
+
* shapes a dim-mismatched or partially-empty vec table produces:
|
|
392
|
+
*
|
|
393
|
+
* 1. Tables built by another embedder: a dim-mismatched MATCH errors and
|
|
394
|
+
* silently returns nothing → recreate at the query's dim.
|
|
395
|
+
* 2. Rows missing for any reason (e.g. a mid-session `recordDecisionVec`
|
|
396
|
+
* wiped the tables via `ensureVecDim` after the engine's one-shot
|
|
397
|
+
* backfill already ran) → restore them here, where the embedder is
|
|
398
|
+
* already in hand.
|
|
399
|
+
*
|
|
400
|
+
* Steady state costs two COUNT queries; the embedder is only touched when
|
|
401
|
+
* something is actually wrong.
|
|
402
|
+
*/
|
|
403
|
+
private async healVecTables(embedder: Embedder, vec: readonly number[]): Promise<void> {
|
|
404
|
+
if (!this.hasVecTables()) return;
|
|
405
|
+
const declared = this.declaredVecDim();
|
|
406
|
+
if (declared !== null && declared !== vec.length) this.ensureVecDim(vec.length);
|
|
407
|
+
const pending = this.vecBackfillPending();
|
|
408
|
+
if (pending.decisions + pending.turnSummaries > 0) await this.backfillVecTables(embedder);
|
|
409
|
+
}
|
|
410
|
+
|
|
365
411
|
private vecSearch(table: string, vec: number[], k: number, maxDistance: number): number[] {
|
|
366
412
|
try {
|
|
367
413
|
const rows = this.db
|