diff --git a/packages/context/src/context/layers/filesystem.ts b/packages/context/src/context/layers/filesystem.ts index 59666bb6..bfd25cca 100644 --- a/packages/context/src/context/layers/filesystem.ts +++ b/packages/context/src/context/layers/filesystem.ts @@ -43,7 +43,11 @@ interface FileReferenceOptions { baseDir?: string; /** Slot position (defaults to Slot.RAG) */ slot?: number; - /** Model to use for priority scoring */ + /** + * Model for LLM-backed relevance scoring of referenced files. OPT-IN: when + * omitted, a fast path-heuristic scores references (no model call), keeping + * the user's turn free of an extra LLM round-trip per new #file reference. + */ scoringModel?: string; /** Maximum file size in bytes (defaults to 1MB) */ maxFileSize?: number; @@ -414,7 +418,8 @@ interface ScoreFileRelevanceParams { async function scoreFileRelevance(params: ScoreFileRelevanceParams): Promise { const { filePath, fileContent, userQuery, ctx, model } = params; - if (!ctx.callModel) { + // No scoring model configured (the default) or no model access: heuristic. + if (!model || !ctx.callModel) { const queryLower = userQuery.toLowerCase(); const pathLower = filePath.toLowerCase(); if (pathLower.includes(queryLower) || queryLower.includes(pathBasename(pathLower))) { @@ -480,7 +485,7 @@ function createFileReferenceRuntime(opts?: FileReferenceOptions): FileReferenceR return { baseDir, slot: opts?.slot ?? Slot.RAG, - scoringModel: opts?.scoringModel ?? 'anthropic/claude-haiku-4-5-20251001', + scoringModel: opts?.scoringModel?.trim() ?? '', readOpts: { maxFileSize, followSymlinks, @@ -983,7 +988,8 @@ async function renderFileReferenceDelta({ * * Behavior: * - Transforms references to markdown anchor links `[#path/to/file](#path-to-file)` - * - Scores file relevance using LLM when first referenced + * - Scores file relevance when first referenced — path-match heuristic by + * default; LLM scoring only when `scoringModel` is explicitly configured * - Injects file contents into context via recall(), ordered by priority * - Detects file changes on each new message, triggers immediate re-render * - Shows warning for deleted files @@ -1003,10 +1009,10 @@ export function filesystem(opts?: FileReferenceOptions): ContextLayer initFileReferenceState(runtime.baseDir), diff --git a/packages/core/test/context/filesystem.test.ts b/packages/core/test/context/filesystem.test.ts index 4371fef8..f551c24d 100644 --- a/packages/core/test/context/filesystem.test.ts +++ b/packages/core/test/context/filesystem.test.ts @@ -1235,9 +1235,66 @@ describe('filesystem', () => { }); describe('append-pipeline timeout headroom + parallel scoring (M8)', () => { - it('factory pins onItemAppend timeout at 30s (fs + LLM work cannot fit the 5s default)', () => { - const layer = filesystem(); - expect(layer.timeouts?.onItemAppend).toBe(30_000); + it('sizes the onItemAppend timeout to the work actually configured', () => { + // Default is heuristic-only scoring: fs reads fit a tighter budget than + // the 5s pipeline default allows, but need nothing like 30s. + expect(filesystem().timeouts?.onItemAppend).toBe(10_000); + // Opting into LLM scoring adds a model round-trip per new reference. + expect( + filesystem({ + scoringModel: 'anthropic/claude-haiku-4-5-20251001', + }).timeouts?.onItemAppend, + ).toBe(30_000); + }); + + it('never calls the model when no scoringModel is configured (heuristic default)', async () => { + await createTestFile('h1.ts', 'heuristic target'); + + const layer = filesystem({ + baseDir: tempDir, + }); + const store = createLayerStateStore(); + let modelCalls = 0; + const ctx = makeCtx({ + executionId: 'exec-heuristic', + callModel: async () => { + modelCalls++; + throw new Error('implicit model call'); + }, + }); + await initLayers({ + layers: [ + layer, + ], + ctx, + storage: makeStorage(), + store, + }); + + await runAppendPipeline({ + layers: [ + layer, + ], + items: [ + makeUserMessage('Look at #h1.ts'), + ], + ctx, + log: makeItemLog(), + store, + }); + + expect(modelCalls).toBe(0); + const state = store.get<{ + files: Map< + string, + { + priority: number; + } + >; + }>('exec-heuristic', 'filesystem'); + // Heuristic path: the query contains the basename, so the path-match + // branch scores 80 — no model was involved either way. + expect(state?.files.get('h1.ts')?.priority).toBe(80); }); it('scores multiple new references in parallel (wall time ≪ sequential), all tracked', async () => { @@ -1248,6 +1305,9 @@ describe('filesystem', () => { const layer = filesystem({ baseDir: tempDir, + // Required: LLM scoring is opt-in, so without a model the scoring path + // never runs and the wall-clock parallelism assertion below is vacuous. + scoringModel: 'test/scorer', }); const store = createLayerStateStore(); const ctx = makeCtx({ diff --git a/packages/web/content/docs/framework/context-layers/filesystem.mdx b/packages/web/content/docs/framework/context-layers/filesystem.mdx index 9309d754..d9d3616a 100644 --- a/packages/web/content/docs/framework/context-layers/filesystem.mdx +++ b/packages/web/content/docs/framework/context-layers/filesystem.mdx @@ -12,7 +12,7 @@ The filesystem layer watches user messages for `#path/to/file.ts`-style referenc - **Budget**: `'auto'` - **Re-render timing**: `immediate` - **[Placement](/docs/framework/context-layers#prompt-cache-anchoring)**: `'anchor'` — a large payload that changes one file at a time. The layer implements `renderDelta`, so a change publishes only the files that moved plus a note of any no longer referenced, instead of republishing the whole set -- **Hook timeouts**: `onItemAppend` `30000` ms — it reads files *and* runs an LLM scoring call per new reference, so the 5s pipeline default would silently drop the transform +- **Hook timeouts**: `onItemAppend` `10000` ms for file reads by default; `30000` ms when `scoringModel` is configured, since each new reference then also runs an LLM scoring call — the 5s pipeline default would silently drop the transform ## Usage @@ -33,7 +33,7 @@ A user message like `Look at #src/index.ts and #package.json` tracks both files; interface FilesystemOptions { baseDir?: string; // base for resolving relative paths (default: cwd) slot?: number; // default Slot.RAG (350) - scoringModel?: string; // model for priority scoring + scoringModel?: string; // opt-in: LLM relevance scoring (default: heuristic, no model call) maxFileSize?: number; // default 1 MB followSymlinks?: boolean; // default false (security) allowedExtensions?: string[]; // default: common code/text extensions @@ -44,7 +44,7 @@ interface FilesystemOptions { |---|---|---|---| | `baseDir` | `string` | `process.cwd()` | All references resolve relative to this directory | | `slot` | `number` | `350` | Position of injected file contents | -| `scoringModel` | `string` | a small fast model | Used for the relevance-scoring LLM call | +| `scoringModel` | `string` | none (heuristic) | Opt-in model for the relevance-scoring LLM call | | `maxFileSize` | `number` | `1048576` | Files larger than this are rejected with `FILE_TOO_LARGE` | | `followSymlinks` | `boolean` | `false` | When `false`, any symlinked path component is rejected | | `allowedExtensions` | `string[]` | code/text set | Extension (or exact filename, e.g. `Dockerfile`) allowlist | @@ -54,7 +54,7 @@ interface FilesystemOptions { ### onItemAppend 1. Scans incoming user messages for the `#path` pattern (requires a file-like shape — `#hashtag`, `#123`, `#region` are not matched). -2. New references are read and **priority-scored 0-100** via an LLM call against the current user query (path-match heuristic when no model is available; parse failures default to 50). Absolute paths are rejected — only relative paths under `baseDir` are allowed. +2. New references are read and **priority-scored 0-100**. By default a fast path-match heuristic scores references — no model call, so the user's turn carries no extra LLM round-trip per `#file` reference. Configure `scoringModel` to opt into LLM scoring against the current user query (parse failures default to 50). Absolute paths are rejected — only relative paths under `baseDir` are allowed. 3. Already-tracked files are re-read for **change detection** (content hash); changes, deletions, and reappearances update the tracked state and request an immediate re-render. 4. Reference text in the message is transformed into markdown anchor links. diff --git a/specs/12-builtin-context-layers.md b/specs/12-builtin-context-layers.md index e6517653..dc20fae0 100644 --- a/specs/12-builtin-context-layers.md +++ b/specs/12-builtin-context-layers.md @@ -342,6 +342,41 @@ const layers = [ --- +## `filesystem()` + +Tracks `#path/to/file` references in user messages and injects priority-scored file contents into the context. + +```typescript +interface FileReferenceOptions { + baseDir?: string; // base for resolving relative paths, default cwd + slot?: number; // default Slot.RAG + scoringModel?: string; // OPT-IN: LLM relevance scoring; default heuristic, no model call + maxFileSize?: number; // default 1MB + followSymlinks?: boolean; // default false (security) + allowedExtensions?: string[]; // default: common code/text extensions +} + +function filesystem(opts?: FileReferenceOptions): ContextLayer +``` + +| Property | Value | +|----------|-------| +| **id** | `'filesystem'` | +| **slot** | `Slot.RAG` (350) | +| **scope** | `'thread'` | +| **budget** | `'auto'` | +| **timeouts** | `{ onItemAppend: 10_000 }` for file reads; `30_000` when `scoringModel` is configured (each new reference then runs an LLM scoring call) | +| **hooks** | `init`, `onItemAppend`, `recall`, `renderDelta` | +| **placement** | `'anchor'` | + +**Behavior:** +- `onItemAppend`: Scans user messages for `#path` references, transforms them to markdown anchor links, reads new references in parallel, and re-reads tracked files for change detection (content hash). Absolute paths are rejected; reads are gated by containment, symlink rejection, an extension allowlist, and a size cap. +- **Scoring is heuristic by default** — a path/query match heuristic scores each new reference 0-100 with **no model call**. LLM-backed scoring against the current user query happens only when `scoringModel` is explicitly configured; this is a deliberate breaking change from the previous implicit small-model default, which silently issued an LLM call per new reference inside the append pipeline. +- `recall`: Renders a `# Referenced Files` developer message ordered by priority, budget-trimmed (head + tail with a truncation marker). +- `renderDelta`: Publishes only changed/dropped file blocks. + +--- + ## Custom Layer Examples (Informative) ### RAG Knowledge Base