feat(soulmap): trigram search, gitignore, soul_query pipeline, hash CAS, scan gating

Optimize the Soul Map for AI-driven work at scale (10k-50k+ files):

- Trigram substring index (trigram.ts): soul_grep narrows literal
  searches via a WITHOUT ROWID trigram table — 0.8s full scan to
  sub-ms candidate lookup at 50k files. Superset filter; ripgrep
  still does the real match (no regex/case/multiline loss).
- .gitignore + .soulforgeignore honored (ignore.ts, wraps `ignore`
  pkg): git path via ls-files --exclude-standard; non-git walk and
  .soulforgeignore via IgnoreMatcher.
- soul_query pipeline tool: one call composes search/find/filter/
  deps/outline/read/limit, threading a working file-set. Zero file
  I/O until a read stage.
- read hash + ifHash CAS: read emits hash: footer; edit_file/
  multi_edit/read accept ifHash to skip re-reads on iterative edits.
- navigate(references) annotates each call site with its enclosing
  function/class (getEnclosingSymbols).
- mtime+size scan gating (files.size_bytes): catches same-mtime
  content swaps, skips redundant re-index.
- atomic writes (atomicWriteFile): crash-safe tmp+rename on all
  platforms, routed through edit_file/multi_edit/undo.

Tests: trigram, ignore-matcher, soul-query, size-gating, repo-map-refs.
typecheck + lint + full suite green.

Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
PouiiroandSoulForge committed 2026-05-31 01:01:37 +02:00
1 parent 3cf1cd5fa7
commit 45854f4207
24 files changed
+1211 -73

No files matched your search

+3
View File
@@ -25,6 +25,7 @@
"@opentui/react": "0.2.16",
"ai": "^6.0.189",
"ghostty-opentui": "1.5.0",
"ignore": "^7.0.5",
"isbinaryfile": "6.0.0",
"jsonrepair": "^3.14.0",
"linkedom": "0.18.12",
@@ -463,6 +464,8 @@
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["[email protected]", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["[email protected]", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
+1
View File
@@ -98,6 +98,7 @@
"@opentui/react": "0.2.16",
"ai": "^6.0.189",
"ghostty-opentui": "1.5.0",
"ignore": "^7.0.5",
"isbinaryfile": "6.0.0",
"jsonrepair": "^3.14.0",
"linkedom": "0.18.12",
+65
View File
@@ -0,0 +1,65 @@
/**
* .gitignore / .soulforgeignore matcher for the repo-map walker.
*
* Wraps the `ignore` npm package — the gitignore-spec reference implementation
* (used by ESLint, Prettier, lint-staged). Pure-JS, zero-dep, cross-platform,
* and correct on the full spec: negations (`!keep`), `**` double-star, root
* anchoring (`/dist`), directory-only (`build/`), and segment globs. This is far
* more robust than a hand-rolled regex matcher and behaves identically to git
* across every language's project layout.
*
* Goal: prune build/generated/vendored trees custom to a given repo (on top of
* the hardcoded IGNORED_DIRS) so the Soul Map indexes only real source.
*/
import ignoreFactory from "ignore";
/**
* Extract the active (non-comment, non-blank) pattern lines from raw ignore text.
* Negations are preserved — the underlying engine honors them correctly.
*/
export function parseIgnore(text: string): string[] {
const out: string[] = [];
for (const line of text.split("\n")) {
const s = line.trim();
if (s === "" || s.startsWith("#")) continue;
out.push(s);
}
return out;
}
/** A compiled ignore matcher backed by the `ignore` package. */
export class IgnoreMatcher {
private ig = ignoreFactory();
readonly size: number;
constructor(patterns: string[]) {
if (patterns.length > 0) this.ig.add(patterns);
this.size = patterns.length;
}
/**
* True when a repo-relative POSIX path should be ignored.
*
* The `ignore` package keys directory-only rules (`build/`) off a trailing
* slash on the tested path, so we append one for directories.
* @param relPath repo-relative path, e.g. "src/foo.ts" or "build" (no leading slash)
* @param isDir whether the path is a directory
*/
ignores(relPath: string, isDir: boolean): boolean {
if (this.size === 0) return false;
let p = relPath.replace(/\\/g, "/");
if (p.startsWith("/")) p = p.slice(1);
if (p === "" || p === ".") return false;
return this.ig.ignores(isDir ? `${p}/` : p);
}
}
/** Build a matcher from raw ignore-file contents (concatenated). */
export function buildIgnoreMatcher(...texts: Array<string | null | undefined>): IgnoreMatcher {
const patterns: string[] = [];
for (const t of texts) {
if (t) patterns.push(...parseIgnore(t));
}
return new IgnoreMatcher(patterns);
}
+31 -10
View File
@@ -1,8 +1,9 @@
import { readdir, stat } from "node:fs/promises";
import { readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { join, relative, resolve } from "node:path";
import { IGNORED_DIRS } from "../context/file-tree.js";
import { isForbidden } from "../security/forbidden.js";
import { buildIgnoreMatcher, type IgnoreMatcher } from "./ignore.js";
import type { Language, SymbolKind } from "./types.js";
import { BARE_FILENAME_TO_LANGUAGE, detectLanguageFromPath, EXT_TO_LANGUAGE } from "./types.js";
@@ -340,10 +341,15 @@ export async function collectFiles(dir: string, depth = 0): Promise<CollectResul
"Opened in home directory or system root — no files indexed. Open a project directory instead.",
};
}
// Fallback walk — shared accumulator so the 60s timeout returns partial results
// Fallback walk (non-git repos): git ls-files isn't available, so honor
// .gitignore + .soulforgeignore ourselves on top of the hardcoded IGNORED_DIRS.
const ignore = buildIgnoreMatcher(
await readIgnoreFile(join(dir, ".gitignore")),
await readIgnoreFile(join(dir, ".soulforgeignore")),
);
const collected: CollectedFile[] = [];
let hitCap = false;
const walkDone = collectFilesWalk(dir, depth, undefined, collected).then(() => {
const walkDone = collectFilesWalk(dir, depth, undefined, collected, dir, ignore).then(() => {
hitCap = collected.length >= WALK_FILE_CAP;
});
const timedOut = await Promise.race([
@@ -389,7 +395,8 @@ async function collectFilesViaGit(dir: string): Promise<CollectedFile[] | null>
if (isForbidden(fullPath)) continue;
try {
const s = await stat(fullPath);
if (s.size < MAX_FILE_SIZE) files.push({ path: fullPath, mtimeMs: s.mtimeMs });
if (s.size < MAX_FILE_SIZE)
files.push({ path: fullPath, mtimeMs: s.mtimeMs, size: s.size });
} catch {}
if (files.length % 50 === 0) await new Promise<void>((r) => setTimeout(r, 0));
}
@@ -403,12 +410,23 @@ async function collectFilesViaGit(dir: string): Promise<CollectedFile[] | null>
// Prevents scanning millions of files when opened in $HOME or a monorepo root.
const WALK_FILE_CAP = 50_000;
/** Read an ignore file, returning null if absent/unreadable. */
async function readIgnoreFile(path: string): Promise<string | null> {
try {
return await readFile(path, "utf-8");
} catch {
return null;
}
}
/** Fallback: manual directory walk for non-git repos. */
async function collectFilesWalk(
dir: string,
depth: number,
counter?: { n: number },
out?: CollectedFile[],
counter: { n: number } | undefined,
out: CollectedFile[] | undefined,
rootDir: string,
ignore: IgnoreMatcher,
): Promise<CollectedFile[]> {
if (depth > MAX_DEPTH) return [];
const ctx = counter ?? { n: 0 };
@@ -422,17 +440,19 @@ async function collectFilesWalk(
if (!entry.isFile() || !isIndexablePath(entry.name)) continue;
}
const fullPath = join(dir, entry.name);
const rel = relative(rootDir, fullPath);
if (entry.isDirectory()) {
if (!IGNORED_DIRS.has(entry.name)) {
await collectFilesWalk(fullPath, depth + 1, ctx, files);
if (!IGNORED_DIRS.has(entry.name) && !ignore.ignores(rel, true)) {
await collectFilesWalk(fullPath, depth + 1, ctx, files, rootDir, ignore);
}
} else if (entry.isFile()) {
if (isForbidden(fullPath)) continue;
if (ignore.ignores(rel, false)) continue;
if (isIndexablePath(entry.name)) {
try {
const s = await stat(fullPath);
if (s.size < MAX_FILE_SIZE) {
files.push({ path: fullPath, mtimeMs: s.mtimeMs });
files.push({ path: fullPath, mtimeMs: s.mtimeMs, size: s.size });
ctx.n++;
}
} catch {}
@@ -447,6 +467,7 @@ async function collectFilesWalk(
interface CollectedFile {
path: string;
mtimeMs: number;
size: number;
}
const MAX_FILE_SIZE = 500_000;
+158 -31
View File
@@ -33,9 +33,18 @@ import {
PAGERANK_DAMPING,
PAGERANK_ITERATIONS,
} from "./repo-map-utils.js";
import {
extractContentTrigrams,
extractPatternTrigrams,
MAX_POSTINGS_PER_TRIGRAM,
} from "./trigram.js";
import type { Language, SymbolKind } from "./types.js";
import { detectLanguageFromPath } from "./types.js";
/** Files larger than this are excluded from the trigram index (minified/generated).
* Matches ripgrep's --max-filesize=256K cap used by soul_grep. */
const TRIGRAM_MAX_FILE_BYTES = 256 * 1024;
interface FileRow {
id: number;
path: string;
@@ -314,6 +323,24 @@ export class RepoMap {
CREATE INDEX IF NOT EXISTS idx_calls_callee_file ON calls(callee_file_id);
`);
// Trigram substring index — packed u24 trigram -> file_id postings.
// WITHOUT ROWID keeps the composite-PK table compact; the index is the table.
// Enables sub-millisecond candidate narrowing for soul_grep at 10k-50k+ files.
this.db.run(`
CREATE TABLE IF NOT EXISTS trigrams (
trigram INTEGER NOT NULL,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
PRIMARY KEY (trigram, file_id)
) WITHOUT ROWID;
`);
this.db.run("CREATE INDEX IF NOT EXISTS idx_trigrams_file ON trigrams(file_id)");
// Migration: size_bytes enables (mtime,size) re-index gating — skip re-hashing
// unchanged files on incremental rescans. 0 on legacy rows forces one re-index.
try {
this.db.run("ALTER TABLE files ADD COLUMN size_bytes INTEGER NOT NULL DEFAULT 0");
} catch {}
this.migrateSemanticSource();
this.migrateSemanticNoCascade();
this.backfillSummaryPaths();
@@ -509,26 +536,45 @@ export class RepoMap {
files = allFiles;
}
const existingFiles = new Map<string, { id: number; mtime_ms: number }>();
const existingFiles = new Map<string, { id: number; mtime_ms: number; size_bytes: number }>();
for (const row of this.db
.query<{ id: number; path: string; mtime_ms: number }, []>(
"SELECT id, path, mtime_ms FROM files",
.query<{ id: number; path: string; mtime_ms: number; size_bytes: number }, []>(
"SELECT id, path, mtime_ms, size_bytes FROM files",
)
.all()) {
existingFiles.set(row.path, { id: row.id, mtime_ms: row.mtime_ms });
existingFiles.set(row.path, {
id: row.id,
mtime_ms: row.mtime_ms,
size_bytes: row.size_bytes,
});
}
const currentPaths = new Set<string>();
const toIndex: { absPath: string; relPath: string; mtime: number; language: Language }[] = [];
const toIndex: {
absPath: string;
relPath: string;
mtime: number;
language: Language;
size: number;
}[] = [];
for (const file of files) {
const relPath = relative(this.cwd, file.path);
currentPaths.add(relPath);
const existing = existingFiles.get(relPath);
if (existing && existing.mtime_ms === file.mtimeMs) continue;
// Gate on (mtime, size): unchanged on both → skip re-index entirely.
// size catches same-mtime content swaps; legacy rows (size_bytes=0) re-index once.
if (existing && existing.mtime_ms === file.mtimeMs && existing.size_bytes === file.size)
continue;
const language = detectLanguageFromPath(file.path);
toIndex.push({ absPath: file.path, relPath, mtime: file.mtimeMs, language });
toIndex.push({
absPath: file.path,
relPath,
mtime: file.mtimeMs,
language,
size: file.size,
});
}
const stale = [...existingFiles.keys()].filter((p) => !currentPaths.has(p));
@@ -555,7 +601,13 @@ export class RepoMap {
const file = toIndex[i];
if (file) {
try {
await this.indexFile(file.absPath, file.relPath, file.mtime, file.language);
await this.indexFile(
file.absPath,
file.relPath,
file.mtime,
file.language,
file.size,
);
} catch (err) {
this.indexErrors++;
if (this.indexErrors <= 5) {
@@ -619,9 +671,9 @@ export class RepoMap {
}
}
private async applyFileCap(
files: Array<{ path: string; mtimeMs: number }>,
): Promise<Array<{ path: string; mtimeMs: number }>> {
private async applyFileCap<T extends { path: string; mtimeMs: number }>(
files: T[],
): Promise<T[]> {
const gitRecency = new Map<string, number>();
if (this.detectGit()) {
try {
@@ -646,19 +698,16 @@ export class RepoMap {
}
}
const sorted = files
.map((f) => ({
path: f.path,
mtimeMs: f.mtimeMs,
gitRank: gitRecency.get(relative(this.cwd, f.path)),
}))
.sort((a, b) => {
const aGit = a.gitRank !== undefined;
const bGit = b.gitRank !== undefined;
if (aGit !== bGit) return aGit ? -1 : 1;
if (aGit && bGit) return (a.gitRank as number) - (b.gitRank as number);
return b.mtimeMs - a.mtimeMs;
});
const rankFor = (f: T) => gitRecency.get(relative(this.cwd, f.path));
const sorted = [...files].sort((a, b) => {
const ra = rankFor(a);
const rb = rankFor(b);
const aGit = ra !== undefined;
const bGit = rb !== undefined;
if (aGit !== bGit) return aGit ? -1 : 1;
if (aGit && bGit) return (ra as number) - (rb as number);
return b.mtimeMs - a.mtimeMs;
});
return sorted.slice(0, this.maxFiles);
}
@@ -687,6 +736,7 @@ export class RepoMap {
relPath: string,
mtime: number,
language: Language,
size = 0,
): Promise<void> {
const existing = this.db
.query<{ id: number }, [string]>("SELECT id FROM files WHERE path = ?")
@@ -706,6 +756,7 @@ export class RepoMap {
this.db.query("DELETE FROM shape_hashes WHERE file_id = ?").run(existing.id);
this.db.query("DELETE FROM token_signatures WHERE file_id = ?").run(existing.id);
this.db.query("DELETE FROM token_fragments WHERE file_id = ?").run(existing.id);
this.db.query("DELETE FROM trigrams WHERE file_id = ?").run(existing.id);
this.db
.query("DELETE FROM edges WHERE source_file_id = ? OR target_file_id = ?")
.run(existing.id, existing.id);
@@ -744,15 +795,15 @@ export class RepoMap {
if (existing) {
this.db
.query(
"UPDATE files SET mtime_ms = ?, language = ?, line_count = ?, symbol_count = ? WHERE id = ?",
"UPDATE files SET mtime_ms = ?, language = ?, line_count = ?, symbol_count = ?, size_bytes = ? WHERE id = ?",
)
.run(mtime, language, lineCount, symbolCount, existing.id);
.run(mtime, language, lineCount, symbolCount, size, existing.id);
} else {
this.db
.query(
"INSERT INTO files (path, mtime_ms, language, line_count, symbol_count) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO files (path, mtime_ms, language, line_count, symbol_count, size_bytes) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(relPath, mtime, language, lineCount, symbolCount);
.run(relPath, mtime, language, lineCount, symbolCount, size);
}
const fileId =
@@ -984,6 +1035,13 @@ export class RepoMap {
}
}
// Trigram substring index — covers ALL files (code + non-code) so soul_grep
// can narrow literal searches at scale. Skips very large files (minified /
// generated) that bloat the index and that rg already skips via --max-filesize.
if (content.length <= TRIGRAM_MAX_FILE_BYTES) {
this.indexTrigrams(fileId, content);
}
// Only extract identifiers from code files — non-code files (JSON, YAML, MD, etc.)
// produce noise refs ("name", "version", "scripts") that create bogus edges.
if (!NON_CODE_LANGUAGES.has(language)) {
@@ -2806,7 +2864,7 @@ export class RepoMap {
for (const [absPath, { relPath, language }] of batch) {
try {
const st = await statAsync(absPath);
this.indexFile(absPath, relPath, st.mtimeMs, language);
this.indexFile(absPath, relPath, st.mtimeMs, language, st.size);
} catch (e) {
this.onError?.(
`reindex failed for ${relPath}: ${e instanceof Error ? e.message : String(e)}`,
@@ -2832,13 +2890,16 @@ export class RepoMap {
recheckModifiedFiles(): void {
if (!this.ready) return;
const files = this.db
.query<{ path: string; mtime_ms: number }, []>("SELECT path, mtime_ms FROM files")
.query<{ path: string; mtime_ms: number; size_bytes: number }, []>(
"SELECT path, mtime_ms, size_bytes FROM files",
)
.all();
for (const f of files) {
const absPath = join(this.cwd, f.path);
try {
const st = statSync(absPath);
if (st.mtimeMs !== f.mtime_ms) {
// Re-index when mtime OR size changed (size catches same-mtime edits).
if (st.mtimeMs !== f.mtime_ms || st.size !== f.size_bytes) {
this.onFileChanged(absPath);
}
} catch {
@@ -5164,4 +5225,70 @@ export class RepoMap {
return null;
}
}
/**
* All symbols in a file that carry a line range, regardless of export status
* or kind — used to find the enclosing scope of an arbitrary line (e.g. a
* reference call site). Unlike getFileSymbolRanges this includes non-exported
* functions, methods, and variable/arrow bindings.
*/
getEnclosingSymbols(relPath: string): Array<{
name: string;
kind: string;
line: number;
endLine: number;
}> {
return this.db
.query<{ name: string; kind: string; line: number; end_line: number }, [string]>(
`SELECT s.name, s.kind, s.line, s.end_line
FROM symbols s JOIN files f ON f.id = s.file_id
WHERE f.path = ? AND s.end_line >= s.line
ORDER BY s.line
LIMIT 2000`,
)
.all(relPath)
.map((r) => ({ name: r.name, kind: r.kind, line: r.line, endLine: r.end_line }));
}
/**
* Populate the trigram index for one file. Caps the number of distinct
* trigrams stored per file to bound index size; common trigrams that already
* have MAX_POSTINGS_PER_TRIGRAM files are skipped (poor selectivity anyway).
*/
private indexTrigrams(fileId: number, content: string): void {
const trigrams = extractContentTrigrams(content);
if (trigrams.size === 0) return;
const insert = this.db.prepare(
"INSERT OR IGNORE INTO trigrams (trigram, file_id) VALUES (?, ?)",
);
const countFor = this.db.prepare<{ c: number }, [number]>(
"SELECT COUNT(*) AS c FROM trigrams WHERE trigram = ?",
);
const tx = this.db.transaction(() => {
for (const tri of trigrams) {
const existing = countFor.get(tri)?.c ?? 0;
if (existing >= MAX_POSTINGS_PER_TRIGRAM) continue;
insert.run(tri, fileId);
}
});
tx();
}
/**
* Return candidate file paths that may contain the given literal pattern,
* by intersecting trigram posting lists. Returns null when the pattern is
* unsuitable for trigram filtering (caller should fall back to a full scan).
* The result is a SUPERSET — the caller still runs the real matcher.
*/
searchTrigramCandidates(pattern: string, limit = 5000): string[] | null {
const tris = extractPatternTrigrams(pattern);
if (!tris || tris.length === 0) return null;
// Intersect posting lists, smallest-first for early termination.
// Build a single SQL with INTERSECT over each trigram's file set.
const selects = tris.map(() => "SELECT file_id FROM trigrams WHERE trigram = ?");
const sql = `SELECT f.path FROM files f WHERE f.id IN (${selects.join(" INTERSECT ")}) LIMIT ?`;
const rows = this.db.query<{ path: string }, (number | number)[]>(sql).all(...tris, limit);
return rows.map((r) => r.path);
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Trigram substring index helpers — shared by the indexer (RepoMap.indexFile)
* and the query path (soul_grep candidate narrowing).
*
* A trigram is 3 consecutive bytes packed into a u24 integer. The index maps
* each trigram → the set of file_ids that contain it. To find files that *might*
* contain a literal substring, we extract its trigrams and intersect their
* posting lists. The result is a SUPERSET of true matches — ripgrep still does
* the real match, so there is zero regex/case/multiline correctness loss.
*
* Trigrams are case-normalized to lowercase so a case-insensitive ripgrep pass
* still hits the right candidates. The actual matcher decides case sensitivity.
*/
/** Max distinct files a single trigram may point at. Common trigrams (e.g. "the",
* "ion") appear nearly everywhere and make terrible filters — cap their posting
* lists so the index stays small and intersections stay cheap. */
export const MAX_POSTINGS_PER_TRIGRAM = 512;
/** Minimum literal run length needed to extract trigrams. */
export const MIN_TRIGRAM_LEN = 3;
/** Pack 3 lowercased bytes into a u24. */
function pack(a: number, b: number, c: number): number {
return (a << 16) | (b << 8) | c;
}
/** Lowercase a byte (ASCII only — non-ASCII passes through unchanged). */
function lower(byte: number): number {
return byte >= 65 && byte <= 90 ? byte + 32 : byte;
}
/**
* Extract the set of distinct trigrams from arbitrary text (file content).
* Skips trigrams that are entirely whitespace — they are noise and explode
* posting lists. Returns packed u24 integers.
*/
export function extractContentTrigrams(content: string): Set<number> {
const out = new Set<number>();
const len = content.length;
if (len < MIN_TRIGRAM_LEN) return out;
for (let i = 0; i + 2 < len; i++) {
const a = lower(content.charCodeAt(i));
const b = lower(content.charCodeAt(i + 1));
const c = lower(content.charCodeAt(i + 2));
// Skip all-whitespace trigrams (space, tab, newline, CR).
if (isWs(a) && isWs(b) && isWs(c)) continue;
// Skip trigrams containing chars outside the byte range we pack (astral / >255).
if (a > 255 || b > 255 || c > 255) continue;
out.add(pack(a, b, c));
}
return out;
}
function isWs(byte: number): boolean {
return byte === 32 || byte === 9 || byte === 10 || byte === 13;
}
/**
* Extract trigrams from a search pattern's longest literal run.
* Returns null when the pattern is unsuitable for trigram filtering:
* - shorter than MIN_TRIGRAM_LEN after stripping regex metacharacters, or
* - the literal portion is too short to be selective.
*
* We take the LONGEST contiguous literal substring (no regex metachars) and
* derive trigrams from it. If the whole pattern is literal, that's the pattern.
*/
export function extractPatternTrigrams(pattern: string): number[] | null {
const literal = longestLiteralRun(pattern);
if (literal.length < MIN_TRIGRAM_LEN) return null;
const set = extractContentTrigrams(literal);
if (set.size === 0) return null;
return [...set];
}
/** Regex metacharacters that break a literal run. */
const META = new Set([".", "*", "+", "?", "(", ")", "[", "]", "{", "}", "|", "^", "$", "\\"]);
/** Longest contiguous run of non-metacharacter, non-whitespace chars. */
function longestLiteralRun(pattern: string): string {
let best = "";
let cur = "";
for (const ch of pattern) {
if (META.has(ch) || ch === " " || ch === "\t" || ch === "\n") {
if (cur.length > best.length) best = cur;
cur = "";
} else {
cur += ch;
}
}
if (cur.length > best.length) best = cur;
return best;
}
+21
View File
@@ -360,3 +360,24 @@ export function matchGlob(input: string, pattern: string): boolean {
const flags = IS_WIN ? "si" : "s";
return new RegExp(`^${escaped}$`, flags).test(str);
}
/**
* Crash-safe file write: write to a sibling temp file, then atomically rename
* over the target via {@link safeRename}. A crash mid-write leaves the original
* intact (the temp file is orphaned, never a half-written target).
*
* Temp file lives in the SAME directory as the target so the rename stays on one
* filesystem (cross-device rename → EXDEV). Works on mac/linux/windows.
*/
export async function atomicWriteFile(targetPath: string, data: string): Promise<void> {
const { writeFile, unlink } = await import("node:fs/promises");
const tmpPath = `${targetPath}.sf-${process.pid.toString(36)}-${Date.now().toString(36)}.tmp`;
try {
await writeFile(tmpPath, data, "utf-8");
safeRename(tmpPath, targetPath);
} catch (err) {
try {
await unlink(tmpPath);
} catch {}
throw err;
}
}
+52 -12
View File
@@ -1,9 +1,10 @@
import { mkdir, readFile, stat as statAsync, writeFile } from "node:fs/promises";
import { mkdir, readFile, stat as statAsync } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { ToolResult } from "../../types";
import { analyzeFile } from "../analysis/complexity";
import { markToolWrite, reloadBuffer } from "../editor/instance";
import { memoryHintComposite } from "../memory/hints.js";
import { atomicWriteFile } from "../platform/index.js";
import { isForbidden } from "../security/forbidden.js";
import { displayPath } from "../utils/path-display.js";
import { pushEdit } from "./edit-stack.js";
@@ -15,6 +16,7 @@ import {
countOccurrences,
startPreEditDiagnostics,
} from "./post-edit-helpers.js";
import { contentHash } from "./tool-utils.js";
import { consumeAstEditNudge } from "./ts-project-detect.js";
interface EditFileArgs {
@@ -22,6 +24,7 @@ interface EditFileArgs {
oldString: string;
newString: string;
lineStart?: number;
ifHash?: string;
tabId?: string;
}
@@ -160,22 +163,34 @@ async function applyEdit(
editLine: number,
label: string,
tabId?: string,
ifHash?: string,
): Promise<ToolResult> {
const beforeMetrics = analyzeFile(content);
const afterMetrics = analyzeFile(updated);
const diagsPromise = startPreEditDiagnostics(filePath);
// CAS: verify file hasn't been modified since we read it (prevents concurrent edit races)
const currentOnDisk = await readFile(filePath, "utf-8");
if (currentOnDisk !== content) {
const msg = "File was modified concurrently since last read. Re-read and retry.";
return { success: false, output: msg, error: "concurrent modification" };
// CAS: verify file hasn't been modified since we read it (prevents concurrent edit races).
// When ifHash is supplied we trust the caller's last-read hash against `content` and
// skip the extra disk read — `content` was already read this call for the match.
if (ifHash) {
if (contentHash(content) !== ifHash) {
const msg =
"ifHash mismatch — file changed since your last read. Re-read and retry with the new hash.";
return { success: false, output: msg, error: "concurrent modification" };
}
} else {
const currentOnDisk = await readFile(filePath, "utf-8");
if (currentOnDisk !== content) {
const msg = "File was modified concurrently since last read. Re-read and retry.";
return { success: false, output: msg, error: "concurrent modification" };
}
}
// Write file immediately — don't wait for diagnostics
// Write file immediately — don't wait for diagnostics.
// atomicWriteFile = tmp+rename so a crash mid-write never corrupts the target.
pushEdit(filePath, content, updated, tabId);
await writeFile(filePath, updated, "utf-8");
await atomicWriteFile(filePath, updated);
markToolWrite(filePath);
emitFileEdited(filePath, updated);
@@ -251,7 +266,7 @@ export const editFileTool = {
dirCreated = true;
}
await mkdir(dir, { recursive: true });
await writeFile(filePath, newStr, "utf-8");
await atomicWriteFile(filePath, newStr);
markToolWrite(filePath);
emitFileEdited(filePath, newStr);
const openedInEditor = await reloadBuffer(filePath);
@@ -312,7 +327,15 @@ export const editFileTool = {
const before = lines.slice(0, range.start);
const after = lines.slice(range.end);
const updated = [...before, ...newLines, ...after].join("\n");
return applyEdit(filePath, content, updated, args.lineStart, label, args.tabId);
return applyEdit(
filePath,
content,
updated,
args.lineStart,
label,
args.tabId,
args.ifHash,
);
}
// Try fuzzy match against the range content
@@ -321,7 +344,15 @@ export const editFileTool = {
const before = lines.slice(0, range.start);
const after = lines.slice(range.end);
const updated = [...before, ...rangeFixed.newStr.split("\n"), ...after].join("\n");
return applyEdit(filePath, content, updated, args.lineStart, label, args.tabId);
return applyEdit(
filePath,
content,
updated,
args.lineStart,
label,
args.tabId,
args.ifHash,
);
}
// oldString doesn't match the range — FAIL instead of blindly applying.
@@ -352,6 +383,7 @@ export const editFileTool = {
matchLine,
" [line range invalid, used string match]",
args.tabId,
args.ifHash,
);
}
}
@@ -391,7 +423,15 @@ export const editFileTool = {
const editLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1;
const updated =
content.slice(0, matchIdx) + resolvedNew + content.slice(matchIdx + resolvedOld.length);
const result = await applyEdit(filePath, content, updated, editLine, "", args.tabId);
const result = await applyEdit(
filePath,
content,
updated,
editLine,
"",
args.tabId,
args.ifHash,
);
if (result.success) {
result.output +=
"\n! lineStart not provided — pass lineStart from read output to make edits escape-proof.";
+3 -2
View File
@@ -1,7 +1,8 @@
import { stat as statAsync, writeFile } from "node:fs/promises";
import { stat as statAsync } from "node:fs/promises";
import { resolve } from "node:path";
import type { ToolResult } from "../../types/index.js";
import { markToolWrite, readBufferContent, reloadBuffer } from "../editor/instance.js";
import { atomicWriteFile } from "../platform/index.js";
import { isForbidden } from "../security/forbidden.js";
import { emitFileEdited } from "./file-events.js";
@@ -210,7 +211,7 @@ export const undoEditTool = {
return { success: false, output: msg, error: msg };
}
await writeFile(filePath, restored, "utf-8");
await atomicWriteFile(filePath, restored);
markToolWrite(filePath);
emitFileEdited(filePath, restored);
+66 -4
View File
@@ -55,6 +55,7 @@ import { soulAnalyzeTool } from "./soul-analyze.js";
import { soulFindTool } from "./soul-find.js";
import { soulGrepTool } from "./soul-grep.js";
import { soulImpactTool } from "./soul-impact.js";
import { soulQueryTool } from "./soul-query.js";
import { taskListTool } from "./task-list.js";
import { buildWebSearchTool } from "./web-search";
@@ -166,6 +167,10 @@ const readFileSpec = z.object({
).describe("Line ranges to read. Omit for full file."),
target: optStr().describe("Symbol type to extract (AST-based). Omit for raw read."),
name: optStr().describe("Symbol name (required when target is set, except scope)"),
ifHash: optStr().describe(
"Skip the body if the file still hashes to this value. Pass the `hash:` from a prior read — " +
"returns `unchanged:<hash>` instead of re-sending unchanged content.",
),
});
/** Coerce common weak-model mistakes for the `files` param:
@@ -450,6 +455,7 @@ export function buildTools(
ranges?: Array<{ start: number; end: number }>;
target?: string;
name?: string;
ifHash?: string;
};
let fileSpecs: FileSpec[];
@@ -475,8 +481,8 @@ export function buildTools(
const hasRanges = spec.ranges && spec.ranges.length > 0;
const isFullRead = !hasRanges && !spec.target;
// Cache: skip re-reads
if (!args.fresh && fullReadCache.has(normPath)) {
// Cache: skip re-reads (ifHash callers want an explicit CAS check)
if (!args.fresh && !spec.ifHash && fullReadCache.has(normPath)) {
if (isFullRead) {
return {
path: fp,
@@ -528,6 +534,7 @@ export function buildTools(
const result = await readFileTool.execute({
path: fp,
...(spec.target ? { target: spec.target, name: spec.name } : {}),
...(spec.ifHash ? { ifHash: spec.ifHash } : {}),
tabId: opts?.tabId,
});
if (!result.success && isFullRead) fullReadCache.delete(normPath);
@@ -568,6 +575,10 @@ export function buildTools(
"1-indexed start line from your last read output. " +
"The range is derived from oldString line count — lineStart anchors where to look.",
),
ifHash: optStr().describe(
"Optional CAS guard — pass the `hash:` from your last read. Rejects with `concurrent modification` " +
"if the file changed, and skips the safety re-read when it matches.",
),
}),
execute: deferExecute(async (args) => {
const gate = await gateOutsideCwd("edit_file", resolve(args.path));
@@ -632,6 +643,10 @@ export function buildTools(
),
)
.describe("Array of edits to apply atomically"),
ifHash: optStr().describe(
"Optional CAS guard — pass the `hash:` from your last read. Rejects with `concurrent modification` " +
"if the file changed, and skips the safety re-read when it matches.",
),
}),
execute: deferExecute(async (args) => {
const gate = await gateOutsideCwd("multi_edit", resolve(args.path));
@@ -864,6 +879,53 @@ export function buildTools(
}),
}),
soul_query: tool({
...TEXT_OUTPUT,
providerOptions: progProviderOpts,
description: soulQueryTool.description,
inputSchema: z.object({
pipeline: z
.preprocess(
coerceJsonArray,
z.array(
z.object({
op: z
.enum(["search", "find", "filter", "deps", "outline", "read", "limit"])
.describe("Pipeline stage operation"),
pattern: optStr().describe("Literal substring (op:search)"),
query: optStr().describe("Fuzzy query (op:find)"),
ext: optStr().describe("Extension filter e.g. '.ts' (op:filter)"),
pathContains: optStr().describe("Path substring filter (op:filter)"),
direction: z
.enum(["imports", "imported_by"])
.nullable()
.optional()
.transform(nullToUndef)
.describe("Dependency direction (op:deps)"),
n: z
.preprocess(coerceInt, z.number())
.nullable()
.optional()
.transform(nullToUndef)
.describe("Cap count (op:limit)"),
ranges: z
.object({ start: z.number(), end: z.number() })
.nullable()
.optional()
.transform(nullToUndef)
.describe("Line range (op:read)"),
}),
),
)
.describe("Ordered pipeline stages — each narrows a working file-set for the next."),
}),
execute: deferExecute((args) => {
resetReadCounter();
// biome-ignore lint/suspicious/noExplicitAny: pipeline ops form a discriminated union at runtime
return soulQueryTool.createExecute(opts?.repoMap)(args as any);
}),
}),
soul_analyze: tool({
...TEXT_OUTPUT,
providerOptions: progProviderOpts,
@@ -1239,7 +1301,7 @@ export function buildTools(
"search_symbols",
])
.describe(
"definition=returns file:line of definition, references=returns all file:line usages, " +
"definition=returns file:line of definition, references=returns file:line usages each annotated with enclosing function/class, " +
"call_hierarchy=returns callers/callees with file:line, implementation=returns concrete implementors, " +
"type_hierarchy=returns super/subtypes, symbols/imports/exports=returns file contents, " +
"workspace_symbols/search_symbols=returns matching symbols across all files",
@@ -2115,7 +2177,7 @@ export function buildSubagentExploreTools(opts?: {
"search_symbols",
])
.describe(
"definition=returns file:line of definition, references=returns all file:line usages, " +
"definition=returns file:line of definition, references=returns file:line usages each annotated with enclosing function/class, " +
"call_hierarchy=returns callers/callees with file:line, implementation=returns concrete implementors, " +
"type_hierarchy=returns super/subtypes, symbols/imports/exports=returns file contents, " +
"workspace_symbols/search_symbols=returns matching symbols across all files",
+29 -8
View File
@@ -1,9 +1,10 @@
import { readFile, stat as statAsync, writeFile } from "node:fs/promises";
import { readFile, stat as statAsync } from "node:fs/promises";
import { resolve } from "node:path";
import type { ToolResult } from "../../types/index.js";
import { analyzeFile } from "../analysis/complexity.js";
import { markToolWrite, reloadBuffer } from "../editor/instance.js";
import { memoryHintComposite } from "../memory/hints.js";
import { atomicWriteFile } from "../platform/index.js";
import { isForbidden } from "../security/forbidden.js";
import { displayPath } from "../utils/path-display.js";
import { buildRichEditError, fuzzyWhitespaceMatch } from "./edit-file.js";
@@ -16,6 +17,7 @@ import {
countOccurrences,
startPreEditDiagnostics,
} from "./post-edit-helpers.js";
import { contentHash } from "./tool-utils.js";
import { consumeAstEditNudge } from "./ts-project-detect.js";
interface EditEntry {
@@ -27,6 +29,7 @@ interface EditEntry {
interface MultiEditArgs {
path: string;
edits: EditEntry[];
ifHash?: string;
tabId?: string;
}
@@ -186,18 +189,36 @@ export const multiEditTool = {
const diagsPromise = startPreEditDiagnostics(filePath);
// CAS: verify file hasn't been modified since we read it
const currentOnDisk = await readFile(filePath, "utf-8");
if (currentOnDisk !== originalContent) {
const msg =
"File was modified concurrently since last read. NO edits were applied (atomic rollback). Re-read and retry ALL edits.";
return { success: false, output: msg, error: "concurrent modification (0 edits applied)" };
// CAS: verify file hasn't been modified since we read it.
// ifHash trusts the caller's last-read hash against originalContent (read this call) —
// skips the extra disk read.
if (args.ifHash) {
if (contentHash(originalContent) !== args.ifHash) {
const msg =
"ifHash mismatch — file changed since your last read. NO edits were applied (atomic rollback). Re-read and retry ALL edits with the new hash.";
return {
success: false,
output: msg,
error: "concurrent modification (0 edits applied)",
};
}
} else {
const currentOnDisk = await readFile(filePath, "utf-8");
if (currentOnDisk !== originalContent) {
const msg =
"File was modified concurrently since last read. NO edits were applied (atomic rollback). Re-read and retry ALL edits.";
return {
success: false,
output: msg,
error: "concurrent modification (0 edits applied)",
};
}
}
// Push single undo entry for the entire batch — write immediately
pushEdit(filePath, originalContent, content, args.tabId);
await writeFile(filePath, content, "utf-8");
await atomicWriteFile(filePath, content);
markToolWrite(filePath);
emitFileEdited(filePath, content);
+64 -1
View File
@@ -377,7 +377,13 @@ export const navigateTool = {
refs.length > MAX_REFS
? `\n+ ${String(refs.length - MAX_REFS)} more — narrow your query`
: "";
const refsOutput = `References to '${symbol}' (${String(refs.length)}):\n${capped.map(formatLocation).join("\n")}${overflow}`;
// Fused references + scope: annotate each hit with its enclosing
// function/class so the agent doesn't follow up with a per-site
// definition/outline lookup. One symbol-range query per file (soul map
// cache, zero file I/O), guarded to ≤25 files to bound work.
const refLines = await formatReferencesWithScope(capped, client);
const refsOutput = `References to '${symbol}' (${String(refs.length)}):\n${refLines}${overflow}`;
const refsAnnotation = await buildAnnotation(resolvedFile, repoMap);
return {
success: true,
@@ -669,3 +675,60 @@ export const navigateTool = {
}
},
};
/**
* Format reference locations, annotating each with its enclosing function/class.
* Groups hits by file and does one symbol-range lookup per file (soul-map cached).
* Falls back to plain `file:line` when scope data is unavailable or too many files.
*/
async function formatReferencesWithScope(
refs: SourceLocation[],
client: IntelligenceClient | null,
): Promise<string> {
const byFile = new Map<string, number[]>();
for (const r of refs) {
const arr = byFile.get(r.file);
if (arr) arr.push(r.line);
else byFile.set(r.file, [r.line]);
}
const cwd = process.cwd();
const rel = (f: string) => (f.startsWith(`${cwd}/`) ? f.slice(cwd.length + 1) : f);
type Range = { name: string; kind: string; line: number; endLine: number | null };
const scopes = new Map<string, Range[]>();
if (client && byFile.size <= 25) {
await Promise.all(
[...byFile.keys()].map(async (f) => {
try {
const ranges = await client.getFileSymbolRanges(rel(f));
if (ranges && ranges.length > 0) scopes.set(f, ranges);
} catch {}
}),
);
}
const enclosing = (f: string, line: number): Range | null => {
const ranges = scopes.get(f);
if (!ranges) return null;
let best: Range | null = null;
for (const s of ranges) {
const end = s.endLine ?? s.line;
if (s.line <= line && end >= line && (!best || s.line > best.line)) best = s;
}
return best;
};
const out: string[] = [];
for (const [f, lns] of byFile) {
lns.sort((a, b) => a - b);
if (!scopes.has(f)) {
for (const ln of lns) out.push(`${rel(f)}:${String(ln)}`);
continue;
}
for (const ln of lns) {
const sc = enclosing(f, ln);
out.push(sc ? `${rel(f)}:${String(ln)} — ${sc.kind} ${sc.name}` : `${rel(f)}:${String(ln)}`);
}
}
return out.join("\n");
}
+31 -3
View File
@@ -10,6 +10,7 @@ import { isForbidden } from "../security/forbidden.js";
import { getIOClient, type ReadFileResult } from "../workers/io-client.js";
import { binaryHint } from "./binary-detect.js";
import { emitFileRead } from "./file-events.js";
import { contentHash } from "./tool-utils.js";
function toRelPath(abs: string): string {
const cwd = process.cwd();
@@ -24,6 +25,7 @@ interface ReadFileArgs {
endLine?: number;
target?: ReadTarget;
name?: string;
ifHash?: string;
tabId?: string;
}
@@ -125,12 +127,24 @@ async function readViaWorker(filePath: string, args: ReadFileArgs): Promise<Tool
}
}
// CAS short-circuit: caller already has this exact content — skip the body.
if (args.ifHash && args.ifHash === result.hash) {
return {
success: true,
output: `unchanged:${result.hash} (${String(result.totalLines)} lines) — content already in your context, not re-sent.`,
};
}
emitFileRead(filePath);
const lineCount = result.numbered.split("\n").length;
const isRangeRead = args.startLine != null || args.endLine != null;
const tail = memoryHintForPaths([toRelPath(filePath)], "read", args.tabId);
// Empty range read (startLine past EOF / inverted range) → no body, no footer.
const tail =
result.numbered === ""
? ""
: `\nhash:${result.hash}${memoryHintForPaths([toRelPath(filePath)], "read", args.tabId)}`;
if (!isRangeRead && lineCount > SMART_TRUNCATE_LINES) {
const cutoffLine = result.start + SMART_TRUNCATE_LINES;
@@ -209,6 +223,16 @@ async function readOnMainThread(filePath: string, args: ReadFileArgs): Promise<T
}
const content = await readBufferContent(filePath);
const fileHash = contentHash(content);
// CAS short-circuit: caller already has this exact content — skip the body.
if (args.ifHash && args.ifHash === fileHash) {
return {
success: true,
output: `unchanged:${fileHash} (${String(content.split("\n").length)} lines) — content already in your context, not re-sent.`,
};
}
const lines = content.split("\n");
const start = (args.startLine ?? 1) - 1;
@@ -232,7 +256,10 @@ async function readOnMainThread(filePath: string, args: ReadFileArgs): Promise<T
if (truncated) {
output += `\n\n(File has ${String(totalLines)} lines. Showing first ${String(MAX_READ_LINES)}. Use ranges:[{start:${String(start + MAX_READ_LINES)}, end:N}] to continue.)`;
}
output += memoryHintForPaths([toRelPath(filePath)], "read", args.tabId);
// Empty range read (startLine past EOF / inverted range) → no body, no footer.
if (numbered !== "") {
output += `\nhash:${fileHash}${memoryHintForPaths([toRelPath(filePath)], "read", args.tabId)}`;
}
return { success: true, output };
}
@@ -244,7 +271,8 @@ export const readFileTool = {
"Ranges go INSIDE each file object. Omit ranges for full file. " +
"AST extraction: {path:'c.ts', target:'function', name:'foo'}. All reads run in parallel. " +
"For context around a range, widen start/end (e.g. start-10, end+10). " +
"To find call sites, use navigate(references) instead. Skip re-reads.",
"To find call sites, use navigate(references) instead. Skip re-reads. " +
"Each read returns a `hash:` footer — pass it back as ifHash to get `unchanged:` instead of re-sending identical content.",
execute: async (args: ReadFileArgs): Promise<ToolResult> => {
try {
const filePath = resolve(args.path);
+244
View File
@@ -0,0 +1,244 @@
import { relative, resolve } from "node:path";
import type { ToolResult } from "../../types";
import { isForbidden } from "../security/forbidden.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
import { readFileTool } from "./read-file.js";
/**
* soul_query — composable code-exploration pipeline in ONE round-trip.
*
* Replaces the most common multi-step loop (grep → filter → deps → outline →
* read) with a single tool call. Each stage operates on a working set of
* repo-relative file paths and passes the (possibly narrowed) set to the next.
*
* All stages route through the Soul Map (trigram index, dep graph, symbol
* ranges) — zero file I/O until an explicit `read` stage. Pure pipeline; no
* subagents, no model calls. Designed so a weak model that won't author
* parallel tool calls still gets the benefit of composed exploration.
*/
export type QueryOp =
| { op: "search"; pattern: string }
| { op: "find"; query: string }
| { op: "filter"; ext?: string; pathContains?: string }
| { op: "deps"; direction?: "imports" | "imported_by" }
| { op: "outline" }
| { op: "read"; ranges?: { start: number; end: number } }
| { op: "limit"; n: number };
interface QueryArgs {
pipeline: QueryOp[];
}
/** Working state threaded through stages. */
interface PipeState {
/** Current working set of repo-relative paths (POSIX). */
files: string[];
/** Accumulated human-readable output blocks from terminal stages. */
blocks: string[];
}
const MAX_FILES = 200;
const MAX_PIPELINE_OPS = 12;
function toRel(cwd: string, p: string): string {
const abs = p.startsWith("/") ? p : resolve(cwd, p);
return relative(cwd, abs).replace(/\\/g, "/");
}
export const soulQueryTool = {
name: "soul_query",
description:
"[TIER-1] Composable code-exploration pipeline — one call replaces the grep→filter→deps→outline→read loop. " +
"Each stage narrows a working set of files and feeds the next. Routes through the Soul Map (trigram index, " +
"dep graph, symbol ranges) — zero file I/O until a `read` stage. " +
"Ops: " +
"{op:'search', pattern} literal substring → candidate files; " +
"{op:'find', query} fuzzy symbol/file search; " +
"{op:'filter', ext?, pathContains?} keep files by extension or path substring; " +
"{op:'deps', direction:'imports'|'imported_by'} expand to dependencies/dependents; " +
"{op:'outline'} symbol outline per file (terminal); " +
"{op:'read', ranges?} read file contents (terminal); " +
"{op:'limit', n} cap the working set. " +
"Example: pipeline:[{op:'search',pattern:'useChat'},{op:'filter',ext:'.ts'},{op:'deps',direction:'imported_by'},{op:'outline'},{op:'limit',n:10}].",
createExecute: (repoMap?: IntelligenceClient) => {
return async (args: QueryArgs): Promise<ToolResult> => {
const cwd = process.cwd();
const pipeline = args.pipeline ?? [];
if (pipeline.length === 0) {
return {
success: false,
output: "Empty pipeline. Provide pipeline:[{op:...}].",
error: "empty",
};
}
if (pipeline.length > MAX_PIPELINE_OPS) {
return {
success: false,
output: `Pipeline too long (${String(pipeline.length)} ops, max ${String(MAX_PIPELINE_OPS)}).`,
error: "too_long",
};
}
if (!repoMap?.isReady) {
return {
success: true,
output:
"Soul map not indexed — soul_query needs the repo index. Run /repo-map, or use soul_grep/read directly.",
};
}
const state: PipeState = { files: [], blocks: [] };
const trace: string[] = [];
for (let i = 0; i < pipeline.length; i++) {
const stage = pipeline[i];
if (!stage) continue;
try {
await runStage(stage, state, repoMap, cwd);
trace.push(
`${String(i + 1)}. ${describeStage(stage)} → ${String(state.files.length)} files`,
);
} catch (err) {
return {
success: false,
output: `Stage ${String(i + 1)} (${stage.op}) failed: ${err instanceof Error ? err.message : String(err)}`,
error: "stage_failed",
};
}
}
const parts: string[] = [
`Pipeline (${pipeline.length} stages):`,
...trace.map((t) => ` ${t}`),
];
if (state.blocks.length > 0) {
parts.push("", ...state.blocks);
} else if (state.files.length > 0) {
parts.push("", "Files:", ...state.files.slice(0, MAX_FILES).map((f) => ` ${f}`));
} else {
parts.push("", "(no files matched)");
}
return { success: true, output: parts.join("\n") };
};
},
};
function describeStage(stage: QueryOp): string {
switch (stage.op) {
case "search":
return `search "${stage.pattern}"`;
case "find":
return `find "${stage.query}"`;
case "filter":
return `filter ${stage.ext ?? ""}${stage.pathContains ? ` ~${stage.pathContains}` : ""}`.trim();
case "deps":
return `deps ${stage.direction ?? "imports"}`;
case "outline":
return "outline";
case "read":
return "read";
case "limit":
return `limit ${String(stage.n)}`;
}
}
async function runStage(
stage: QueryOp,
state: PipeState,
repoMap: IntelligenceClient,
cwd: string,
): Promise<void> {
switch (stage.op) {
case "search": {
const candidates = await safe(
repoMap.searchTrigramCandidates(stage.pattern, MAX_FILES),
null,
);
state.files = dedupeAllowed(candidates ?? [], cwd);
return;
}
case "find": {
const matches = await safe(repoMap.findSymbols(stage.query), [] as Array<{ path: string }>);
state.files = dedupeAllowed(
matches.map((m) => toRel(cwd, m.path)),
cwd,
);
return;
}
case "filter": {
state.files = state.files.filter((f) => {
if (stage.ext && !f.endsWith(stage.ext)) return false;
if (stage.pathContains && !f.includes(stage.pathContains)) return false;
return true;
});
return;
}
case "deps": {
const next = new Set<string>();
const dir = stage.direction ?? "imports";
for (const f of state.files.slice(0, MAX_FILES)) {
const rows = await safe(
dir === "imported_by" ? repoMap.getFileDependents(f) : repoMap.getFileDependencies(f),
[] as Array<{ path: string; weight: number }>,
);
for (const r of rows) next.add(toRel(cwd, r.path));
}
state.files = dedupeAllowed([...next], cwd);
return;
}
case "outline": {
for (const f of state.files.slice(0, 40)) {
const ranges = await safe(
repoMap.getEnclosingSymbols(f),
[] as Array<{ name: string; kind: string; line: number; endLine: number }>,
);
if (ranges.length === 0) continue;
const lines = ranges
.slice(0, 60)
.map((s) => ` ${String(s.line)}-${String(s.endLine)} ${s.kind} ${s.name}`);
state.blocks.push(`${f}:\n${lines.join("\n")}`);
}
return;
}
case "read": {
for (const f of state.files.slice(0, 10)) {
const res = await readFileTool.execute({
path: resolve(cwd, f),
...(stage.ranges ? { startLine: stage.ranges.start, endLine: stage.ranges.end } : {}),
});
state.blocks.push(`── ${f} ──\n${res.output}`);
}
return;
}
case "limit": {
state.files = state.files.slice(0, Math.max(0, stage.n));
return;
}
}
}
/** Dedupe + drop forbidden paths, preserving order. */
function dedupeAllowed(paths: string[], cwd: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const p of paths) {
const rel = toRel(cwd, p);
if (seen.has(rel)) continue;
seen.add(rel);
if (isForbidden(resolve(cwd, rel)) !== null) continue;
out.push(rel);
}
return out.slice(0, MAX_FILES);
}
/**
* Normalize a repoMap method result that may be sync (RepoMap instance) or a
* Promise (IntelligenceClient worker proxy), swallowing errors to a fallback.
*/
async function safe<T>(value: T | Promise<T>, fallback: T): Promise<T> {
try {
return await value;
} catch {
return fallback;
}
}
+8
View File
@@ -28,3 +28,11 @@ export function deriveTool<T extends object>(tool: T, overrides?: Partial<T>): T
if (overrides) Object.assign(derived, overrides);
return derived;
}
/**
* Fast, stable content hash for change-detection / CAS preconditions.
* Bun's native non-crypto hasher; hex string is the agent-facing `hash:` token.
* Same input → same output across mac/linux/windows (no platform dependence).
*/
export function contentHash(s: string): string {
return Bun.hash(s).toString(16);
}
+10
View File
@@ -894,4 +894,14 @@ export class IntelligenceClient extends WorkerClient {
getEntryPointsCached(): string[] {
return this._entryPointsCache ?? [];
}
async getEnclosingSymbols(
relPath: string,
): Promise<Array<{ name: string; kind: string; line: number; endLine: number }>> {
return this.call("getEnclosingSymbols", relPath);
}
async searchTrigramCandidates(pattern: string, limit?: number): Promise<string[] | null> {
return this.call("searchTrigramCandidates", pattern, limit);
}
}
+4
View File
@@ -160,11 +160,15 @@ const handlers: Record<string, (...args: unknown[]) => unknown> = {
findSymbol: (name: unknown) => requireRepoMap().findSymbol(name as string),
searchSymbolsSubstring: (query: unknown, limit: unknown) =>
requireRepoMap().searchSymbolsSubstring(query as string, limit as number | undefined),
searchTrigramCandidates: (pattern: unknown, limit: unknown) =>
requireRepoMap().searchTrigramCandidates(pattern as string, limit as number | undefined),
searchSymbolsFts: (query: unknown, limit: unknown) =>
requireRepoMap().searchSymbolsFts(query as string, limit as number | undefined),
getFileSymbols: (relPath: unknown) => requireRepoMap().getFileSymbols(relPath as string),
getFileSymbolRanges: (relPath: unknown) =>
requireRepoMap().getFileSymbolRanges(relPath as string),
getEnclosingSymbols: (relPath: unknown) =>
requireRepoMap().getEnclosingSymbols(relPath as string),
getSymbolSignature: (name: unknown) => requireRepoMap().getSymbolSignature(name as string),
getSymbolsByKind: (kind: unknown, limit: unknown) =>
requireRepoMap().getSymbolsByKind(kind as string, limit as number | undefined),
+8 -1
View File
@@ -39,7 +39,14 @@ interface LoadSessionResult {
}
export type ReadFileResult =
| { ok: true; numbered: string; totalLines: number; truncated: boolean; start: number }
| {
ok: true;
numbered: string;
totalLines: number;
truncated: boolean;
start: number;
hash: string;
}
| { error: "directory"; message: string }
| { error: "binary"; ext: string; sizeStr: string }
| { error: "too_large"; sizeStr: string }
+8 -1
View File
@@ -63,7 +63,14 @@ const handlers: Record<string, (...args: unknown[]) => unknown> = {
})
.join("\n");
return { ok: true, numbered, totalLines, truncated, start };
return {
ok: true,
numbered,
totalLines,
truncated,
start,
hash: Bun.hash(content).toString(16),
};
},
// ── Shell Output Compression ───────────────────────────────────────
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildIgnoreMatcher, parseIgnore } from "../src/core/intelligence/ignore.js";
import { collectFiles } from "../src/core/intelligence/repo-map-utils.js";
describe("parseIgnore", () => {
test("drops comments and blanks, preserves rules incl. negations", () => {
const rules = parseIgnore("# comment\n\n!keep.ts\nbuild/\n");
// negations are valid gitignore syntax — the engine honors them, so we keep them
expect(rules).toEqual(["!keep.ts", "build/"]);
});
});
describe("IgnoreMatcher", () => {
test("dir/ matches a directory at any depth", () => {
const m = buildIgnoreMatcher("build/");
expect(m.ignores("build", true)).toBe(true);
expect(m.ignores("packages/x/build", true)).toBe(true);
// dir-only rule must NOT match a file of the same name
expect(m.ignores("build", false)).toBe(false);
});
test("rooted /foo matches only at repo root", () => {
const m = buildIgnoreMatcher("/dist");
expect(m.ignores("dist", true)).toBe(true);
expect(m.ignores("src/dist", true)).toBe(false);
});
test("*.ext glob matches by extension at any depth", () => {
const m = buildIgnoreMatcher("*.log");
expect(m.ignores("debug.log", false)).toBe(true);
expect(m.ignores("logs/server.log", false)).toBe(true);
expect(m.ignores("server.ts", false)).toBe(false);
});
test("exact basename matches at any depth", () => {
const m = buildIgnoreMatcher("secret.key");
expect(m.ignores("secret.key", false)).toBe(true);
expect(m.ignores("config/secret.key", false)).toBe(true);
expect(m.ignores("secret.key.example", false)).toBe(false);
});
test("path with slash anchors to that path", () => {
const m = buildIgnoreMatcher("src/generated");
expect(m.ignores("src/generated", true)).toBe(true);
expect(m.ignores("src/generated/api.ts", false)).toBe(true);
expect(m.ignores("lib/src/generated", true)).toBe(false);
});
test("** double-star matches at any depth (gitignore spec)", () => {
const m = buildIgnoreMatcher("**/temp");
expect(m.ignores("temp", true)).toBe(true);
expect(m.ignores("a/b/temp", true)).toBe(true);
});
test("negation re-includes a previously ignored path", () => {
const m = buildIgnoreMatcher("*.log\n!keep.log\n");
expect(m.ignores("debug.log", false)).toBe(true);
expect(m.ignores("keep.log", false)).toBe(false);
});
test("empty matcher never ignores", () => {
const m = buildIgnoreMatcher(null, undefined, "");
expect(m.size).toBe(0);
expect(m.ignores("anything", true)).toBe(false);
});
test("does not ignore real source files", () => {
const m = buildIgnoreMatcher("node_modules/\ndist/\n*.log\ncoverage/\n");
expect(m.ignores("src/core/agents/forge.ts", false)).toBe(false);
expect(m.ignores("src/components/App.tsx", false)).toBe(false);
});
});
+10
View File
@@ -169,4 +169,14 @@ describe("call graph", () => {
expect(stats.calls).toBeGreaterThan(0);
expect(typeof stats.calls).toBe("number");
});
it("getEnclosingSymbols returns ranged symbols for a file", () => {
const syms = repoMap.getEnclosingSymbols("src/app.ts");
expect(syms.length).toBeGreaterThan(0);
const names = syms.map((s) => s.name);
expect(names).toContain("renderTimestamp");
for (const s of syms) {
expect(s.endLine).toBeGreaterThanOrEqual(s.line);
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { RepoMap } from "../src/core/intelligence/repo-map.js";
let dir: string;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
});
test("rescan re-indexes a same-mtime content change (size gate)", async () => {
dir = join(tmpdir(), `size-gate-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(join(dir, "src"), { recursive: true });
const file = join(dir, "src", "a.ts");
writeFileSync(file, "export function before() { return 1; }\n");
const rm = new RepoMap(dir);
await rm.scan();
expect(rm.findSymbols("before").length).toBe(1);
// Capture mtime, rewrite with DIFFERENT content+size, then restore the exact mtime.
// Without size gating, the (mtime-only) diff would skip re-indexing this file.
const { atimeMs, mtimeMs } = statSync(file);
writeFileSync(file, "export function afterChange() { return 22; }\nexport const x = 1;\n");
utimesSync(file, atimeMs / 1000, mtimeMs / 1000);
await rm.scan();
expect(rm.findSymbols("afterChange").length).toBe(1);
expect(rm.findSymbols("before").length).toBe(0);
rm.close();
});
test("rescan skips an unchanged file (mtime+size both equal)", async () => {
dir = join(tmpdir(), `size-gate-skip-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(join(dir, "src"), { recursive: true });
writeFileSync(join(dir, "src", "b.ts"), "export function keep() { return 1; }\n");
const rm = new RepoMap(dir);
await rm.scan();
const before = rm.getStats().files;
await rm.scan(); // no changes — gate should skip all files
expect(rm.getStats().files).toBe(before);
expect(rm.findSymbols("keep").length).toBe(1);
rm.close();
});
+78
View File
@@ -0,0 +1,78 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { RepoMap } from "../src/core/intelligence/repo-map.js";
import { soulQueryTool } from "../src/core/tools/soul-query.js";
const TMP = join(tmpdir(), `soul-query-${Date.now()}`);
const ORIG_CWD = process.cwd();
let repoMap: RepoMap;
beforeAll(async () => {
mkdirSync(join(TMP, "src"), { recursive: true });
writeFileSync(
join(TMP, "src", "service.ts"),
"export function widgetHandler() { return rareTokenABC(1); }\nexport class WidgetStore {}\n",
);
writeFileSync(join(TMP, "src", "other.ts"), "export function unrelated() { return 2; }\n");
writeFileSync(join(TMP, "src", "notes.md"), "# rareTokenABC docs\n");
// chdir so the tool's process.cwd()-relative resolution matches the fixture
process.chdir(TMP);
repoMap = new RepoMap(TMP);
await repoMap.scan();
});
afterAll(() => {
process.chdir(ORIG_CWD);
repoMap?.close();
rmSync(TMP, { recursive: true, force: true });
});
function makeExec() {
// Cast: tool accepts the IntelligenceClient interface; RepoMap satisfies the used subset.
return soulQueryTool.createExecute(repoMap as unknown as Parameters<typeof soulQueryTool.createExecute>[0]);
}
test("search → filter narrows to matching code files", async () => {
const exec = makeExec();
const r = await exec({
pipeline: [
{ op: "search", pattern: "rareTokenABC" },
{ op: "filter", ext: ".ts" },
],
});
expect(r.success).toBe(true);
expect(r.output).toContain("src/service.ts");
expect(r.output).not.toContain("src/notes.md"); // filtered by ext
expect(r.output).not.toContain("src/other.ts"); // didn't match search
});
test("search → outline emits enclosing symbols", async () => {
const exec = makeExec();
const r = await exec({
pipeline: [
{ op: "search", pattern: "rareTokenABC" },
{ op: "filter", ext: ".ts" },
{ op: "outline" },
],
});
expect(r.success).toBe(true);
expect(r.output).toContain("widgetHandler");
});
test("limit caps the working set", async () => {
const exec = makeExec();
const r = await exec({
pipeline: [{ op: "search", pattern: "export" }, { op: "limit", n: 1 }],
});
expect(r.success).toBe(true);
// trace line shows the stage narrowed to 1 file
expect(r.output).toMatch(/limit 1 → 1 files/);
});
test("empty pipeline errors", async () => {
const exec = makeExec();
const r = await exec({ pipeline: [] });
expect(r.success).toBe(false);
});
+102
View File
@@ -0,0 +1,102 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
extractContentTrigrams,
extractPatternTrigrams,
MIN_TRIGRAM_LEN,
} from "../src/core/intelligence/trigram.js";
import { RepoMap } from "../src/core/intelligence/repo-map.js";
describe("trigram extraction", () => {
test("extracts distinct trigrams from content", () => {
const tris = extractContentTrigrams("abcd");
// "abc" and "bcd"
expect(tris.size).toBe(2);
});
test("returns empty for content shorter than 3 chars", () => {
expect(extractContentTrigrams("ab").size).toBe(0);
});
test("is case-insensitive (lowercases)", () => {
const a = extractContentTrigrams("ABC");
const b = extractContentTrigrams("abc");
expect([...a]).toEqual([...b]);
});
test("skips all-whitespace trigrams", () => {
const tris = extractContentTrigrams("a b");
// "a " has a non-ws char, " " is skipped, " b" has a non-ws char
// so only the all-space window is dropped
expect(tris.size).toBeGreaterThan(0);
// the pure " " (3 spaces) trigram must not be present
const wsOnly = extractContentTrigrams(" ");
expect(wsOnly.size).toBe(0);
});
test("pattern: returns trigrams for a pure literal", () => {
const tris = extractPatternTrigrams("handleRequest");
expect(tris).not.toBeNull();
expect(tris?.length).toBeGreaterThan(0);
});
test("pattern: null when literal run too short", () => {
expect(extractPatternTrigrams("ab")).toBeNull();
expect(extractPatternTrigrams("a.b")).toBeNull(); // longest literal run = "a" or "b"
});
test("pattern: uses longest literal run in a regex", () => {
// longest literal run is "Request" (7 chars) — should produce trigrams
const tris = extractPatternTrigrams("handle.*Request");
expect(tris).not.toBeNull();
expect(tris?.length).toBeGreaterThanOrEqual(MIN_TRIGRAM_LEN - 1);
});
});
describe("RepoMap.searchTrigramCandidates", () => {
const TMP = join(tmpdir(), `trigram-rm-${Date.now()}`);
let repoMap: RepoMap;
beforeAll(async () => {
mkdirSync(join(TMP, "src"), { recursive: true });
writeFileSync(
join(TMP, "src", "alpha.ts"),
"export function handleRequest() { return uniqueNeedleXYZ(1); }\n",
);
writeFileSync(
join(TMP, "src", "beta.ts"),
"export function unrelated() { return somethingElse(2); }\n",
);
repoMap = new RepoMap(TMP);
await repoMap.scan();
});
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
test("returns only files containing the literal", () => {
const candidates = repoMap.searchTrigramCandidates("uniqueNeedleXYZ");
expect(candidates).not.toBeNull();
expect(candidates).toContain("src/alpha.ts");
expect(candidates).not.toContain("src/beta.ts");
});
test("returns empty array for a literal in no file", () => {
const candidates = repoMap.searchTrigramCandidates("zzzAbsentLiteralQQQ");
expect(candidates).not.toBeNull();
expect(candidates?.length).toBe(0);
});
test("returns null for an un-narrowable short pattern", () => {
expect(repoMap.searchTrigramCandidates("ab")).toBeNull();
});
test("candidate set is a superset — finds shared substrings", () => {
// "function" appears in both files
const candidates = repoMap.searchTrigramCandidates("function");
expect(candidates).not.toBeNull();
expect(candidates).toContain("src/alpha.ts");
expect(candidates).toContain("src/beta.ts");
});
});