feat: worker architecture — intelligence & IO workers, async FS migration, RPC framework

Phases 0-3 of the worker architecture. Main thread does only TUI render + LLM stream.

- RPC framework (rpc.ts): typed message protocol, 30s timeout, auto-restart (3x max),
  idempotent dispose, event/callback system, requestCallback timeout (60s) to prevent
  worker-side memory leaks
- Intelligence Worker: RepoMap + Shiki in dedicated Bun Worker thread, 35+ RPC handlers,
  reverse RPC for LLM summary generation, sync cached getters on client
- IO Worker (smol heap): shell compression, file tree walk, git parsing, session
  save/load (atomic writes), compaction serialization — all with local fallbacks
- Worker store (zustand): per-worker health, restarts, RPC stats, status auto-transitions
- Async FS migration: all sync FS removed from tool hot paths (edit-file, read-file,
  multi-edit, list-dir, refactor, rename-file, rename-symbol, agent-runner),
  EACCES/EPERM differentiated from ENOENT, race conditions fixed (await applyEdits)
- UI: ContextBar worker indicator (nerd font), StatusDashboard per-worker stats
- Headless: workers in stderr + JSONL events
- Tests: 105 tests covering RPC round-trip, concurrency, crash recovery, deadlock
  resistance (nested callbacks, re-entrant calls, timeout aftermath), session persistence,
  git parsing, working state serialization, worker store state machine
This commit is contained in:
Pouiiro committed 2026-03-27 00:08:07 +01:00
1 parent 3ef0383881
commit 31a2e99bde
41 files changed
+3922 -527

No files matched your search

+14 -12
View File
@@ -1,5 +1,4 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type Selection, TextAttributes } from "@opentui/core";
import { useRenderer, useTerminalDimensions } from "@opentui/react";
@@ -629,17 +628,20 @@ export function App({
// biome-ignore lint/correctness/useExhaustiveDependencies: narrow trigger — only re-run on tab count/active changes
useEffect(() => {
if (tabMgr.tabCount <= 1) return;
try {
const dir = join(cwd, ".soulforge");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const activeChat = tabMgr.getActiveChat();
const layout = tabMgr.tabs.map((t) => ({
id: t.id,
label: t.label,
activeModel: t.id === tabMgr.activeTabId ? activeChat?.activeModel : undefined,
}));
writeFileSync(join(dir, "tabs.json"), JSON.stringify(layout, null, 2));
} catch {}
(async () => {
try {
const { mkdir, writeFile } = await import("node:fs/promises");
const dir = join(cwd, ".soulforge");
await mkdir(dir, { recursive: true });
const activeChat = tabMgr.getActiveChat();
const layout = tabMgr.tabs.map((t) => ({
id: t.id,
label: t.label,
activeModel: t.id === tabMgr.activeTabId ? activeChat?.activeModel : undefined,
}));
await writeFile(join(dir, "tabs.json"), JSON.stringify(layout, null, 2));
} catch {}
})();
}, [tabMgr.tabCount, tabMgr.activeTabId]);
const { displayProvider, displayModel, isGateway, isProxy } = useMemo(() => {
+47 -2
View File
@@ -1,7 +1,9 @@
import { fg as fgStyle, StyledText, type TextRenderable } from "@opentui/core";
import { useCallback, useEffect, useRef } from "react";
import type { ContextManager } from "../../core/context/manager.js";
import { icon } from "../../core/icons.js";
import { useStatusBarStore } from "../../stores/statusbar.js";
import { useWorkerStore, type WorkerStatus } from "../../stores/workers.js";
const BAR_WIDTH = 8;
const CHARS_PER_TOKEN = 4;
@@ -57,6 +59,11 @@ interface CompactState {
v2Slots: number;
}
interface WorkerIndicator {
intel: WorkerStatus;
io: WorkerStatus;
}
function buildContent(
pct: number,
tokensK: string,
@@ -64,6 +71,7 @@ function buildContent(
live: boolean,
flash: boolean,
compact?: CompactState,
workers?: WorkerIndicator,
): StyledText {
const filled = Math.round((pct / 100) * BAR_WIDTH);
const empty = BAR_WIDTH - filled;
@@ -89,6 +97,33 @@ function buildContent(
} else if (compact?.strategy === "v2") {
chunks.push(fgStyle("#336")(` v2:${String(compact.v2Slots)}`));
}
if (workers) {
const worst =
workers.intel === "crashed" || workers.io === "crashed"
? "crashed"
: workers.intel === "restarting" || workers.io === "restarting"
? "restarting"
: workers.intel === "busy" || workers.io === "busy"
? "busy"
: "ok";
const wColor =
worst === "crashed"
? "#f44"
: worst === "restarting"
? "#FF8C00"
: worst === "busy"
? "#5af"
: "#333";
const wGlyph =
worst === "crashed"
? icon("worker_crash")
: worst === "restarting"
? icon("worker_restart")
: worst === "busy"
? icon("worker_busy")
: icon("worker");
chunks.push(fgStyle(wColor)(` ${wGlyph}`));
}
return new StyledText(chunks);
}
@@ -107,6 +142,7 @@ export function ContextBar({ contextManager }: Props) {
const currentTokensRef = useRef(0);
const compactFrameRef = useRef(0);
const prevV2SlotsRef = useRef(0);
const workerRef = useRef<WorkerIndicator>({ intel: "idle", io: "idle" });
const renderedContentRef = useRef(buildContent(0, "0.0", formatWindow(200_000), false, false));
const computeTarget = useCallback(
@@ -150,7 +186,12 @@ export function ContextBar({ contextManager }: Props) {
return useStatusBarStore.subscribe(computeTarget);
}, [computeTarget]);
// Cleanup flash timer on unmount
useEffect(() => {
return useWorkerStore.subscribe((state) => {
workerRef.current = { intel: state.intelligence.status, io: state.io.status };
});
}, []);
useEffect(() => {
return () => {
if (flashTimerRef.current) {
@@ -171,12 +212,15 @@ export function ContextBar({ contextManager }: Props) {
const tok = approach(currentTokensRef.current, target.tokensX10);
const slotsChanged = store.v2Slots !== prevV2SlotsRef.current;
prevV2SlotsRef.current = store.v2Slots;
const wk = workerRef.current;
const wkChanged = wk.intel === "crashed" || wk.intel === "busy" || wk.io === "crashed";
if (
pct === currentPctRef.current &&
tok === currentTokensRef.current &&
!target.flash &&
!isCompacting &&
!slotsChanged
!slotsChanged &&
!wkChanged
)
return;
currentPctRef.current = pct;
@@ -194,6 +238,7 @@ export function ContextBar({ contextManager }: Props) {
strategy: store.compactionStrategy,
v2Slots: store.v2Slots,
},
wk,
);
renderedContentRef.current = content;
if (textRef.current) {
+84 -1
View File
@@ -10,6 +10,7 @@ import type { ChatInstance } from "../../hooks/useChat.js";
import type { UseTabsReturn } from "../../hooks/useTabs.js";
import { useRepoMapStore } from "../../stores/repomap.js";
import { useStatusBarStore } from "../../stores/statusbar.js";
import { useWorkerStore } from "../../stores/workers.js";
import { Overlay, POPUP_BG, PopupRow } from "../layout/shared.js";
const CHROME_ROWS = 6;
@@ -157,6 +158,7 @@ export function StatusDashboard({
const sb = useStatusBarStore();
const rm = useRepoMapStore();
const wk = useWorkerStore();
useEffect(() => {
if (visible) {
@@ -421,6 +423,87 @@ export function StatusDashboard({
}
lines.push(<Spacer key="s3" innerW={innerW} />);
lines.push(
<SectionHeader
key="h-wk"
label={`${icon("worker")} Workers`}
color="#2dd4bf"
innerW={innerW}
/>,
);
const wkIcon = (s: string) =>
s === "busy"
? icon("worker_busy")
: s === "crashed"
? icon("worker_crash")
: s === "restarting"
? icon("worker_restart")
: icon("worker");
const wkColor = (s: string) =>
s === "ready" || s === "busy"
? "#4a7"
: s === "starting" || s === "restarting"
? "#b87333"
: s === "crashed"
? "#f44"
: "#666";
const intelStatus =
wk.intelligence.status === "busy"
? `busy (${String(wk.intelligence.rpcInFlight)} rpc)`
: wk.intelligence.status;
lines.push(
<EntryRow
key="wk-intel"
label={` ${wkIcon(wk.intelligence.status)} Intelligence`}
value={
wk.intelligence.restarts > 0
? `${intelStatus} (${String(wk.intelligence.restarts)} ${icon("worker_restart")})`
: intelStatus
}
valueColor={wkColor(wk.intelligence.status)}
innerW={innerW}
/>,
);
if (wk.intelligence.totalCalls > 0) {
lines.push(
<EntryRow
key="wk-intel-stats"
label=" Calls"
value={`${String(wk.intelligence.totalCalls)} total${wk.intelligence.totalErrors > 0 ? `, ${String(wk.intelligence.totalErrors)} ${icon("error")}` : ""}`}
valueColor="#666"
innerW={innerW}
/>,
);
}
const ioStatus =
wk.io.status === "busy" ? `busy (${String(wk.io.rpcInFlight)} rpc)` : wk.io.status;
lines.push(
<EntryRow
key="wk-io"
label={` ${wkIcon(wk.io.status)} IO (smol)`}
value={
wk.io.restarts > 0
? `${ioStatus} (${String(wk.io.restarts)} ${icon("worker_restart")})`
: ioStatus
}
valueColor={wkColor(wk.io.status)}
innerW={innerW}
/>,
);
if (wk.intelligence.lastError || wk.io.lastError) {
const errMsg = wk.intelligence.lastError ?? wk.io.lastError ?? "";
lines.push(
<EntryRow
key="wk-err"
label={` ${icon("error")} Last Error`}
value={errMsg.slice(0, 40)}
valueColor="#f44"
innerW={innerW}
/>,
);
}
lines.push(<Spacer key="s4" innerW={innerW} />);
lines.push(<SectionHeader key="h-sys" label="System" innerW={innerW} />);
lines.push(
<EntryRow
@@ -460,7 +543,7 @@ export function StatusDashboard({
);
return lines;
}, [sb, rm, currentMode, currentModeLabel, innerW]);
}, [sb, rm, wk, currentMode, currentModeLabel, innerW]);
const activeLines = tab === "Context" ? contextLines : systemLines;
const clampedScroll = Math.min(scrollOffset, Math.max(0, activeLines.length - maxVisible));
+17 -20
View File
@@ -477,26 +477,23 @@ export async function runAgentTask(
const editedFiles = bus.getEditedFiles(task.agentId);
if (editedFiles.size > 0) {
const noopEdits: string[] = [];
for (const [editedPath] of editedFiles) {
const cachedContent = bus.getFileContent(editedPath);
if (cachedContent == null) continue;
// Read current file from disk to compare
try {
const { readFileSync } = require("node:fs") as typeof import("node:fs");
const { resolve: resolvePath, isAbsolute } =
require("node:path") as typeof import("node:path");
const abs = isAbsolute(editedPath)
? editedPath
: resolvePath(process.cwd(), editedPath);
const diskContent = readFileSync(abs, "utf-8");
// If cache and disk are identical, the "edit" was a no-op
if (cachedContent === diskContent) {
noopEdits.push(editedPath);
}
} catch {
// File doesn't exist or can't be read — skip verification
}
}
const { readFile } = await import("node:fs/promises");
const { resolve: resolvePath, isAbsolute } = await import("node:path");
await Promise.all(
[...editedFiles.keys()].map(async (editedPath) => {
const cachedContent = bus.getFileContent(editedPath);
if (cachedContent == null) return;
try {
const abs = isAbsolute(editedPath)
? editedPath
: resolvePath(process.cwd(), editedPath);
const diskContent = await readFile(abs, "utf-8");
if (cachedContent === diskContent) {
noopEdits.push(editedPath);
}
} catch {}
}),
);
if (noopEdits.length > 0) {
editVerificationWarning = `Post-edit verification: ${String(noopEdits.length)} file(s) marked as edited but content unchanged: ${noopEdits.join(", ")}`;
}
+1 -1
View File
@@ -33,7 +33,7 @@ interface CodeAgentOptions {
webSearchModel?: LanguageModel;
onApproveWebSearch?: (query: string) => Promise<boolean>;
onApproveFetchPage?: (url: string) => Promise<boolean>;
repoMap?: import("../intelligence/repo-map.js").RepoMap;
repoMap?: import("../workers/intelligence-client.js").IntelligenceClient;
contextWindow?: number;
disablePruning?: boolean;
}
+1 -1
View File
@@ -48,7 +48,7 @@ interface ExploreAgentOptions {
webSearchModel?: LanguageModel;
onApproveWebSearch?: (query: string) => Promise<boolean>;
onApproveFetchPage?: (url: string) => Promise<boolean>;
repoMap?: import("../intelligence/repo-map.js").RepoMap;
repoMap?: import("../workers/intelligence-client.js").IntelligenceClient;
contextWindow?: number;
disablePruning?: boolean;
role?: "explore" | "investigate";
+1 -1
View File
@@ -161,7 +161,7 @@ function stripRejectedDispatches(messages: ModelMessage[]): ModelMessage[] {
function buildForgePrepareStep(
isPlanMode: boolean,
drainSteering?: () => string | null,
_repoMap?: import("../intelligence/repo-map.js").RepoMap,
_repoMap?: import("../workers/intelligence-client.js").IntelligenceClient,
contextManager?: {
buildCrossTabSection(): string | null;
isEditorOpen(): boolean;
+44 -20
View File
@@ -6,7 +6,9 @@ import { renderTaskList } from "../tools/task-list.js";
import type { AgentBus } from "./agent-bus.js";
import { emitSubagentStep } from "./subagent-events.js";
type SymbolLookup = (absPath: string) => Array<{ name: string; kind: string; isExported: boolean }>;
type SymbolLookup = (
absPath: string,
) => Promise<Array<{ name: string; kind: string; isExported: boolean }>>;
export interface PrepareStepOptions {
bus?: AgentBus;
@@ -362,11 +364,11 @@ function stripOldEditArgs(messages: ModelMessage[], cutoff: number): ModelMessag
/** Unused in production — retained for test coverage and possible future reactivation. */
function compactOldToolResults(
async function compactOldToolResults(
messages: ModelMessage[],
symbolLookup?: SymbolLookup,
pathMap?: Map<string, string>,
): ModelMessage[] {
): Promise<ModelMessage[]> {
if (messages.length <= KEEP_RECENT_MESSAGES) return messages;
const cutoff = messages.length - KEEP_RECENT_MESSAGES;
@@ -392,16 +394,32 @@ function compactOldToolResults(
}
}
return messages.map((msg, idx) => {
if (idx >= cutoff) return msg;
if (msg.role !== "tool" || typeof msg.content === "string") return msg;
if (!Array.isArray(msg.content)) return msg;
const result: ModelMessage[] = [];
for (const [idx, msg] of messages.entries()) {
if (idx >= cutoff) {
result.push(msg);
continue;
}
if (msg.role !== "tool" || typeof msg.content === "string") {
result.push(msg);
continue;
}
if (!Array.isArray(msg.content)) {
result.push(msg);
continue;
}
let changed = false;
const newContent = msg.content.map((part) => {
if (part.type !== "tool-result") return part;
if (EDIT_TOOLS.has(part.toolName)) return part;
if (!SUMMARIZABLE_TOOLS.has(part.toolName)) return part;
const newContent = [];
for (const part of msg.content) {
if (
part.type !== "tool-result" ||
EDIT_TOOLS.has(part.toolName) ||
!SUMMARIZABLE_TOOLS.has(part.toolName)
) {
newContent.push(part);
continue;
}
const text = extractText(part.output);
let symbolHint: string | undefined;
@@ -409,7 +427,7 @@ function compactOldToolResults(
const absPath = resolvedPathMap.get(part.toolCallId);
if (absPath) {
try {
symbolHint = formatSymbolHint(symbolLookup(absPath));
symbolHint = formatSymbolHint(await symbolLookup(absPath));
} catch {}
}
}
@@ -418,13 +436,17 @@ function compactOldToolResults(
symbolHint,
args: argsMap.get(part.toolCallId),
});
if (!summary) return part;
if (!summary) {
newContent.push(part);
continue;
}
changed = true;
return { ...part, output: { type: "text" as const, value: summary } };
});
newContent.push({ ...part, output: { type: "text" as const, value: summary } });
}
return changed ? { ...msg, content: newContent } : msg;
}) as ModelMessage[];
result.push(changed ? { ...msg, content: newContent } : msg);
}
return result as ModelMessage[];
}
interface PrepareStepResult {
@@ -656,10 +678,12 @@ export function buildPrepareStep({
export function buildSymbolLookup(repoMap?: {
isReady: boolean;
getCwd(): string;
getFileSymbols(relPath: string): Array<{ name: string; kind: string; isExported: boolean }>;
getFileSymbols(
relPath: string,
): Promise<Array<{ name: string; kind: string; isExported: boolean }>>;
}): SymbolLookup | undefined {
if (!repoMap) return undefined;
return (absPath: string) => {
return async (absPath: string) => {
if (!repoMap.isReady) return [];
const cwd = repoMap.getCwd();
let rel: string;
@@ -670,7 +694,7 @@ export function buildSymbolLookup(repoMap?: {
} else {
rel = absPath;
}
return repoMap.getFileSymbols(rel);
return await repoMap.getFileSymbols(rel);
};
}
+89 -72
View File
@@ -5,10 +5,10 @@ import { z } from "zod";
import { logBackgroundError } from "../../stores/errors.js";
import type { AgentFeatures } from "../../types/index.js";
import { getWorkspaceCoordinator } from "../coordination/WorkspaceCoordinator.js";
import type { RepoMap } from "../intelligence/repo-map.js";
import { getModelContextWindow } from "../llm/models.js";
// detectModelFamily removed — subagent pruning is now always disabled
import { getActiveTaskTab } from "../tools/task-list.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
import { AgentBus, type AgentTask, normalizePath, type SharedCache } from "./agent-bus.js";
import { cleanupDispatchDir, type DispatchOutput, type DoneToolResult } from "./agent-results.js";
import {
@@ -42,7 +42,7 @@ export interface SubagentModels {
onApproveWebSearch?: (query: string) => Promise<boolean>;
onApproveFetchPage?: (url: string) => Promise<boolean>;
readOnly?: boolean;
repoMap?: RepoMap;
repoMap?: IntelligenceClient;
sharedCacheRef?: SharedCacheRef;
agentFeatures?: AgentFeatures;
skills?: Array<{ name: string; content: string }>;
@@ -357,7 +357,10 @@ export function buildSubagentTools(models: SubagentModels) {
const verified: string[] = [];
const hallucinated: string[] = [];
const onDiskOnly: string[] = [];
const symbolCache = new Map<string, ReturnType<RepoMap["getFileSymbolRanges"]>>();
const symbolCache = new Map<
string,
Awaited<ReturnType<IntelligenceClient["getFileSymbolRanges"]>>
>();
const cwd = process.cwd();
for (const file of contract.filesNeeded) {
@@ -366,7 +369,7 @@ export function buildSubagentTools(models: SubagentModels) {
// Tier 1: Soul Map (most reliable — has symbols, line ranges)
if (repoMap) {
const symbols = repoMap.getFileSymbolRanges(norm);
const symbols = await repoMap.getFileSymbolRanges(norm);
symbolCache.set(norm, symbols);
if (symbols.length > 0) {
verified.push(norm);
@@ -426,7 +429,7 @@ export function buildSubagentTools(models: SubagentModels) {
const missingDeps: string[] = [];
for (const f of codeFiles) {
if (!verified.includes(f)) continue;
const importers = repoMap.getFileDependents(f);
const importers = await repoMap.getFileDependents(f);
for (const imp of importers.slice(0, 5)) {
if (!contractSet.has(imp.path) && !codeFiles.includes(imp.path)) {
missingDeps.push(`\`${imp.path}\` imports \`${f}\``);
@@ -447,11 +450,14 @@ export function buildSubagentTools(models: SubagentModels) {
const fileList: string[] = [];
for (const f of verified) {
if (repoMap) {
const symbols = symbolCache.get(f) ?? repoMap.getFileSymbolRanges(f);
const symbols = symbolCache.get(f) ?? (await repoMap.getFileSymbolRanges(f));
if (symbols.length > 0) {
const top = symbols
.slice(0, 5)
.map((s) => `${s.name} (${s.kind}, L${String(s.line)})`)
.map(
(s: { name: string; kind: string; line: number }) =>
`${s.name} (${s.kind}, L${String(s.line)})`,
)
.join(", ");
fileList.push(` \`${f}\` → ${top}`);
continue;
@@ -786,77 +792,88 @@ export function buildSubagentTools(models: SubagentModels) {
}
args = { ...args, tasks: expandedTasks };
const tasks: AgentTask[] = args.tasks.map((t, i) => {
const isWebTask =
t.targetFiles.length === 1 && t.targetFiles[0]?.toLowerCase() === WEB_MARKER;
let fileHint = "";
if (!isWebTask) {
const enriched = t.targetFiles.map((f) => {
if (!models.repoMap) return f;
const ranges = models.repoMap.getFileSymbolRanges(f);
if (ranges.length === 0) return f;
const rangeStr = ranges
.map((r) => {
const end = r.endLine ? `-${String(r.endLine)}` : "";
return ` ${r.name} (${r.kind}, lines ${String(r.line)}${end})`;
})
.join("\n");
return `${f}\n${rangeStr}`;
});
fileHint = `\nTarget files:\n${enriched.join("\n")}`;
}
let skillHint = "";
if (models.skills && models.skills.length > 0) {
const matched = matchSkillsToTask(models.skills, t.task);
for (const s of matched) {
const truncated =
s.content.length > SKILL_MAX_INJECT_CHARS
? `${s.content.slice(0, SKILL_MAX_INJECT_CHARS)}\n[...]`
: s.content;
skillHint += `\n\n--- Relevant skill: ${s.name} ---\n${truncated}`;
const tasks: AgentTask[] = await Promise.all(
args.tasks.map(async (t, i) => {
const isWebTask =
t.targetFiles.length === 1 && t.targetFiles[0]?.toLowerCase() === WEB_MARKER;
let fileHint = "";
if (!isWebTask) {
const enriched = await Promise.all(
t.targetFiles.map(async (f: string) => {
if (!models.repoMap) return f;
const ranges = await models.repoMap.getFileSymbolRanges(f);
if (ranges.length === 0) return f;
const rangeStr = ranges
.map(
(r: {
name: string;
kind: string;
line: number;
endLine: number | null;
}) => {
const end = r.endLine ? `-${String(r.endLine)}` : "";
return ` ${r.name} (${r.kind}, lines ${String(r.line)}${end})`;
},
)
.join("\n");
return `${f}\n${rangeStr}`;
}),
);
fileHint = `\nTarget files:\n${enriched.join("\n")}`;
}
let skillHint = "";
if (models.skills && models.skills.length > 0) {
const matched = matchSkillsToTask(models.skills, t.task);
for (const s of matched) {
const truncated =
s.content.length > SKILL_MAX_INJECT_CHARS
? `${s.content.slice(0, SKILL_MAX_INJECT_CHARS)}\n[...]`
: s.content;
skillHint += `\n\n--- Relevant skill: ${s.name} ---\n${truncated}`;
}
}
}
// Inject cross-tab claims so subagents know about other tabs' edits
let crossTabHint = "";
if (!isWebTask && t.role === "code") {
const tabId = getActiveTaskTab();
if (tabId) {
const wc = getWorkspaceCoordinator();
const editors = wc.getActiveEditors();
const otherEdits: string[] = [];
for (const [tid] of editors) {
if (tid === tabId) continue;
const tc = wc.getClaimsForTab(tid);
if (tc.size === 0) continue;
let label = "Unknown";
const paths: string[] = [];
for (const [p, c] of tc) {
label = c.tabLabel;
paths.push(p);
// Inject cross-tab claims so subagents know about other tabs' edits
let crossTabHint = "";
if (!isWebTask && t.role === "code") {
const tabId = getActiveTaskTab();
if (tabId) {
const wc = getWorkspaceCoordinator();
const editors = wc.getActiveEditors();
const otherEdits: string[] = [];
for (const [tid] of editors) {
if (tid === tabId) continue;
const tc = wc.getClaimsForTab(tid);
if (tc.size === 0) continue;
let label = "Unknown";
const paths: string[] = [];
for (const [p, c] of tc) {
label = c.tabLabel;
paths.push(p);
}
otherEdits.push(
`Tab "${label}": ${paths.slice(0, 5).join(", ")}${paths.length > 5 ? ` (+${String(paths.length - 5)} more)` : ""}`,
);
}
if (otherEdits.length > 0) {
crossTabHint = `\n\nOther tabs editing files:\n${otherEdits.join("\n")}\nAvoid these files. If you must edit one, your edit will still apply but may conflict.`;
}
otherEdits.push(
`Tab "${label}": ${paths.slice(0, 5).join(", ")}${paths.length > 5 ? ` (+${String(paths.length - 5)} more)` : ""}`,
);
}
if (otherEdits.length > 0) {
crossTabHint = `\n\nOther tabs editing files:\n${otherEdits.join("\n")}\nAvoid these files. If you must edit one, your edit will still apply but may conflict.`;
}
}
}
return {
agentId: t.id ?? `agent-${String(i + 1)}`,
role: t.role ?? "explore",
task: `${t.task}${fileHint}${skillHint}${crossTabHint}`,
returnFormat: t.returnFormat,
dependsOn: t.dependsOn,
taskId: t.taskId,
tabId: getActiveTaskTab() ?? undefined,
targetFileCount: isWebTask ? 0 : t.targetFiles.length,
targetFiles: isWebTask ? [] : t.targetFiles,
};
});
return {
agentId: t.id ?? `agent-${String(i + 1)}`,
role: t.role ?? "explore",
task: `${t.task}${fileHint}${skillHint}${crossTabHint}`,
returnFormat: t.returnFormat,
dependsOn: t.dependsOn,
taskId: t.taskId,
tabId: getActiveTaskTab() ?? undefined,
targetFileCount: isWebTask ? 0 : t.targetFiles.length,
targetFiles: isWebTask ? [] : t.targetFiles,
};
}),
);
// Auto-serialize code agents that target the same file —
// concurrent edits to the same file cause old_string mismatch failures.
+26 -5
View File
@@ -2,12 +2,13 @@ import type { JSONObject } from "@ai-sdk/provider";
import type { ModelMessage } from "ai";
import { generateText } from "ai";
import { logBackgroundError } from "../../stores/errors.js";
import type { IOClient } from "../workers/io-client.js";
import type { WorkingStateManager } from "./working-state.js";
/**
* Build the compacted summary for v2.
*
* 1. Serialize the incrementally-built working state (free, already computed).
* 1. Serialize the incrementally-built working state (offloaded to IO worker when available).
* 2. Optionally run a cheap LLM pass to verify completeness against the
* full older messages — not a gap-fill on a tiny sample, but a real
* verification pass that sees everything.
@@ -21,10 +22,21 @@ export async function buildV2Summary(opts: {
headers?: Record<string, string>;
skipLlm?: boolean;
abortSignal?: AbortSignal;
ioClient?: IOClient;
}): Promise<string> {
const { wsm, olderMessages, model, providerOptions, headers, skipLlm, abortSignal } = opts;
const { wsm, olderMessages, model, providerOptions, headers, skipLlm, abortSignal, ioClient } =
opts;
const structuredState = wsm.serialize();
let structuredState: string;
if (ioClient) {
try {
structuredState = await ioClient.serializeWorkingState(wsm.getState());
} catch {
structuredState = wsm.serialize();
}
} else {
structuredState = wsm.serialize();
}
// Skip gap-fill when structured state is rich enough — the incremental
// extraction already captured the important context. Saves ~2k tokens.
@@ -33,7 +45,16 @@ export async function buildV2Summary(opts: {
return structuredState;
}
const convoText = buildFullConvoText(olderMessages, 12000);
let convoText: string;
if (ioClient) {
try {
convoText = await ioClient.buildConvoText(olderMessages, 12000);
} catch {
convoText = buildFullConvoText(olderMessages, 12000);
}
} else {
convoText = buildFullConvoText(olderMessages, 12000);
}
let gapFill: string | undefined;
try {
@@ -83,7 +104,7 @@ export async function buildV2Summary(opts: {
return `${structuredState}\n\n## Additional Details\n${gapFill.trim()}`;
}
function buildFullConvoText(messages: ModelMessage[], charBudget: number): string {
export function buildFullConvoText(messages: ModelMessage[], charBudget: number): string {
const parts: string[] = [];
let chars = 0;
+69 -66
View File
@@ -133,71 +133,74 @@ export class WorkingStateManager {
}
serialize(): string {
const s = this.state;
const sections: string[] = [];
if (s.task) {
sections.push(`## Task\n${s.task}`);
}
if (s.userRequirements.length > 0) {
sections.push(`## User Requirements\n${s.userRequirements.map((r) => `- ${r}`).join("\n")}`);
}
if (s.plan.length > 0) {
const planLines = s.plan.map((step) => {
const icon =
step.status === "done"
? "✓"
: step.status === "active"
? "▸"
: step.status === "skipped"
? "⊘"
: "○";
return ` ${icon} [${step.id}] ${step.label} — ${step.status}`;
});
sections.push(`## Plan\n${planLines.join("\n")}`);
}
if (s.environment.length > 0) {
sections.push(`## Environment\n${s.environment.map((e) => `- ${e}`).join("\n")}`);
}
if (s.files.size > 0) {
const fileLines: string[] = [];
for (const [path, slot] of s.files) {
const actions = slot.actions.map((a) => {
if (a.type === "read") return `read: ${a.summary}`;
if (a.type === "edit") return `edited: ${a.detail}`;
if (a.type === "create") return `created: ${a.detail}`;
return "deleted";
});
fileLines.push(`- \`${path}\`: ${actions.join("; ")}`);
}
sections.push(`## Files Touched\n${fileLines.join("\n")}`);
}
if (s.decisions.length > 0) {
sections.push(`## Key Decisions\n${s.decisions.map((d) => `- ${d}`).join("\n")}`);
}
if (s.assistantNotes.length > 0) {
sections.push(`## Assistant Notes\n${s.assistantNotes.map((n) => `- ${n}`).join("\n")}`);
}
if (s.toolResults.length > 0) {
const resultLines = s.toolResults.map((r) => `- **${r.tool}**: ${r.summary}`);
sections.push(`## Tool Results\n${resultLines.join("\n")}`);
}
if (s.failures.length > 0) {
sections.push(`## Errors & Failures\n${s.failures.map((f) => `- ${f}`).join("\n")}`);
}
if (s.discoveries.length > 0) {
sections.push(`## Discoveries\n${s.discoveries.map((d) => `- ${d}`).join("\n")}`);
}
return sections.join("\n\n");
return serializeState(this.state);
}
}
export function serializeState(s: WorkingState): string {
const sections: string[] = [];
if (s.task) {
sections.push(`## Task\n${s.task}`);
}
if (s.userRequirements.length > 0) {
sections.push(`## User Requirements\n${s.userRequirements.map((r) => `- ${r}`).join("\n")}`);
}
if (s.plan.length > 0) {
const planLines = s.plan.map((step) => {
const icon =
step.status === "done"
? "✓"
: step.status === "active"
? "▸"
: step.status === "skipped"
? "⊘"
: "○";
return ` ${icon} [${step.id}] ${step.label} — ${step.status}`;
});
sections.push(`## Plan\n${planLines.join("\n")}`);
}
if (s.environment.length > 0) {
sections.push(`## Environment\n${s.environment.map((e) => `- ${e}`).join("\n")}`);
}
if (s.files.size > 0) {
const fileLines: string[] = [];
for (const [path, slot] of s.files) {
const actions = slot.actions.map((a) => {
if (a.type === "read") return `read: ${a.summary}`;
if (a.type === "edit") return `edited: ${a.detail}`;
if (a.type === "create") return `created: ${a.detail}`;
return "deleted";
});
fileLines.push(`- \`${path}\`: ${actions.join("; ")}`);
}
sections.push(`## Files Touched\n${fileLines.join("\n")}`);
}
if (s.decisions.length > 0) {
sections.push(`## Key Decisions\n${s.decisions.map((d) => `- ${d}`).join("\n")}`);
}
if (s.assistantNotes.length > 0) {
sections.push(`## Assistant Notes\n${s.assistantNotes.map((n) => `- ${n}`).join("\n")}`);
}
if (s.toolResults.length > 0) {
const resultLines = s.toolResults.map((r) => `- **${r.tool}**: ${r.summary}`);
sections.push(`## Tool Results\n${resultLines.join("\n")}`);
}
if (s.failures.length > 0) {
sections.push(`## Errors & Failures\n${s.failures.map((f) => `- ${f}`).join("\n")}`);
}
if (s.discoveries.length > 0) {
sections.push(`## Discoveries\n${s.discoveries.map((d) => `- ${d}`).join("\n")}`);
}
return sections.join("\n\n");
}
+98 -69
View File
@@ -6,7 +6,7 @@ import type { EditorIntegration, ForgeMode, TaskRouter } from "../../types/index
import { toErrorMessage } from "../../utils/errors.js";
import { setNeovimFileWrittenHandler } from "../editor/neovim.js";
import { buildGitContext } from "../git/status.js";
import { RepoMap, type SymbolForSummary } from "../intelligence/repo-map.js";
import type { SymbolForSummary } from "../intelligence/repo-map.js";
import { resolveModel } from "../llm/provider.js";
import { MemoryManager } from "../memory/manager.js";
import {
@@ -19,6 +19,7 @@ import {
} from "../prompts/index.js";
import { buildForbiddenContext, isForbidden } from "../security/forbidden.js";
import { emitFileEdited, onFileEdited, onFileRead } from "../tools/file-events.js";
import { IntelligenceClient } from "../workers/intelligence-client.js";
// extractConversationTerms removed — FTS boosting was noisy
import { walkDir } from "./file-tree.js";
import { detectToolchain } from "./toolchain.js";
@@ -28,7 +29,7 @@ import { detectToolchain } from "./toolchain.js";
// Mode instructions are in src/core/prompts/modes/index.ts
export interface SharedContextResources {
repoMap: RepoMap;
repoMap: IntelligenceClient;
memoryManager: MemoryManager;
workspaceCoordinator?: import("../coordination/WorkspaceCoordinator.js").WorkspaceCoordinator;
}
@@ -60,7 +61,7 @@ export class ContextManager {
private editorIntegration: EditorIntegration | null = null;
private fileTreeCache: { tree: string; at: number } | null = null;
private projectInfoCache: { info: string | null; at: number } | null = null;
private repoMap: RepoMap;
private repoMap: IntelligenceClient;
private repoMapReady = false;
/** Repo map is always enabled unless SOULFORGE_NO_REPOMAP=1 env var is set (debug only). */
private repoMapEnabled = process.env.SOULFORGE_NO_REPOMAP !== "1";
@@ -95,7 +96,7 @@ export class ContextManager {
this.wireFileEventHandlers();
} else {
this.memoryManager = new MemoryManager(cwd);
this.repoMap = new RepoMap(cwd);
this.repoMap = new IntelligenceClient(cwd);
this.wireFileEventHandlers();
if (this.repoMapEnabled) {
this.wireRepoMapCallbacks();
@@ -116,7 +117,7 @@ export class ContextManager {
await tick();
onStep?.("Mapping the codebase…");
const repoMap = new RepoMap(cwd);
const repoMap = new IntelligenceClient(cwd);
await tick();
onStep?.("Wiring up the forge…");
@@ -175,7 +176,7 @@ export class ContextManager {
}
private wireRepoMapCallbacks(): void {
this.repoMap.onProgress = (indexed, total) => {
this.repoMap.onProgress = (indexed: number, total: number) => {
const store = useRepoMapStore.getState();
const phaseLabels: Record<number, string> = {
[-1]: "building edges",
@@ -184,22 +185,24 @@ export class ContextManager {
};
const label = phaseLabels[indexed] ?? `${String(indexed)}/${String(total)}`;
store.setScanProgress(label);
const stats = this.repoMap.getStats();
store.setStats(stats.files, stats.symbols, stats.edges, this.repoMap.dbSizeBytes());
const stats = this.repoMap.getStatsCached();
store.setStats(stats.files, stats.symbols, stats.edges, this.repoMap.dbSizeBytesCached());
};
this.repoMap.onScanComplete = (success) => {
this.repoMap.onScanComplete = async (success: boolean) => {
if (success) {
this.repoMapReady = true;
this.syncRepoMapStore("ready");
useRepoMapStore.getState().setScanError("");
// Re-apply semantic mode now that repo map is ready (may have been set before scan finished)
const current = this.repoMap.getSemanticMode();
if (current === "off") {
const persisted = this.repoMap.detectPersistedSemanticMode();
this.setSemanticSummaries(persisted === "off" ? "synthetic" : persisted);
} else {
this.setSemanticSummaries(current);
}
const semanticTask =
current === "off"
? this.repoMap
.detectPersistedSemanticMode()
.then((persisted) =>
this.setSemanticSummaries(persisted === "off" ? "synthetic" : persisted),
)
: this.setSemanticSummaries(current);
await Promise.all([semanticTask, this.warmRepoMapCache()]);
} else {
this.repoMapReady = false;
this.syncRepoMapStore("error");
@@ -207,18 +210,16 @@ export class ContextManager {
}
};
// On stale symbols: always regen free summaries (ast/synthetic), optionally regen LLM
this.repoMap.onStaleSymbols = (count) => {
this.repoMap.onStaleSymbols = async (count: number) => {
const mode = this.repoMap.getSemanticMode();
if (mode === "off" || !this.repoMapReady) return;
// AST + synthetic regen is always free and instant
this.repoMap.generateAstSummaries();
const tasks: Promise<unknown>[] = [this.repoMap.generateAstSummaries()];
if (mode === "synthetic" || mode === "full") {
this.repoMap.generateSyntheticSummaries();
tasks.push(this.repoMap.generateSyntheticSummaries());
}
await Promise.all(tasks);
// LLM regen only when auto-regen is enabled (costs tokens)
if ((mode === "llm" || mode === "full" || mode === "on") && this.semanticAutoRegen) {
const modelId = this.getSemanticModelId(this.lastActiveModel ?? "");
if (!modelId || modelId === "none") return;
@@ -227,8 +228,7 @@ export class ContextManager {
store.setSemanticProgress(`${String(count)} stale — regenerating...`);
this.generateSemanticSummaries(modelId).catch(() => {});
} else {
// Just update counts from free regen
const stats = this.repoMap.getStats();
const stats = this.repoMap.getStatsCached();
useRepoMapStore.getState().setSemanticCount(stats.summaries);
}
};
@@ -237,10 +237,9 @@ export class ContextManager {
private syncRepoMapStore(status: "off" | "scanning" | "ready" | "error"): void {
const store = useRepoMapStore.getState();
store.setStatus(status);
// Don't reset stats to 0 during scanning — keep last-known values visible
if (status !== "scanning") {
const stats = this.repoMap.getStats();
store.setStats(stats.files, stats.symbols, stats.edges, this.repoMap.dbSizeBytes());
const stats = this.repoMap.getStatsCached();
store.setStats(stats.files, stats.symbols, stats.edges, this.repoMap.dbSizeBytesCached());
store.setScanProgress("");
}
}
@@ -311,15 +310,16 @@ export class ContextManager {
this.repoMap.onFileChanged(absPath);
if (this.repoMapReady) {
setTimeout(() => {
const stats = this.repoMap.getStats();
const stats = this.repoMap.getStatsCached();
useRepoMapStore
.getState()
.setStats(stats.files, stats.symbols, stats.edges, this.repoMap.dbSizeBytes());
.setStats(stats.files, stats.symbols, stats.edges, this.repoMap.dbSizeBytesCached());
}, 200);
}
}
this.editedFiles.add(absPath);
this.repoMapCache = null;
if (this.repoMapCache) this.repoMapCache.at = 0;
this.warmRepoMapCache();
this.gitContextStale = true;
}
@@ -349,27 +349,42 @@ export class ContextManager {
this.mentionedFiles.clear();
this.conversationTokens = 0;
this.repoMapCache = null;
if (this.repoMapCache) this.repoMapCache.at = 0;
this.warmRepoMapCache();
}
/** Render repo map with full tracked context (cached within TTL) */
private repoMapRefreshing = false;
/** Render repo map with full tracked context (returns cached, refreshes in background) */
renderRepoMap(): string {
if (!this.repoMapReady) return "";
const now = Date.now();
if (this.repoMapCache && now - this.repoMapCache.at < ContextManager.REPO_MAP_TTL) {
if (this.repoMapCache) {
if (now - this.repoMapCache.at >= ContextManager.REPO_MAP_TTL) {
this.warmRepoMapCache();
}
return this.repoMapCache.content;
}
const content = this.repoMap.render({
editorFile: this.editorFile,
editedFiles: [...this.editedFiles],
mentionedFiles: [...this.mentionedFiles],
conversationTokens: this.conversationTokens,
});
this.repoMapCache = { content, at: now };
return content;
this.warmRepoMapCache();
return "";
}
getRepoMap(): RepoMap {
private async warmRepoMapCache(): Promise<void> {
if (this.repoMapRefreshing) return;
this.repoMapRefreshing = true;
try {
const content = await this.repoMap.render({
editorFile: this.editorFile,
editedFiles: [...this.editedFiles],
mentionedFiles: [...this.mentionedFiles],
conversationTokens: this.conversationTokens,
});
this.repoMapCache = { content, at: Date.now() };
} catch {}
this.repoMapRefreshing = false;
}
getRepoMap(): IntelligenceClient {
return this.repoMap;
}
@@ -378,7 +393,7 @@ export class ContextManager {
}
isRepoMapReady(): boolean {
if (this.isChild) return this.repoMap.getStats().files > 0;
if (this.isChild) return this.repoMap.getStatsCached().files > 0;
return this.repoMapReady;
}
@@ -388,9 +403,9 @@ export class ContextManager {
// Kept for backward compat with callers that haven't been updated yet.
}
setSemanticSummaries(
async setSemanticSummaries(
modeOrBool: "off" | "ast" | "synthetic" | "llm" | "full" | "on" | boolean,
): void {
): Promise<void> {
const mode =
modeOrBool === true
? "synthetic"
@@ -416,36 +431,31 @@ export class ContextManager {
return;
}
// AST extraction (free) — runs for all non-off modes
store.setSemanticStatus("generating");
store.setSemanticProgress("extracting docstrings...");
this.repoMap.generateAstSummaries();
// Synthetic generation (free, instant) — runs for synthetic/full modes
store.setSemanticProgress("generating summaries...");
const genTasks: Promise<unknown>[] = [this.repoMap.generateAstSummaries()];
if (mode === "synthetic" || mode === "full") {
store.setSemanticProgress("generating synthetic summaries...");
this.repoMap.generateSyntheticSummaries();
genTasks.push(this.repoMap.generateSyntheticSummaries());
}
await Promise.all(genTasks);
// Update stats from actual DB state
const bd = this.repoMap.getSummaryBreakdown();
const bd = await this.repoMap.getSummaryBreakdown();
store.setSemanticCount(bd.total);
if (mode === "llm" || mode === "full") {
// Auto-trigger LLM generation in background if model available
const modelId = this.getSemanticModelId(this.lastActiveModel);
if (modelId && modelId !== "none") {
const genId = ++this.semanticGenId;
store.setSemanticModel(modelId);
store.setSemanticStatus("generating"); // never "ready" before LLM finishes
store.setSemanticStatus("generating");
store.setSemanticProgress(
bd.total > 0
? `${this.formatBreakdown(bd)} (generating LLM...)`
: "generating LLM summaries...",
);
this.generateSemanticSummaries(modelId).catch(() => {
this.generateSemanticSummaries(modelId).catch(async () => {
if (this.semanticGenId !== genId) return;
const current = this.repoMap.getSummaryBreakdown();
const current = await this.repoMap.getSummaryBreakdown();
store.setSemanticCount(current.total);
store.setSemanticStatus(current.total > 0 ? "ready" : "off");
store.setSemanticProgress(
@@ -462,10 +472,9 @@ export class ContextManager {
}
}
/** Clear only free summaries (ast/synthetic). LLM summaries are preserved. */
clearFreeSummaries(): void {
async clearFreeSummaries(): Promise<void> {
this.repoMap.clearFreeSummaries();
const bd = this.repoMap.getSummaryBreakdown();
const bd = await this.repoMap.getSummaryBreakdown();
const store = useRepoMapStore.getState();
store.setSemanticCount(bd.total);
store.setSemanticProgress(bd.total > 0 ? this.formatBreakdown(bd) : "");
@@ -614,9 +623,8 @@ export class ContextManager {
try {
const count = await this.repoMap.generateSemanticSummaries(this.semanticSummaryLimit);
// Only update store if this is still the active generation (not superseded)
if (this.semanticGenId === myGenId) {
const bd = this.repoMap.getSummaryBreakdown();
const bd = await this.repoMap.getSummaryBreakdown();
store.setSemanticCount(bd.total);
store.setSemanticStatus(bd.total > 0 ? "ready" : "off");
store.setSemanticProgress(bd.total > 0 ? this.formatBreakdown(bd) : "");
@@ -629,7 +637,7 @@ export class ContextManager {
store.setSemanticProgress(msg.slice(0, 80));
store.setSemanticModel("");
store.resetSemanticTokens();
const fallbackStats = this.repoMap.getStats();
const fallbackStats = this.repoMap.getStatsCached();
store.setSemanticCount(fallbackStats.summaries);
}
throw err;
@@ -644,6 +652,9 @@ export class ContextManager {
if (!this.isChild) {
this.repoMap.close().catch(() => {});
this.memoryManager.close();
import("../workers/io-client.js")
.then(({ disposeIOClient }) => disposeIOClient())
.catch(() => {});
}
}
@@ -653,8 +664,8 @@ export class ContextManager {
await this.repoMap.scan().catch((err: unknown) => this.handleScanError(err));
}
clearRepoMap(): void {
this.repoMap.clear();
async clearRepoMap(): Promise<void> {
await this.repoMap.clear();
this.repoMapReady = false;
this.syncRepoMapStore("off");
}
@@ -804,7 +815,7 @@ export class ContextManager {
modelId: modelIdOverride || this.lastActiveModel,
cwd: this.cwd,
hasRepoMap: this.repoMapReady,
hasSymbols: this.repoMapReady && this.repoMap.getStats().symbols > 0,
hasSymbols: this.repoMapReady && this.repoMap.getStatsCached().symbols > 0,
forgeMode: this.forgeMode,
contextPercent: this.getContextPercent(),
isMinimalContext: this.contextWindowTokens <= MINIMAL_CONTEXT_THRESHOLD,
@@ -1047,18 +1058,36 @@ export class ContextManager {
return detectToolchain(this.cwd);
}
/** Generate a simple file tree (cached with 30s TTL) */
private fileTreeRefreshing = false;
/** Generate a simple file tree (returns cached, refreshes via IO worker in background) */
private getFileTree(maxDepth: number): string {
const now = Date.now();
if (this.fileTreeCache && now - this.fileTreeCache.at < ContextManager.FILE_TREE_TTL) {
if (this.fileTreeCache) {
if (now - this.fileTreeCache.at >= ContextManager.FILE_TREE_TTL) {
this.warmFileTreeCache(maxDepth);
}
return this.fileTreeCache.tree;
}
const lines: string[] = [];
walkDir(this.cwd, "", maxDepth, lines);
const tree = lines.slice(0, 50).join("\n");
this.fileTreeCache = { tree, at: now };
this.warmFileTreeCache(maxDepth);
return tree;
}
private async warmFileTreeCache(maxDepth: number): Promise<void> {
if (this.fileTreeRefreshing) return;
this.fileTreeRefreshing = true;
try {
const { getIOClient } = await import("../workers/io-client.js");
const lines = await getIOClient().walkDir(this.cwd, "", maxDepth);
const tree = lines.slice(0, 50).join("\n");
this.fileTreeCache = { tree, at: Date.now() };
} catch {}
this.fileTreeRefreshing = false;
}
}
export { extractConversationTerms } from "./conversation-terms.js";
+7 -1
View File
@@ -205,7 +205,13 @@ export async function getGitLog(cwd: string, count = 10): Promise<GitLogEntry[]>
cwd,
);
if (!ok) return [];
return stdout.trim().split("\n").filter(Boolean).map(parseGitLogLine);
const lines = stdout.trim().split("\n").filter(Boolean);
try {
const { getIOClient } = await import("../workers/io-client.js");
return await getIOClient().parseGitLogBatch(lines);
} catch {
return lines.map(parseGitLogLine);
}
}
export async function gitInit(cwd: string): Promise<boolean> {
+8
View File
@@ -92,6 +92,10 @@ const NERD: Record<string, string> = {
diff_remove: "󰍴",
create: "󰝒",
write: "󰏫",
worker: "󰒋",
worker_busy: "󰑮",
worker_crash: "󰅙",
worker_restart: "󰑓",
};
const ASCII: Record<string, string> = {
@@ -186,6 +190,10 @@ const ASCII: Record<string, string> = {
diff_remove: "-",
create: "+",
write: "✎",
worker: "⚡",
worker_busy: "⚡",
worker_crash: "⚡✗",
worker_restart: "⚡↻",
};
let _nerdFont: boolean | null = null;
+28
View File
@@ -3,6 +3,7 @@ import { rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { logBackgroundError } from "../../stores/errors.js";
import type { ChatMessage } from "../../types/index.js";
import { getIOClient } from "../workers/io-client.js";
import { rebuildCoreMessages } from "./rebuild.js";
import type { SessionMeta, TabMeta } from "./types.js";
@@ -43,6 +44,15 @@ export class SessionManager {
async saveSession(meta: SessionMeta, tabMessages: Map<string, ChatMessage[]>): Promise<void> {
this.ensureDir();
const sessionDir = join(this.dir, meta.id);
try {
const io = getIOClient();
await io.saveSession(sessionDir, meta, [...tabMessages.entries()]);
return;
} catch {
// IO worker unavailable — fall back to local serialization
}
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
}
@@ -115,6 +125,24 @@ export class SessionManager {
}
}
async loadSessionAsync(
id: string,
): Promise<{ meta: SessionMeta; tabMessages: Map<string, ChatMessage[]> } | null> {
const sessionDir = join(this.dir, id);
try {
const io = getIOClient();
const result = await io.loadSession(sessionDir);
if (!result) return null;
const tabMessages = new Map<string, ChatMessage[]>();
for (const [tabId, msgs] of result.tabEntries) {
tabMessages.set(tabId, msgs);
}
return { meta: result.meta, tabMessages };
} catch {
return this.loadSession(id);
}
}
loadSessionMessages(
id: string,
): { messages: ChatMessage[]; coreMessages: import("ai").ModelMessage[] } | null {
+8 -4
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { type AgentBus, normalizePath } from "../agents/agent-bus.js";
import type { RepoMap } from "../intelligence/repo-map.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
interface WrappableTool {
description?: string;
@@ -13,13 +13,13 @@ export function wrapWithBusCache(
tools: Record<string, WrappableTool>,
bus: AgentBus,
agentId: string,
repoMap?: RepoMap,
repoMap?: IntelligenceClient,
): Record<string, WrappableTool> {
const wrapped = { ...tools };
const CACHE_HIT_LINES_THRESHOLD = 80;
function tagCacheHit(result: unknown, path: string): unknown {
async function tagCacheHit(result: unknown, path: string): Promise<unknown> {
const text =
typeof result === "string"
? result
@@ -30,7 +30,11 @@ export function wrapWithBusCache(
let symbols: Array<{ name: string; kind: string; line: number }> = [];
if (repoMap) {
try {
symbols = repoMap.getFileSymbolRanges(path);
symbols = (await repoMap.getFileSymbolRanges(path)).map((s) => ({
name: s.name,
kind: s.kind,
line: s.line,
}));
} catch {}
}
+17 -10
View File
@@ -1,5 +1,4 @@
import { existsSync, mkdirSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { mkdir, stat as statAsync, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { ToolResult } from "../../types";
import { analyzeFile } from "../analysis/complexity";
@@ -120,8 +119,13 @@ export const editFileTool = {
// Create new file
if (oldStr === "") {
const dir = dirname(filePath);
const dirCreated = !existsSync(dir);
mkdirSync(dir, { recursive: true });
let dirCreated = false;
try {
await statAsync(dir);
} catch {
dirCreated = true;
}
await mkdir(dir, { recursive: true });
await writeFile(filePath, newStr, "utf-8");
emitFileEdited(filePath, newStr);
const openedInEditor = await reloadBuffer(filePath);
@@ -132,12 +136,15 @@ export const editFileTool = {
return { success: true, output: out };
}
if (!existsSync(filePath)) {
return {
success: false,
output: `File not found: ${filePath}`,
error: `File not found: ${filePath}`,
};
try {
await statAsync(filePath);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
const msg =
code === "EACCES" || code === "EPERM"
? `Permission denied: ${filePath}`
: `File not found: ${filePath}`;
return { success: false, output: msg, error: msg };
}
const content = await readBufferContent(filePath);
+32 -32
View File
@@ -9,7 +9,6 @@ import {
prependWarning,
} from "../coordination/tool-wrapper.js";
import { getWorkspaceCoordinator } from "../coordination/WorkspaceCoordinator.js";
import type { RepoMap } from "../intelligence/repo-map.js";
import { MemoryManager } from "../memory/manager.js";
import {
describeDestructiveCommand,
@@ -17,6 +16,7 @@ import {
isSensitiveFile,
} from "../security/approval-gates.js";
import { needsOutsideConfirm } from "../security/outside-cwd.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
import { analyzeTool } from "./analyze.js";
import { truncateBytes, truncateLines } from "./constants.js";
import { discoverPatternTool } from "./discover-pattern.js";
@@ -199,7 +199,7 @@ export function buildTools(
contextManager?: import("../context/manager.js").ContextManager;
agentSkills?: boolean;
webSearchModel?: import("ai").LanguageModel;
repoMap?: RepoMap;
repoMap?: IntelligenceClient;
onApproveFetchPage?: (url: string) => Promise<boolean>;
onApproveOutsideCwd?: (toolName: string, path: string) => Promise<boolean>;
onApproveDestructive?: (description: string) => Promise<boolean>;
@@ -382,8 +382,8 @@ export function buildTools(
const rel = args.path.startsWith("/")
? args.path.slice(effectiveCwd.length + 1)
: args.path;
const blast = opts.repoMap.getFileBlastRadius(rel);
const cochanges = opts.repoMap.getFileCoChanges(rel);
const blast = await opts.repoMap.getFileBlastRadius(rel);
const cochanges = await opts.repoMap.getFileCoChanges(rel);
if (blast > 0 || cochanges.length > 0) {
const parts: string[] = [];
if (blast > 0) parts.push(`${String(blast)} files depend on this`);
@@ -391,7 +391,7 @@ export function buildTools(
parts.push(
`cochanges: ${cochanges
.slice(0, 3)
.map((c) => c.path)
.map((c: { path: string; count: number }) => c.path)
.join(", ")}${cochanges.length > 3 ? ` +${String(cochanges.length - 3)}` : ""}`,
);
result.output += `\n[impact: ${parts.join(" · ")}]`;
@@ -448,8 +448,8 @@ export function buildTools(
const rel = args.path.startsWith("/")
? args.path.slice(effectiveCwd.length + 1)
: args.path;
const blast = opts.repoMap.getFileBlastRadius(rel);
const cochanges = opts.repoMap.getFileCoChanges(rel);
const blast = await opts.repoMap.getFileBlastRadius(rel);
const cochanges = await opts.repoMap.getFileCoChanges(rel);
if (blast > 0 || cochanges.length > 0) {
const impactParts: string[] = [];
if (blast > 0) impactParts.push(`${String(blast)} files depend on this`);
@@ -457,7 +457,7 @@ export function buildTools(
impactParts.push(
`cochanges: ${cochanges
.slice(0, 3)
.map((c) => c.path)
.map((c: { path: string; count: number }) => c.path)
.join(", ")}${cochanges.length > 3 ? ` +${String(cochanges.length - 3)}` : ""}`,
);
result.output += `\n[impact: ${impactParts.join(" · ")}]`;
@@ -672,11 +672,11 @@ export function buildTools(
),
fresh: z.boolean().optional().describe("Set true to bypass cache and re-execute"),
}),
execute: deferExecute((args) => {
execute: deferExecute(async (args) => {
resetReadCounter();
if (!args.force) {
const hit = tryInterceptGrep(args, opts?.repoMap, effectiveCwd);
if (hit) return Promise.resolve(hit);
const hit = await tryInterceptGrep(args, opts?.repoMap, effectiveCwd);
if (hit) return hit;
}
return grepTool.execute(args);
}),
@@ -695,11 +695,11 @@ export function buildTools(
),
fresh: z.boolean().optional().describe("Set true to bypass cache and re-execute"),
}),
execute: deferExecute((args) => {
execute: deferExecute(async (args) => {
resetReadCounter();
if (!args.force) {
const hit = tryInterceptGlob(args, opts?.repoMap, effectiveCwd);
if (hit) return Promise.resolve(hit);
const hit = await tryInterceptGlob(args, opts?.repoMap, effectiveCwd);
if (hit) return hit;
}
return globTool.execute(args);
}),
@@ -851,11 +851,11 @@ export function buildTools(
"Skip soul map fast-path. Only use after confirming the soul map result was incomplete or stale.",
),
}),
execute: deferExecute((args) => {
execute: deferExecute(async (args) => {
resetReadCounter();
if (!args.force) {
const hit = tryInterceptNavigate(args, opts?.repoMap, effectiveCwd);
if (hit) return Promise.resolve(hit);
const hit = await tryInterceptNavigate(args, opts?.repoMap, effectiveCwd);
if (hit) return hit;
}
return navigateTool.execute(args, opts?.repoMap);
}),
@@ -1005,10 +1005,10 @@ export function buildTools(
"Skip soul map fast-path. Only use after confirming the soul map result was incomplete or stale.",
),
}),
execute: deferExecute((args) => {
execute: deferExecute(async (args) => {
if (!args.force) {
const hit = tryInterceptDiscoverPattern(args, opts?.repoMap, effectiveCwd);
if (hit) return Promise.resolve(hit);
const hit = await tryInterceptDiscoverPattern(args, opts?.repoMap, effectiveCwd);
if (hit) return hit;
}
return discoverPatternTool.execute(args);
}),
@@ -1146,7 +1146,7 @@ export function buildSubagentExploreTools(opts?: {
webSearchModel?: import("ai").LanguageModel;
onApproveWebSearch?: (query: string) => Promise<boolean>;
onApproveFetchPage?: (url: string) => Promise<boolean>;
repoMap?: RepoMap;
repoMap?: IntelligenceClient;
tabId?: string;
}) {
const subagentCwd = process.cwd();
@@ -1189,7 +1189,7 @@ export function buildSubagentExploreTools(opts?: {
}),
execute: deferExecute(async (args) => {
if (!args.force) {
const hit = tryInterceptGrep(args, opts?.repoMap, subagentCwd);
const hit = await tryInterceptGrep(args, opts?.repoMap, subagentCwd);
if (hit) return hit;
}
const result = await grepTool.execute({ ...args, maxCount: 10 });
@@ -1210,10 +1210,10 @@ export function buildSubagentExploreTools(opts?: {
"Skip soul map fast-path. Only use after confirming the soul map result was incomplete or stale.",
),
}),
execute: deferExecute((args) => {
execute: deferExecute(async (args) => {
if (!args.force) {
const hit = tryInterceptGlob(args, opts?.repoMap, subagentCwd);
if (hit) return Promise.resolve(hit);
const hit = await tryInterceptGlob(args, opts?.repoMap, subagentCwd);
if (hit) return hit;
}
return globTool.execute(args);
}),
@@ -1252,10 +1252,10 @@ export function buildSubagentExploreTools(opts?: {
"Skip soul map fast-path. Only use after confirming the soul map result was incomplete or stale.",
),
}),
execute: deferExecute((args) => {
execute: deferExecute(async (args) => {
if (!args.force) {
const hit = tryInterceptNavigate(args, opts?.repoMap, subagentCwd);
if (hit) return Promise.resolve(hit);
const hit = await tryInterceptNavigate(args, opts?.repoMap, subagentCwd);
if (hit) return hit;
}
return navigateTool.execute(args, opts?.repoMap);
}),
@@ -1296,10 +1296,10 @@ export function buildSubagentExploreTools(opts?: {
"Skip soul map fast-path. Only use after confirming the soul map result was incomplete or stale.",
),
}),
execute: deferExecute((args) => {
execute: deferExecute(async (args) => {
if (!args.force) {
const hit = tryInterceptDiscoverPattern(args, opts?.repoMap, subagentCwd);
if (hit) return Promise.resolve(hit);
const hit = await tryInterceptDiscoverPattern(args, opts?.repoMap, subagentCwd);
if (hit) return hit;
}
return discoverPatternTool.execute(args);
}),
@@ -1444,7 +1444,7 @@ export function buildSubagentCodeTools(opts?: {
webSearchModel?: import("ai").LanguageModel;
onApproveWebSearch?: (query: string) => Promise<boolean>;
onApproveFetchPage?: (url: string) => Promise<boolean>;
repoMap?: RepoMap;
repoMap?: IntelligenceClient;
tabId?: string;
tabLabel?: string;
}) {
+26 -23
View File
@@ -1,8 +1,8 @@
import { readdirSync, statSync } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import type { ToolResult } from "../../types/index.js";
import type { RepoMap } from "../intelligence/repo-map.js";
import { isForbidden } from "../security/forbidden.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
interface ListDirArgs {
path?: string;
@@ -17,7 +17,7 @@ interface ListDirArgs {
export const listDirTool = {
name: "list_dir",
description: "List directory contents with file metadata.",
execute: async (args: ListDirArgs, repoMap?: RepoMap): Promise<ToolResult> => {
execute: async (args: ListDirArgs, repoMap?: IntelligenceClient): Promise<ToolResult> => {
try {
const cwd = process.cwd();
const targetPath = args.path ? resolve(args.path) : cwd;
@@ -32,7 +32,7 @@ export const listDirTool = {
// Try repo map first
if (repoMap) {
const dirKey = relPath === "" ? "." : relPath;
const entries = repoMap.listDirectory(dirKey);
const entries = await repoMap.listDirectory(dirKey);
if (entries && entries.length > 0) {
const lines: string[] = [];
for (const e of entries) {
@@ -56,33 +56,36 @@ export const listDirTool = {
}
}
// Fallback: filesystem
let entries: string[];
// Fallback: filesystem (async)
let rawEntries: string[];
try {
entries = readdirSync(targetPath);
rawEntries = await readdir(targetPath);
} catch {
const msg = `Cannot read directory: ${targetPath}`;
return { success: false, output: msg, error: msg };
}
// Filter forbidden entries and sort dirs first
const visible = rawEntries.filter(
(name) =>
(!name.startsWith(".") || name === ".gitignore") && !isForbidden(join(targetPath, name)),
);
const classified = await Promise.all(
visible.map(async (name) => {
try {
const s = await stat(join(targetPath, name));
return { name, isDir: s.isDirectory() };
} catch {
return { name, isDir: false };
}
}),
);
const dirs: string[] = [];
const files: string[] = [];
for (const name of entries) {
if (name.startsWith(".") && name !== ".gitignore") continue;
const full = join(targetPath, name);
if (isForbidden(full)) continue;
try {
const stat = statSync(full);
if (stat.isDirectory()) {
dirs.push(name);
} else {
files.push(name);
}
} catch {
files.push(name);
}
for (const { name, isDir } of classified) {
if (isDir) dirs.push(name);
else files.push(name);
}
const lines: string[] = [];
+9 -4
View File
@@ -1,5 +1,4 @@
import { existsSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { stat as statAsync, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { ToolResult } from "../../types/index.js";
import { analyzeFile } from "../analysis/complexity.js";
@@ -44,8 +43,14 @@ export const multiEditTool = {
return { success: false, output: msg, error: msg };
}
if (!existsSync(filePath)) {
const msg = `File not found: ${filePath}`;
try {
await statAsync(filePath);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
const msg =
code === "EACCES" || code === "EPERM"
? `Permission denied: ${filePath}`
: `File not found: ${filePath}`;
return { success: false, output: msg, error: msg };
}
+2 -2
View File
@@ -49,7 +49,7 @@ const FILE_REQUIRED_ACTIONS = new Set<NavigateAction>([
type RepoMapLike = {
isReady: boolean;
findSymbols(name: string): Array<{ path: string; kind: string; isExported: boolean }>;
findSymbols(name: string): Promise<Array<{ path: string; kind: string; isExported: boolean }>>;
};
type ResolveResult = { resolved: string } | { candidates: string[] } | null;
@@ -104,7 +104,7 @@ async function autoResolveFile(
): Promise<ResolveResult> {
// Tier 1: Repo map (instant SQLite lookup, ~0ms)
if (repoMap?.isReady) {
const matches = repoMap.findSymbols(symbol);
const matches = await repoMap.findSymbols(symbol);
if (matches.length === 1) return { resolved: (matches[0] as { path: string }).path };
if (matches.length > 1) {
const exported = matches.filter((m) => m.isExported);
+16 -11
View File
@@ -1,6 +1,6 @@
import { existsSync, statSync } from "node:fs";
import { stat as statAsync } from "node:fs/promises";
import { extname, resolve } from "node:path";
import { isBinaryFileSync } from "isbinaryfile";
import { isBinaryFile } from "isbinaryfile";
import type { ToolResult } from "../../types";
import { readBufferContent } from "../editor/instance";
import type { SymbolKind } from "../intelligence/types.js";
@@ -71,16 +71,19 @@ export const readFileTool = {
return { success: false, output: msg, error: msg };
}
if (!existsSync(filePath)) {
return {
success: false,
output: `File not found: ${filePath}`,
error: `File not found: ${filePath}`,
};
let fileStat: Awaited<ReturnType<typeof statAsync>>;
try {
fileStat = await statAsync(filePath);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
const msg =
code === "EACCES" || code === "EPERM"
? `Permission denied: ${filePath}`
: `File not found: ${filePath}`;
return { success: false, output: msg, error: msg };
}
const stat = statSync(filePath);
if (stat.isDirectory()) {
if (fileStat.isDirectory()) {
return {
success: false,
output: `Path is a directory: ${filePath}`,
@@ -88,7 +91,9 @@ export const readFileTool = {
};
}
if (isBinaryFileSync(filePath)) {
const stat = fileStat;
if (await isBinaryFile(filePath)) {
const ext = extname(filePath).toLowerCase();
const sizeStr =
stat.size > 1024 * 1024
+9 -9
View File
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { ToolResult } from "../../types/index.js";
import { type CodeIntelligenceRouter, getIntelligenceRouter } from "../intelligence/index.js";
@@ -40,10 +40,10 @@ interface RefactorArgs {
apply?: boolean;
}
function applyEdits(edits: FileEdit[]): void {
async function applyEdits(edits: FileEdit[]): Promise<void> {
for (const edit of edits) {
pushEdit(edit.file, edit.oldContent);
writeFileSync(edit.file, edit.newContent, "utf-8");
await writeFile(edit.file, edit.newContent, "utf-8");
emitFileEdited(edit.file, edit.newContent);
}
}
@@ -62,7 +62,7 @@ async function applyAndDiagnose(
if (diags) beforeMap.set(edit.file, diags);
}
applyEdits(edits);
await applyEdits(edits);
// Run diagnostic diff on each file
try {
@@ -291,7 +291,7 @@ export const refactorTool = {
};
}
if (shouldApply) applyFormatEdits(tracked.value);
if (shouldApply) await applyFormatEdits(tracked.value);
return {
success: true,
output: `Formatted ${file} (${String(tracked.value.edits.length)} edit(s))${shouldApply ? " — applied" : " — pass apply: true to apply"}`,
@@ -329,7 +329,7 @@ export const refactorTool = {
};
}
if (shouldApply) applyFormatEdits(tracked.value);
if (shouldApply) await applyFormatEdits(tracked.value);
return {
success: true,
output: `Formatted ${file} lines ${String(startLine)}-${String(endLine)} (${String(tracked.value.edits.length)} edit(s))${shouldApply ? " — applied" : ""}`,
@@ -383,8 +383,8 @@ export const refactorTool = {
},
};
function applyFormatEdits(formatEdit: FormatEdit): void {
const content = readFileSync(formatEdit.file, "utf-8");
async function applyFormatEdits(formatEdit: FormatEdit): Promise<void> {
const content = await readFile(formatEdit.file, "utf-8");
// Pre-compute line start offsets (1-indexed: lineStarts[1] = offset of line 1)
// Handles both \n and \r\n line endings correctly
@@ -408,6 +408,6 @@ function applyFormatEdits(formatEdit: FormatEdit): void {
}
pushEdit(formatEdit.file, content);
writeFileSync(formatEdit.file, result, "utf-8");
await writeFile(formatEdit.file, result, "utf-8");
emitFileEdited(formatEdit.file, result);
}
+18 -10
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { mkdir, readFile, rename, stat as statAsync, writeFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import type { ToolResult } from "../../types/index.js";
import { getIntelligenceRouter } from "../intelligence/index.js";
@@ -20,11 +20,18 @@ export const renameFileTool = {
const to = resolve(args.to);
const cwd = process.cwd();
if (!existsSync(from)) {
try {
await statAsync(from);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
const msg =
code === "EACCES" || code === "EPERM"
? `Permission denied: ${relative(cwd, from)}`
: `File not found: ${relative(cwd, from)}`;
return {
success: false,
output: `File not found: ${relative(cwd, from)}`,
error: "not found",
output: msg,
error: code === "EACCES" || code === "EPERM" ? "permission denied" : "not found",
};
}
@@ -49,13 +56,14 @@ export const renameFileTool = {
return { success: false, output: "Source and destination are the same", error: "same path" };
}
if (existsSync(to)) {
try {
await statAsync(to);
return {
success: false,
output: `Destination already exists: ${relative(cwd, to)}`,
error: "exists",
};
}
} catch {}
const router = getIntelligenceRouter(cwd);
const language = router.detectLanguage(from);
@@ -80,7 +88,7 @@ export const renameFileTool = {
const forbidden = isForbidden(edit.file);
if (forbidden) continue;
pushEdit(edit.file, edit.oldContent);
writeFileSync(edit.file, edit.newContent, "utf-8");
await writeFile(edit.file, edit.newContent, "utf-8");
emitFileEdited(edit.file, edit.newContent);
router.fileCache.invalidate(edit.file);
appliedFiles.push(edit.file);
@@ -91,13 +99,13 @@ export const renameFileTool = {
// 3. Move the file
const toDir = dirname(to);
if (!existsSync(toDir)) mkdirSync(toDir, { recursive: true });
await mkdir(toDir, { recursive: true });
const originalContent = readFileSync(from, "utf-8");
const originalContent = await readFile(from, "utf-8");
pushEdit(from, originalContent);
try {
renameSync(from, to);
await rename(from, to);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, output: `Failed to move file: ${msg}`, error: "move failed" };
+7 -6
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { existsSync, statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
import type { ToolResult } from "../../types/index.js";
import { getIntelligenceRouter } from "../intelligence/index.js";
@@ -7,12 +8,12 @@ import { isForbidden } from "../security/forbidden.js";
import { pushEdit } from "./edit-stack.js";
import { emitFileEdited } from "./file-events.js";
function applyEdits(edits: FileEdit[]): void {
async function applyEdits(edits: FileEdit[]): Promise<void> {
for (const edit of edits) {
const blocked = isForbidden(edit.file);
if (blocked) throw new Error(`Cannot edit forbidden file: ${edit.file} (${blocked})`);
pushEdit(edit.file, edit.oldContent);
writeFileSync(edit.file, edit.newContent, "utf-8");
await writeFile(edit.file, edit.newContent, "utf-8");
emitFileEdited(edit.file, edit.newContent);
}
}
@@ -485,7 +486,7 @@ export const renameSymbolTool = {
}
if (tracked) {
applyEdits(tracked.value.edits);
await applyEdits(tracked.value.edits);
}
// Always grep for remaining references — catches LSP misses AND handles the
@@ -496,13 +497,13 @@ export const renameSymbolTool = {
const escaped = args.symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
for (const ref of remaining) {
try {
const content = readFileSync(ref, "utf-8");
const content = await readFile(ref, "utf-8");
const refBlocked = isForbidden(ref);
if (refBlocked) continue;
const updated = replaceInCode(content, escaped, args.newName, ref);
if (updated !== content) {
pushEdit(ref, content);
writeFileSync(ref, updated, "utf-8");
await writeFile(ref, updated, "utf-8");
emitFileEdited(ref, updated);
textFixed.push(ref);
}
+24 -23
View File
@@ -1,5 +1,5 @@
import { relative } from "node:path";
import type { RepoMap } from "../intelligence/repo-map.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
interface InterceptResult {
intercepted: true;
@@ -100,20 +100,20 @@ function globToLike(pattern: string): string | null {
return like;
}
export function tryInterceptGlob(
export async function tryInterceptGlob(
args: GlobArgs,
repoMap: RepoMap | undefined,
repoMap: IntelligenceClient | undefined,
cwd: string,
): InterceptResult | null {
): Promise<InterceptResult | null> {
if (!repoMap || !repoMap.isReady) return null;
const like = globToLike(args.pattern);
if (!like) return null;
const matches = repoMap.matchFiles(like);
const matches = await repoMap.matchFiles(like);
if (matches.length === 0) return null;
const fileList = matches.map((p) => ` ${relative(cwd, p)}`).join("\n");
const fileList = matches.map((p: string) => ` ${relative(cwd, p)}`).join("\n");
const output =
`SOUL MAP — ${String(matches.length)} indexed file${matches.length === 1 ? "" : "s"} match "${args.pattern}":\n${fileList}\n` +
`Use read_file on these paths directly — dispatch is not needed. Glob was skipped.`;
@@ -134,11 +134,11 @@ function formatSubstringMatches(
.join("\n");
}
export function tryInterceptDiscoverPattern(
export async function tryInterceptDiscoverPattern(
args: { query: string; file?: string },
repoMap: RepoMap | undefined,
repoMap: IntelligenceClient | undefined,
cwd: string,
): InterceptResult | null {
): Promise<InterceptResult | null> {
if (!repoMap || !repoMap.isReady) return null;
if (args.file) return null;
@@ -146,13 +146,14 @@ export function tryInterceptDiscoverPattern(
const safeQuery = escapeLike(query);
// Exact symbol match first
const exactMatches = repoMap.findSymbols(query);
const exactMatches = await repoMap.findSymbols(query);
// Substring symbol match (e.g. "provider" → "createOpenAIProvider", "ProviderConfig")
const substringMatches = exactMatches.length === 0 ? repoMap.searchSymbolsSubstring(query) : [];
const substringMatches =
exactMatches.length === 0 ? await repoMap.searchSymbolsSubstring(query) : [];
// File path match
const fileMatches = repoMap.matchFiles(`%${safeQuery}%`, 10);
const fileMatches = await repoMap.matchFiles(`%${safeQuery}%`, 10);
if (exactMatches.length === 0 && substringMatches.length === 0 && fileMatches.length === 0)
return null;
@@ -171,7 +172,7 @@ export function tryInterceptDiscoverPattern(
if (fileMatches.length > 0) {
parts.push(`\nFiles matching "${query}" (${String(fileMatches.length)}):`);
parts.push(fileMatches.map((p) => ` ${relative(cwd, p)}`).join("\n"));
parts.push(fileMatches.map((p: string) => ` ${relative(cwd, p)}`).join("\n"));
}
parts.push(
@@ -186,11 +187,11 @@ export function tryInterceptDiscoverPattern(
};
}
export function tryInterceptGrep(
export async function tryInterceptGrep(
args: GrepArgs,
repoMap: RepoMap | undefined,
repoMap: IntelligenceClient | undefined,
cwd: string,
): InterceptResult | null {
): Promise<InterceptResult | null> {
if (!repoMap || !repoMap.isReady) return null;
// Skip when scoped searches — those are legitimate usage lookups
@@ -207,7 +208,7 @@ export function tryInterceptGrep(
if (!symbolName) return null;
// Try exact match first
const exactMatches = repoMap.findSymbols(symbolName);
const exactMatches = await repoMap.findSymbols(symbolName);
if (exactMatches.length > 0) {
const matchList = formatMatches(exactMatches, cwd);
const bestMatch = exactMatches[0] as { path: string; kind: string };
@@ -230,7 +231,7 @@ export function tryInterceptGrep(
}
// Fall back to substring match — "provider" finds "createOpenAIProvider", "ProviderConfig", etc.
const substringMatches = repoMap.searchSymbolsSubstring(symbolName);
const substringMatches = await repoMap.searchSymbolsSubstring(symbolName);
if (substringMatches.length > 0) {
const matchList = formatSubstringMatches(substringMatches, cwd);
const output =
@@ -243,11 +244,11 @@ export function tryInterceptGrep(
return null;
}
export function tryInterceptNavigate(
export async function tryInterceptNavigate(
args: NavigateArgs,
repoMap: RepoMap | undefined,
repoMap: IntelligenceClient | undefined,
cwd: string,
): InterceptResult | null {
): Promise<InterceptResult | null> {
if (!repoMap || !repoMap.isReady) return null;
if (!INTERCEPTABLE_NAVIGATE_ACTIONS.has(args.action)) return null;
@@ -259,7 +260,7 @@ export function tryInterceptNavigate(
if (!symbolName) return null;
// Exact match
const exactMatches = repoMap.findSymbols(symbolName);
const exactMatches = await repoMap.findSymbols(symbolName);
if (exactMatches.length > 0) {
const matchList = formatMatches(exactMatches, cwd);
const bestMatch = exactMatches[0] as { path: string; kind: string };
@@ -281,7 +282,7 @@ export function tryInterceptNavigate(
}
// Substring match
const substringMatches = repoMap.searchSymbolsSubstring(symbolName);
const substringMatches = await repoMap.searchSymbolsSubstring(symbolName);
if (substringMatches.length > 0) {
const matchList = formatSubstringMatches(substringMatches, cwd);
const output =
+9 -3
View File
@@ -2,8 +2,9 @@ import { spawn } from "node:child_process";
import stripAnsi from "strip-ansi";
import type { ToolResult } from "../../types";
import { isForbidden } from "../security/forbidden.js";
import { getIOClient } from "../workers/io-client.js";
import { checkShellBinaryRead } from "./binary-detect.js";
import { compressShellOutputFull } from "./shell-compress.js";
import { compressShellOutputFull as compressLocal } from "./shell-compress.js";
import { saveTee, truncateWithTee } from "./tee.js";
const DEFAULT_TIMEOUT = 30_000;
@@ -384,12 +385,17 @@ export const shellTool = {
if (stderrBytes <= MAX_COLLECT_BYTES) errChunks.push(data.toString());
});
proc.on("close", (code: number | null) => {
proc.on("close", async (code: number | null) => {
let raw = sanitizeOutput(chunks.join(""));
if (stdoutBytes > MAX_COLLECT_BYTES) {
raw += `\n[output truncated — ${String(Math.round(stdoutBytes / 1024))}KB total, showing first ${String(Math.round(MAX_COLLECT_BYTES / 1024))}KB]`;
}
const compressed = compressShellOutputFull(raw);
let compressed: { text: string; original: string | null };
try {
compressed = await getIOClient().compressShellOutputFull(raw);
} catch {
compressed = compressLocal(raw);
}
let stdout = compressed.text;
const stderr = sanitizeOutput(errChunks.join(""));
+64 -49
View File
@@ -1,11 +1,11 @@
import { extname, relative } from "node:path";
import type { ToolResult } from "../../types";
import type { RepoMap } from "../intelligence/repo-map.js";
import {
IMPORT_TRACKABLE_LANGUAGES,
INDEXABLE_EXTENSIONS,
} from "../intelligence/repo-map-utils.js";
import { isForbidden } from "../security/forbidden.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
type AnalyzeAction =
| "identifier_frequency"
@@ -28,7 +28,7 @@ export const soulAnalyzeTool = {
name: "soul_analyze",
description: "Codebase analysis from Soul Map — zero file I/O, instant results.",
createExecute: (repoMap?: RepoMap) => {
createExecute: (repoMap?: IntelligenceClient) => {
return async (args: SoulAnalyzeArgs): Promise<ToolResult> => {
if (!repoMap?.isReady) {
return {
@@ -42,19 +42,19 @@ export const soulAnalyzeTool = {
switch (args.action) {
case "identifier_frequency":
return identifierFrequency(repoMap, cwd, args.name, args.limit);
return await identifierFrequency(repoMap, cwd, args.name, args.limit);
case "unused_exports":
return unusedExports(repoMap, cwd, args.limit);
return await unusedExports(repoMap, cwd, args.limit);
case "file_profile":
return fileProfile(repoMap, cwd, args.file);
return await fileProfile(repoMap, cwd, args.file);
case "duplication":
return duplication(repoMap, cwd, args.file, args.limit);
return await duplication(repoMap, cwd, args.file, args.limit);
case "top_files":
return topFiles(repoMap, cwd, args.limit);
return await topFiles(repoMap, cwd, args.limit);
case "packages":
return packages(repoMap, args.name, args.limit);
return await packages(repoMap, args.name, args.limit);
case "symbols_by_kind":
return symbolsByKind(repoMap, cwd, args.kind, args.name, args.limit);
return await symbolsByKind(repoMap, cwd, args.kind, args.name, args.limit);
default:
return {
success: false,
@@ -66,16 +66,16 @@ export const soulAnalyzeTool = {
},
};
function identifierFrequency(
repoMap: RepoMap,
async function identifierFrequency(
repoMap: IntelligenceClient,
cwd: string,
name: string | undefined,
limit: number | undefined,
): ToolResult {
): Promise<ToolResult> {
if (name) {
const symbols = repoMap.findSymbols(name);
const freq = repoMap.getIdentifierFrequency(500);
const match = freq.find((f) => f.name === name);
const symbols = await repoMap.findSymbols(name);
const freq = await repoMap.getIdentifierFrequency(500);
const match = freq.find((f: { name: string; fileCount: number }) => f.name === name);
const lines: string[] = [`Identifier "${name}":`];
@@ -97,7 +97,7 @@ function identifierFrequency(
return { success: true, output: lines.join("\n") };
}
const entries = repoMap.getIdentifierFrequency(limit ?? 25);
const entries = await repoMap.getIdentifierFrequency(limit ?? 25);
if (entries.length === 0) {
return { success: true, output: "No identifiers indexed." };
}
@@ -112,10 +112,14 @@ function identifierFrequency(
return { success: true, output: lines.join("\n") };
}
function unusedExports(repoMap: RepoMap, cwd: string, limit: number | undefined): ToolResult {
const unused = repoMap.getUnusedExports(limit ?? 500);
const testOnly = repoMap.getTestOnlyExports();
const deadBarrels = repoMap.getDeadBarrels();
async function unusedExports(
repoMap: IntelligenceClient,
cwd: string,
limit: number | undefined,
): Promise<ToolResult> {
const unused = await repoMap.getUnusedExports(limit ?? 500);
const testOnly = await repoMap.getTestOnlyExports();
const deadBarrels = await repoMap.getDeadBarrels();
if (unused.length === 0 && testOnly.length === 0 && deadBarrels.length === 0) {
return {
@@ -172,10 +176,10 @@ function unusedExports(repoMap: RepoMap, cwd: string, limit: number | undefined)
}> = [];
for (const [file, entry] of byFile) {
const totalExported = repoMap.getFileExportCount(file);
const totalExported = await repoMap.getFileExportCount(file);
const totalDead = entry.dead.length + entry.unnecessary.length;
const allDead = totalExported > 0 && totalDead >= totalExported;
const hasDependents = repoMap.getFileDependents(file).length > 0;
const hasDependents = (await repoMap.getFileDependents(file)).length > 0;
if (allDead && !hasDependents && canTrackFileImports(file)) {
deadFiles.push({
@@ -301,7 +305,11 @@ function unusedExports(repoMap: RepoMap, cwd: string, limit: number | undefined)
return { success: true, output: lines.join("\n") };
}
function fileProfile(repoMap: RepoMap, cwd: string, file: string | undefined): ToolResult {
async function fileProfile(
repoMap: IntelligenceClient,
cwd: string,
file: string | undefined,
): Promise<ToolResult> {
if (!file) {
return {
success: false,
@@ -320,11 +328,11 @@ function fileProfile(repoMap: RepoMap, cwd: string, file: string | undefined): T
const relPath = file.startsWith("/") ? relative(cwd, file) : file;
const deps = repoMap.getFileDependencies(relPath);
const dependents = repoMap.getFileDependents(relPath);
const cochanges = repoMap.getFileCoChanges(relPath);
const blastRadius = repoMap.getFileBlastRadius(relPath);
const symbols = repoMap.getFileSymbols(relPath);
const deps = await repoMap.getFileDependencies(relPath);
const dependents = await repoMap.getFileDependents(relPath);
const cochanges = await repoMap.getFileCoChanges(relPath);
const blastRadius = await repoMap.getFileBlastRadius(relPath);
const symbols = await repoMap.getFileSymbols(relPath);
if (deps.length === 0 && dependents.length === 0 && symbols.length === 0) {
return { success: true, output: `"${relPath}" not found in soul map index.` };
@@ -338,8 +346,11 @@ function fileProfile(repoMap: RepoMap, cwd: string, file: string | undefined): T
if (symbols.length > 0) {
lines.push(`Exports (${String(symbols.length)}):`);
for (const s of symbols) {
const sigs = repoMap.getSymbolSignature(s.name);
const sig = sigs.find((x) => x.path === relPath || x.path.endsWith(`/${relPath}`));
const sigs = await repoMap.getSymbolSignature(s.name);
const sig = sigs.find(
(x: { path: string; kind: string; signature: string | null; line: number }) =>
x.path === relPath || x.path.endsWith(`/${relPath}`),
);
lines.push(` ${sig?.signature ?? `${s.kind} ${s.name}`}`);
}
lines.push("");
@@ -376,12 +387,12 @@ function fileProfile(repoMap: RepoMap, cwd: string, file: string | undefined): T
return { success: true, output: lines.join("\n") };
}
function duplication(
repoMap: RepoMap,
async function duplication(
repoMap: IntelligenceClient,
cwd: string,
file: string | undefined,
limit: number | undefined,
): ToolResult {
): Promise<ToolResult> {
if (file) {
if (isForbidden(file) !== null) {
return {
@@ -391,7 +402,7 @@ function duplication(
};
}
const relPath = file.startsWith("/") ? relative(cwd, file) : file;
const fileDups = repoMap.getFileDuplicates(relPath);
const fileDups = await repoMap.getFileDuplicates(relPath);
if (fileDups.length === 0) {
return { success: true, output: `No structural clones found for functions in "${relPath}".` };
}
@@ -414,7 +425,7 @@ function duplication(
const cap = limit ?? 15;
const lines: string[] = [];
const clusters = repoMap.getDuplicateStructures(cap);
const clusters = await repoMap.getDuplicateStructures(cap);
if (clusters.length > 0) {
lines.push(`Exact structural clones (${String(clusters.length)} groups):\n`);
for (const cluster of clusters) {
@@ -432,7 +443,7 @@ function duplication(
}
}
const nearDups = repoMap.getNearDuplicates(0.7, cap);
const nearDups = await repoMap.getNearDuplicates(0.7, cap);
if (nearDups.length > 0) {
lines.push(`Near-duplicates (>70% token similarity, ${String(nearDups.length)} pairs):\n`);
for (const pair of nearDups) {
@@ -445,7 +456,7 @@ function duplication(
}
}
const fragments = repoMap.getRepeatedFragments(cap);
const fragments = await repoMap.getRepeatedFragments(cap);
if (fragments.length > 0) {
lines.push(
`Repeated code fragments (${String(fragments.length)} patterns across multiple functions):\n`,
@@ -472,8 +483,12 @@ function duplication(
return { success: true, output: lines.join("\n") };
}
function topFiles(repoMap: RepoMap, cwd: string, limit: number | undefined): ToolResult {
const files = repoMap.getTopFiles(limit ?? 20);
async function topFiles(
repoMap: IntelligenceClient,
cwd: string,
limit: number | undefined,
): Promise<ToolResult> {
const files = await repoMap.getTopFiles(limit ?? 20);
if (files.length === 0) {
return { success: true, output: "No files indexed." };
}
@@ -490,13 +505,13 @@ function topFiles(repoMap: RepoMap, cwd: string, limit: number | undefined): Too
return { success: true, output: lines.join("\n") };
}
function packages(
repoMap: RepoMap,
async function packages(
repoMap: IntelligenceClient,
name: string | undefined,
limit: number | undefined,
): ToolResult {
): Promise<ToolResult> {
if (name) {
const files = repoMap.getFilesByPackage(name);
const files = await repoMap.getFilesByPackage(name);
if (files.length === 0) {
return { success: true, output: `No files import "${name}" (or package not indexed).` };
}
@@ -510,7 +525,7 @@ function packages(
return { success: true, output: lines.join("\n") };
}
const pkgs = repoMap.getExternalPackages(limit ?? 20);
const pkgs = await repoMap.getExternalPackages(limit ?? 20);
if (pkgs.length === 0) {
return { success: true, output: "No external packages detected." };
}
@@ -524,15 +539,15 @@ function packages(
return { success: true, output: lines.join("\n") };
}
function symbolsByKind(
repoMap: RepoMap,
async function symbolsByKind(
repoMap: IntelligenceClient,
cwd: string,
kind: string | undefined,
name: string | undefined,
limit: number | undefined,
): ToolResult {
): Promise<ToolResult> {
if (name) {
const sigs = repoMap.getSymbolSignature(name);
const sigs = await repoMap.getSymbolSignature(name);
if (sigs.length === 0) {
return { success: true, output: `Symbol "${name}" not found in index.` };
}
@@ -556,7 +571,7 @@ function symbolsByKind(
};
}
const symbols = repoMap.getSymbolsByKind(kind, limit ?? 30);
const symbols = await repoMap.getSymbolsByKind(kind, limit ?? 30);
if (symbols.length === 0) {
return { success: true, output: `No exported ${kind} symbols found.` };
}
+58 -41
View File
@@ -1,8 +1,8 @@
import { spawn } from "node:child_process";
import { relative, resolve } from "node:path";
import type { ToolResult } from "../../types";
import type { RepoMap } from "../intelligence/repo-map.js";
import { isForbidden } from "../security/forbidden.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
interface SoulFindArgs {
query: string;
@@ -14,16 +14,18 @@ export const soulFindTool = {
name: "soul_find",
description: "Fuzzy file and symbol search ranked by importance. Supports multi-word queries.",
createExecute: (repoMap?: RepoMap) => {
createExecute: (repoMap?: IntelligenceClient) => {
return async (args: SoulFindArgs): Promise<ToolResult> => {
const { query } = args;
const limit = args.limit ?? 20;
const cwd = process.cwd();
const repoMapResults = repoMap?.isReady ? searchRepoMap(repoMap, query, cwd, limit) : null;
const repoMapResults = repoMap?.isReady
? await searchIntelligenceClient(repoMap, query, cwd, limit)
: null;
if (repoMapResults && repoMapResults.length > 0) {
const symbolDetails = buildSymbolDetails(repoMap, repoMapResults);
const symbolDetails = await buildSymbolDetails(repoMap, repoMapResults);
return { success: true, output: symbolDetails };
}
@@ -33,8 +35,8 @@ export const soulFindTool = {
}
const enriched = repoMap?.isReady
? enrichWithSymbols(repoMap, fileResults, cwd)
: fileResults.map((f) => ` ${relative(cwd, resolve(f))}`).join("\n");
? await enrichWithSymbols(repoMap, fileResults, cwd)
: fileResults.map((f: string) => ` ${relative(cwd, resolve(f))}`).join("\n");
return {
success: true,
@@ -52,25 +54,30 @@ interface RankedFile {
symbols: Array<{ name: string; kind: string }>;
}
function searchRepoMap(repoMap: RepoMap, query: string, cwd: string, limit: number): RankedFile[] {
async function searchIntelligenceClient(
repoMap: IntelligenceClient,
query: string,
cwd: string,
limit: number,
): Promise<RankedFile[]> {
const fileMap = new Map<string, RankedFile>();
const words = query.split(/\s+/).filter((w) => w.length >= 2);
const words = query.split(/\s+/).filter((w: string) => w.length >= 2);
const primaryWord = words[0] ?? query;
const exactSymbols = repoMap.findSymbols(primaryWord);
const exactSymbols = await repoMap.findSymbols(primaryWord);
for (const sym of exactSymbols) {
const rel = relative(cwd, sym.path);
upsertFile(fileMap, rel, sym.path, sym.pagerank + 10, { name: primaryWord, kind: sym.kind });
}
const substringSymbols = repoMap.searchSymbolsSubstring(primaryWord, 30);
const substringSymbols = await repoMap.searchSymbolsSubstring(primaryWord, 30);
for (const sym of substringSymbols) {
const rel = relative(cwd, sym.path);
upsertFile(fileMap, rel, sym.path, sym.pagerank + 3, { name: sym.name, kind: sym.kind });
}
for (const word of words.slice(1)) {
const extra = repoMap.searchSymbolsSubstring(word, 15);
const extra = await repoMap.searchSymbolsSubstring(word, 15);
for (const sym of extra) {
const rel = relative(cwd, sym.path);
const existing = fileMap.get(rel);
@@ -84,7 +91,7 @@ function searchRepoMap(repoMap: RepoMap, query: string, cwd: string, limit: numb
}
const safeQuery = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
const fileMatches = repoMap.matchFiles(`%${safeQuery}%`, 30);
const fileMatches = await repoMap.matchFiles(`%${safeQuery}%`, 30);
for (const absPath of fileMatches) {
const rel = relative(cwd, absPath);
const nameScore = fuzzyScoreMultiWord(words, rel);
@@ -106,7 +113,7 @@ function searchRepoMap(repoMap: RepoMap, query: string, cwd: string, limit: numb
if (words.length > 1) {
for (const word of words) {
const wordSafe = word.replace(/%/g, "\\%").replace(/_/g, "\\_");
const wordFiles = repoMap.matchFiles(`%${wordSafe}%`, 15);
const wordFiles = await repoMap.matchFiles(`%${wordSafe}%`, 15);
for (const absPath of wordFiles) {
const rel = relative(cwd, absPath);
if (!fileMap.has(rel)) {
@@ -123,12 +130,12 @@ function searchRepoMap(repoMap: RepoMap, query: string, cwd: string, limit: numb
}
const topFiles = [...fileMap.values()]
.filter((f) => f.score > 0)
.sort((a, b) => b.score - a.score)
.filter((f: RankedFile) => f.score > 0)
.sort((a: RankedFile, b: RankedFile) => b.score - a.score)
.slice(0, 5);
for (const top of topFiles) {
const cochanges = repoMap.getFileCoChanges(top.relPath);
const cochanges = await repoMap.getFileCoChanges(top.relPath);
for (const co of cochanges) {
if (isForbidden(co.path) !== null) continue;
const existing = fileMap.get(co.path);
@@ -147,8 +154,8 @@ function searchRepoMap(repoMap: RepoMap, query: string, cwd: string, limit: numb
}
return [...fileMap.values()]
.filter((f) => isForbidden(f.relPath) === null && isForbidden(f.path) === null)
.sort((a, b) => b.score - a.score)
.filter((f: RankedFile) => isForbidden(f.relPath) === null && isForbidden(f.path) === null)
.sort((a: RankedFile, b: RankedFile) => b.score - a.score)
.slice(0, limit);
}
@@ -177,25 +184,31 @@ function upsertFile(
}
}
function buildSymbolDetails(repoMap: RepoMap | undefined, results: RankedFile[]): string {
async function buildSymbolDetails(
repoMap: IntelligenceClient | undefined,
results: RankedFile[],
): Promise<string> {
const lines: string[] = [`${String(results.length)} results:\n`];
for (const r of results) {
lines.push(` ${r.relPath}`);
if (repoMap) {
const matchedNames = new Set(r.symbols.map((s) => s.name));
const matchedNames = new Set(r.symbols.map((s: { name: string; kind: string }) => s.name));
for (const sym of r.symbols.slice(0, 4)) {
const sigs = repoMap.getSymbolSignature(sym.name);
const sig = sigs.find((s) => s.path === r.relPath || s.path.endsWith(`/${r.relPath}`));
const sigs = await repoMap.getSymbolSignature(sym.name);
const sig = sigs.find(
(s: { path: string; signature: string | null }) =>
s.path === r.relPath || s.path.endsWith(`/${r.relPath}`),
);
lines.push(` ${sig?.signature ?? `${sym.kind} ${sym.name}`}`);
}
const extra = repoMap
.getFileSymbols(r.relPath)
.filter((fs) => !matchedNames.has(fs.name))
const allFileSymbols = await repoMap.getFileSymbols(r.relPath);
const extra = allFileSymbols
.filter((fs: { name: string }) => !matchedNames.has(fs.name))
.slice(0, 3);
if (extra.length > 0) {
lines.push(` also: ${extra.map((s) => s.name).join(", ")}`);
lines.push(` also: ${extra.map((s: { name: string }) => s.name).join(", ")}`);
}
} else {
for (const sym of r.symbols.slice(0, 4)) {
@@ -345,19 +358,23 @@ function fallbackFind(typeFilter: string | undefined): Promise<string[]> {
});
}
function enrichWithSymbols(repoMap: RepoMap, files: string[], cwd: string): string {
return files
.map((f) => {
const rel = relative(cwd, resolve(f));
const syms = repoMap.getFileSymbols(rel);
const symStr =
syms.length > 0
? `\n ${syms
.slice(0, 5)
.map((s) => `${s.kind} ${s.name}`)
.join(", ")}`
: "";
return ` ${rel}${symStr}`;
})
.join("\n");
async function enrichWithSymbols(
repoMap: IntelligenceClient,
files: string[],
cwd: string,
): Promise<string> {
const results: string[] = [];
for (const f of files) {
const rel = relative(cwd, resolve(f));
const syms = await repoMap.getFileSymbols(rel);
const symStr =
syms.length > 0
? `\n ${syms
.slice(0, 5)
.map((s: { kind: string; name: string }) => `${s.kind} ${s.name}`)
.join(", ")}`
: "";
results.push(` ${rel}${symStr}`);
}
return results.join("\n");
}
+13 -8
View File
@@ -1,9 +1,9 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import type { ToolResult } from "../../types";
import type { RepoMap } from "../intelligence/repo-map.js";
import { isForbidden } from "../security/forbidden.js";
import { getVendoredPath } from "../setup/install.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
import { enrichWithSymbolContext } from "./grep.js";
const ENRICHMENT_TIMEOUT_MS = 2000;
@@ -48,13 +48,13 @@ export const soulGrepTool = {
name: "soul_grep",
description:
"Token-efficient search with count mode and word-boundary matching. Count mode returns per-file counts. Non-count mode includes symbol context.",
createExecute: (repoMap?: RepoMap) => {
createExecute: (repoMap?: IntelligenceClient) => {
return async (args: SoulGrepArgs): Promise<ToolResult> => {
const { pattern, count, wordBoundary, dep } = args;
const searchPath = dep ? resolveDepPath(dep, args.path) : (args.path ?? ".");
if (!dep && count && wordBoundary && repoMap?.isReady && !args.path && !args.glob) {
const intercept = tryRepoMapCount(repoMap, pattern);
const intercept = await tryIntelligenceClientCount(repoMap, pattern);
if (intercept) return intercept;
}
@@ -86,14 +86,17 @@ export const soulGrepTool = {
},
};
function tryRepoMapCount(repoMap: RepoMap, pattern: string): ToolResult | null {
async function tryIntelligenceClientCount(
repoMap: IntelligenceClient,
pattern: string,
): Promise<ToolResult | null> {
if (/[^a-zA-Z0-9_$]/.test(pattern)) return null;
const freq = repoMap.getIdentifierFrequency(500);
const match = freq.find((f) => f.name === pattern);
const freq = await repoMap.getIdentifierFrequency(500);
const match = freq.find((f: { name: string; fileCount: number }) => f.name === pattern);
if (!match) return null;
const symbols = repoMap.findSymbols(pattern);
const symbols = await repoMap.findSymbols(pattern);
const lines = [
`${pattern}: referenced in ${String(match.fileCount)} files (from soul map index)`,
];
@@ -107,7 +110,9 @@ function tryRepoMapCount(repoMap: RepoMap, pattern: string): ToolResult | null {
}
}
const nearby = freq.filter((f) => f.name !== pattern).slice(0, 5);
const nearby = freq
.filter((f: { name: string; fileCount: number }) => f.name !== pattern)
.slice(0, 5);
if (nearby.length > 0) {
lines.push("");
lines.push("Top identifiers for comparison:");
+17 -17
View File
@@ -1,7 +1,7 @@
import { relative } from "node:path";
import type { ToolResult } from "../../types";
import type { RepoMap } from "../intelligence/repo-map.js";
import { isForbidden } from "../security/forbidden.js";
import type { IntelligenceClient } from "../workers/intelligence-client.js";
type ImpactAction = "dependents" | "dependencies" | "cochanges" | "blast_radius";
@@ -14,7 +14,7 @@ export const soulImpactTool = {
name: "soul_impact",
description: "Dependency graph queries: dependents, dependencies, cochanges, blast_radius.",
createExecute: (repoMap?: RepoMap) => {
createExecute: (repoMap?: IntelligenceClient) => {
return async (args: SoulImpactArgs): Promise<ToolResult> => {
if (!repoMap?.isReady) {
return { success: false, output: "Soul map not ready.", error: "not ready" };
@@ -33,13 +33,13 @@ export const soulImpactTool = {
switch (args.action) {
case "dependents":
return showDependents(repoMap, relPath);
return await showDependents(repoMap, relPath);
case "dependencies":
return showDependencies(repoMap, relPath);
return await showDependencies(repoMap, relPath);
case "cochanges":
return showCoChanges(repoMap, relPath);
return await showCoChanges(repoMap, relPath);
case "blast_radius":
return showBlastRadius(repoMap, relPath);
return await showBlastRadius(repoMap, relPath);
default:
return {
success: false,
@@ -51,8 +51,8 @@ export const soulImpactTool = {
},
};
function showDependents(repoMap: RepoMap, relPath: string): ToolResult {
const dependents = repoMap.getFileDependents(relPath);
async function showDependents(repoMap: IntelligenceClient, relPath: string): Promise<ToolResult> {
const dependents = await repoMap.getFileDependents(relPath);
if (dependents.length === 0) {
return { success: true, output: `No files depend on "${relPath}" (or file not indexed).` };
}
@@ -65,8 +65,8 @@ function showDependents(repoMap: RepoMap, relPath: string): ToolResult {
return { success: true, output: lines.join("\n") };
}
function showDependencies(repoMap: RepoMap, relPath: string): ToolResult {
const deps = repoMap.getFileDependencies(relPath);
async function showDependencies(repoMap: IntelligenceClient, relPath: string): Promise<ToolResult> {
const deps = await repoMap.getFileDependencies(relPath);
if (deps.length === 0) {
return {
success: true,
@@ -82,8 +82,8 @@ function showDependencies(repoMap: RepoMap, relPath: string): ToolResult {
return { success: true, output: lines.join("\n") };
}
function showCoChanges(repoMap: RepoMap, relPath: string): ToolResult {
const cochanges = repoMap.getFileCoChanges(relPath);
async function showCoChanges(repoMap: IntelligenceClient, relPath: string): Promise<ToolResult> {
const cochanges = await repoMap.getFileCoChanges(relPath);
if (cochanges.length === 0) {
return { success: true, output: `No co-change partners found for "${relPath}".` };
}
@@ -98,11 +98,11 @@ function showCoChanges(repoMap: RepoMap, relPath: string): ToolResult {
return { success: true, output: lines.join("\n") };
}
function showBlastRadius(repoMap: RepoMap, relPath: string): ToolResult {
const dependents = repoMap.getFileDependents(relPath);
const cochanges = repoMap.getFileCoChanges(relPath);
const blastCount = repoMap.getFileBlastRadius(relPath);
const symbols = repoMap.getFileSymbols(relPath);
async function showBlastRadius(repoMap: IntelligenceClient, relPath: string): Promise<ToolResult> {
const dependents = await repoMap.getFileDependents(relPath);
const cochanges = await repoMap.getFileCoChanges(relPath);
const blastCount = await repoMap.getFileBlastRadius(relPath);
const symbols = await repoMap.getFileSymbols(relPath);
if (dependents.length === 0 && cochanges.length === 0 && symbols.length === 0) {
return { success: true, output: `"${relPath}" not found in soul map index.` };
+445
View File
@@ -0,0 +1,445 @@
import { join } from "node:path";
import { useWorkerStore } from "../../stores/workers.js";
import type { RepoMapOptions, SymbolForSummary } from "../intelligence/repo-map.js";
import { WorkerClient } from "./rpc.js";
type SummaryGenerator = (
batch: SymbolForSummary[],
) => Promise<Array<{ name: string; summary: string }>>;
interface RepoMapStats {
files: number;
symbols: number;
edges: number;
summaries: number;
calls: number;
}
interface SummaryBreakdown {
ast: number;
llm: number;
synthetic: number;
total: number;
eligible: number;
}
export class IntelligenceClient extends WorkerClient {
// Cached sync values (pushed from worker via events)
private _isReady = false;
private _cwd: string;
private _stats: RepoMapStats = { files: 0, symbols: 0, edges: 0, summaries: 0, calls: 0 };
private _dbSize = 0;
private _semanticMode: "off" | "ast" | "synthetic" | "llm" | "full" | "on" = "off";
onProgress: ((indexed: number, total: number) => void) | null = null;
onScanComplete: ((success: boolean) => void) | null = null;
onStaleSymbols: ((count: number) => void) | null = null;
private static readonly SCAN_TIMEOUT = 300_000; // 5 min for full repo scan
constructor(cwd: string) {
const workerPath = join(import.meta.dir, "intelligence.worker.ts");
super(workerPath, { cwd });
this._cwd = cwd;
const ws = useWorkerStore.getState();
ws.markStarted("intelligence");
this.onStatusChange = (status) => {
const store = useWorkerStore.getState();
switch (status) {
case "starting":
store.setWorkerStatus("intelligence", "starting");
break;
case "ready":
store.markStarted("intelligence");
break;
case "crashed":
store.setWorkerError("intelligence", "Worker crashed unexpectedly");
break;
case "restarting":
store.incrementRestarts("intelligence");
store.setWorkerStatus("intelligence", "restarting");
break;
}
};
this.onRpcStart = () => {
useWorkerStore.getState().updateRpcInFlight("intelligence", 1);
};
this.onRpcEnd = (error) => {
const store = useWorkerStore.getState();
store.updateRpcInFlight("intelligence", -1);
store.incrementCalls("intelligence");
if (error) store.incrementErrors("intelligence");
};
this.on("init-error", (data) => {
const d = data as { message: string };
useWorkerStore.getState().setWorkerError("intelligence", d.message);
});
this.on("progress", (data) => {
const d = data as {
indexed: number;
total: number;
stats: RepoMapStats;
dbSize: number;
};
this._stats = d.stats;
this._dbSize = d.dbSize;
this.onProgress?.(d.indexed, d.total);
});
this.on("scan-complete", (data) => {
const d = data as {
success: boolean;
stats: RepoMapStats;
dbSize: number;
isReady: boolean;
};
this._isReady = d.isReady;
this._stats = d.stats;
this._dbSize = d.dbSize;
if (d.success) this.resetRestartCount();
this.onScanComplete?.(d.success);
});
this.on("stale-symbols", (data) => {
const d = data as { count: number };
this.onStaleSymbols?.(d.count);
});
}
// ── Cached Sync Getters ────────────────────────────────────────────
get isReady(): boolean {
return this._isReady;
}
getCwd(): string {
return this._cwd;
}
// ── Core ───────────────────────────────────────────────────────────
async scan(): Promise<void> {
await this.callWithTimeout<void>(IntelligenceClient.SCAN_TIMEOUT, "scan");
}
async close(): Promise<void> {
await this.call<void>("close");
this.dispose();
}
clear(): Promise<void> {
return this.call<void>("clear");
}
// ── Semantic ───────────────────────────────────────────────────────
setSemanticMode(mode: "off" | "ast" | "synthetic" | "llm" | "full" | "on"): void {
this._semanticMode = mode;
this.fire("setSemanticMode", mode);
}
getSemanticMode(): "off" | "ast" | "synthetic" | "llm" | "full" | "on" {
return this._semanticMode;
}
isSemanticEnabled(): boolean {
return this._semanticMode !== "off";
}
async detectPersistedSemanticMode(): Promise<
"off" | "ast" | "synthetic" | "llm" | "full" | "on"
> {
const mode = await this.call<"off" | "ast" | "synthetic" | "llm" | "full" | "on">(
"detectPersistedSemanticMode",
);
this._semanticMode = mode;
return mode;
}
setSummaryGenerator(generator: SummaryGenerator | null): void {
if (generator) {
this.registerCallback("summaryGenerator", async (data) => {
return generator(data as SymbolForSummary[]);
});
}
}
async generateAstSummaries(): Promise<number> {
return this.call<number>("generateAstSummaries");
}
async generateSyntheticSummaries(limit?: number): Promise<number> {
return this.call<number>("generateSyntheticSummaries", limit);
}
async generateSemanticSummaries(maxSymbols?: number): Promise<number> {
return this.callWithTimeout<number>(
IntelligenceClient.SCAN_TIMEOUT,
"generateSemanticSummaries",
maxSymbols,
);
}
clearFreeSummaries(): void {
this.fire("clearFreeSummaries");
}
clearSemanticSummaries(): void {
this.fire("clearSemanticSummaries");
}
async getStaleSummaryCount(): Promise<number> {
return this.call<number>("getStaleSummaryCount");
}
async getSummaryBreakdown(): Promise<SummaryBreakdown> {
return this.call<SummaryBreakdown>("getSummaryBreakdown");
}
// ── File Monitoring ────────────────────────────────────────────────
onFileChanged(absPath: string): void {
this.fire("onFileChanged", absPath);
}
recheckModifiedFiles(): void {
this.fire("recheckModifiedFiles");
}
// ── Render ─────────────────────────────────────────────────────────
async render(opts: RepoMapOptions = {}): Promise<string> {
return this.call<string>("render", opts);
}
// ── Symbol Lookup ──────────────────────────────────────────────────
async findSymbols(
name: string,
): Promise<Array<{ path: string; kind: string; isExported: boolean; pagerank: number }>> {
return this.call("findSymbols", name);
}
async findSymbol(name: string): Promise<string | null> {
return this.call("findSymbol", name);
}
async searchSymbolsSubstring(
query: string,
limit?: number,
): Promise<
Array<{ name: string; path: string; kind: string; isExported: boolean; pagerank: number }>
> {
return this.call("searchSymbolsSubstring", query, limit);
}
async getFileSymbols(
relPath: string,
): Promise<Array<{ name: string; kind: string; isExported: boolean }>> {
return this.call("getFileSymbols", relPath);
}
async getFileSymbolRanges(
relPath: string,
): Promise<Array<{ name: string; kind: string; line: number; endLine: number | null }>> {
return this.call("getFileSymbolRanges", relPath);
}
async getSymbolSignature(
name: string,
): Promise<Array<{ path: string; kind: string; signature: string | null; line: number }>> {
return this.call("getSymbolSignature", name);
}
async getSymbolsByKind(
kind: string,
limit?: number,
): Promise<Array<{ name: string; path: string; signature: string | null; line: number }>> {
return this.call("getSymbolsByKind", kind, limit);
}
// ── File Analysis ──────────────────────────────────────────────────
async matchFiles(pattern: string, limit?: number): Promise<string[]> {
return this.call("matchFiles", pattern, limit);
}
async getFileDependents(relPath: string): Promise<Array<{ path: string; weight: number }>> {
return this.call("getFileDependents", relPath);
}
async getFileDependencies(relPath: string): Promise<Array<{ path: string; weight: number }>> {
return this.call("getFileDependencies", relPath);
}
async getFileCoChanges(relPath: string): Promise<Array<{ path: string; count: number }>> {
return this.call("getFileCoChanges", relPath);
}
async getFileExportCount(relPath: string): Promise<number> {
return this.call("getFileExportCount", relPath);
}
async getFileBlastRadius(relPath: string): Promise<number> {
return this.call("getFileBlastRadius", relPath);
}
async getFilesByPackage(pkg: string): Promise<Array<{ path: string; specifiers: string }>> {
return this.call("getFilesByPackage", pkg);
}
async listDirectory(dirPath: string): Promise<Array<{
name: string;
type: "file" | "dir";
language?: string;
lines?: number;
symbols?: number;
importance?: number;
}> | null> {
return this.call("listDirectory", dirPath);
}
// ── Code Analysis ─────────────────────────────────────────────────
async getIdentifierFrequency(
limit?: number,
): Promise<Array<{ name: string; fileCount: number }>> {
return this.call("getIdentifierFrequency", limit);
}
async getUnusedExports(limit?: number): Promise<
Array<{
name: string;
path: string;
kind: string;
lineCount: number;
usedInternally: boolean;
}>
> {
return this.call("getUnusedExports", limit);
}
async getTestOnlyExports(): Promise<Array<{ name: string; path: string; kind: string }>> {
return this.call("getTestOnlyExports");
}
async getDeadBarrels(): Promise<Array<{ path: string; lineCount: number; language: string }>> {
return this.call("getDeadBarrels");
}
async getRepeatedFragments(limit?: number): Promise<
Array<{
count: number;
locations: Array<{ name: string; path: string; line: number }>;
}>
> {
return this.call("getRepeatedFragments", limit);
}
async getDuplicateStructures(limit?: number): Promise<
Array<{
shapeHash: string;
kind: string;
nodeCount: number;
members: Array<{ name: string; path: string; line: number; endLine: number }>;
}>
> {
return this.call("getDuplicateStructures", limit);
}
async getNearDuplicates(
threshold?: number,
limit?: number,
): Promise<
Array<{
similarity: number;
a: { name: string; path: string; line: number; endLine: number };
b: { name: string; path: string; line: number; endLine: number };
}>
> {
return this.call("getNearDuplicates", threshold, limit);
}
async getFileDuplicates(relPath: string): Promise<
Array<{
name: string;
line: number;
clones: Array<{ name: string; path: string; line: number }>;
}>
> {
return this.call("getFileDuplicates", relPath);
}
async getCallees(symbolId: number): Promise<
Array<{
calleeName: string;
calleeFile: string;
calleeLine: number;
callLine: number;
}>
> {
return this.call("getCallees", symbolId);
}
// ── Stats ──────────────────────────────────────────────────────────
async getStats(): Promise<RepoMapStats> {
const stats = await this.call<RepoMapStats>("getStats");
this._stats = stats;
return stats;
}
getStatsCached(): RepoMapStats {
return this._stats;
}
async dbSizeBytes(): Promise<number> {
const size = await this.call<number>("dbSizeBytes");
this._dbSize = size;
return size;
}
dbSizeBytesCached(): number {
return this._dbSize;
}
async getTopFiles(limit?: number): Promise<
Array<{
path: string;
pagerank: number;
lines: number;
symbols: number;
language: string;
}>
> {
return this.call("getTopFiles", limit);
}
async getExternalPackages(
limit?: number,
): Promise<Array<{ package: string; fileCount: number; specifiers: string[] }>> {
return this.call("getExternalPackages", limit);
}
// ── Shiki Highlighting ─────────────────────────────────────────────
async codeToAnsi(code: string, lang?: string): Promise<string> {
return this.call("codeToAnsi", code, lang);
}
async codeToStyledTokens(
code: string,
lang?: string,
): Promise<Array<Array<{ content: string; color?: string }>>> {
return this.call("codeToStyledTokens", code, lang);
}
async isShikiLanguage(lang: string): Promise<boolean> {
return this.call("isShikiLanguage", lang);
}
}
+279
View File
@@ -0,0 +1,279 @@
import type { WorkerHandlerContext } from "./rpc.js";
import { createWorkerHandler } from "./rpc.js";
let repoMap: import("../intelligence/repo-map.js").RepoMap | null = null;
let highlighter: import("shiki").Highlighter | null = null;
let ctx: WorkerHandlerContext;
const LANG_ALIASES: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
py: "python",
rb: "ruby",
rs: "rust",
sh: "bash",
shell: "bash",
zsh: "bash",
yml: "yaml",
md: "markdown",
dockerfile: "docker",
tf: "terraform",
cs: "csharp",
"c++": "cpp",
"c#": "csharp",
objc: "objective-c",
};
const SHIKI_LANGS = [
"typescript",
"javascript",
"tsx",
"jsx",
"python",
"rust",
"go",
"bash",
"json",
"yaml",
"toml",
"html",
"css",
"sql",
"markdown",
"ruby",
"java",
"kotlin",
"swift",
"c",
"cpp",
"csharp",
"php",
"lua",
"zig",
"elixir",
"haskell",
"ocaml",
"scala",
"dart",
"dockerfile",
"graphql",
"terraform",
"vim",
"diff",
"ini",
"xml",
] as const;
function normalizeLang(lang: string): string {
const lower = lang.toLowerCase().trim();
return LANG_ALIASES[lower] ?? lower;
}
async function ensureHighlighter() {
if (highlighter) return highlighter;
const { createHighlighter } = await import("shiki");
highlighter = await createHighlighter({
themes: ["catppuccin-mocha"],
langs: [...SHIKI_LANGS],
});
return highlighter;
}
function requireRepoMap() {
if (!repoMap) throw new Error("RepoMap not initialized — send init first");
return repoMap;
}
const handlers: Record<string, (...args: unknown[]) => unknown> = {
// ── Core ──
scan: async () => {
await requireRepoMap().scan();
},
getCwd: () => requireRepoMap().getCwd(),
close: async () => {
await requireRepoMap().close();
repoMap = null;
},
clear: () => requireRepoMap().clear(),
// ── Semantic ──
setSemanticMode: (mode: unknown) =>
requireRepoMap().setSemanticMode(mode as "off" | "ast" | "synthetic" | "llm" | "full" | "on"),
getSemanticMode: () => requireRepoMap().getSemanticMode(),
isSemanticEnabled: () => requireRepoMap().isSemanticEnabled(),
detectPersistedSemanticMode: () => requireRepoMap().detectPersistedSemanticMode(),
generateAstSummaries: () => requireRepoMap().generateAstSummaries(),
generateSyntheticSummaries: (limit: unknown) =>
requireRepoMap().generateSyntheticSummaries(limit as number | undefined),
clearFreeSummaries: () => requireRepoMap().clearFreeSummaries(),
clearSemanticSummaries: () => requireRepoMap().clearSemanticSummaries(),
getStaleSummaryCount: () => requireRepoMap().getStaleSummaryCount(),
getSummaryBreakdown: () => requireRepoMap().getSummaryBreakdown(),
generateSemanticSummaries: async (maxSymbols: unknown) => {
const rm = requireRepoMap();
rm.setSummaryGenerator(async (batch) => {
return ctx.requestCallback<Array<{ name: string; summary: string }>>(
"summaryGenerator",
batch,
);
});
const count = await rm.generateSemanticSummaries(maxSymbols as number | undefined);
rm.setSummaryGenerator(null);
return count;
},
// ── File Monitoring ──
onFileChanged: (absPath: unknown) => requireRepoMap().onFileChanged(absPath as string),
recheckModifiedFiles: () => requireRepoMap().recheckModifiedFiles(),
// ── Render ──
render: (opts: unknown) =>
requireRepoMap().render(opts as import("../intelligence/repo-map.js").RepoMapOptions),
// ── Symbol Lookup ──
findSymbols: (name: unknown) => requireRepoMap().findSymbols(name as string),
findSymbol: (name: unknown) => requireRepoMap().findSymbol(name as string),
searchSymbolsSubstring: (query: unknown, limit: unknown) =>
requireRepoMap().searchSymbolsSubstring(query as string, limit as number | undefined),
getFileSymbols: (relPath: unknown) => requireRepoMap().getFileSymbols(relPath as string),
getFileSymbolRanges: (relPath: unknown) =>
requireRepoMap().getFileSymbolRanges(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),
// ── File Analysis ──
matchFiles: (pattern: unknown, limit: unknown) =>
requireRepoMap().matchFiles(pattern as string, limit as number | undefined),
getFileDependents: (relPath: unknown) => requireRepoMap().getFileDependents(relPath as string),
getFileDependencies: (relPath: unknown) =>
requireRepoMap().getFileDependencies(relPath as string),
getFileCoChanges: (relPath: unknown) => requireRepoMap().getFileCoChanges(relPath as string),
getFileExportCount: (relPath: unknown) => requireRepoMap().getFileExportCount(relPath as string),
getFileBlastRadius: (relPath: unknown) => requireRepoMap().getFileBlastRadius(relPath as string),
getFilesByPackage: (pkg: unknown) => requireRepoMap().getFilesByPackage(pkg as string),
listDirectory: (dirPath: unknown) => requireRepoMap().listDirectory(dirPath as string),
// ── Code Analysis ──
getIdentifierFrequency: (limit: unknown) =>
requireRepoMap().getIdentifierFrequency(limit as number | undefined),
getUnusedExports: (limit: unknown) =>
requireRepoMap().getUnusedExports(limit as number | undefined),
getTestOnlyExports: () => requireRepoMap().getTestOnlyExports(),
getDeadBarrels: () => requireRepoMap().getDeadBarrels(),
getRepeatedFragments: (limit: unknown) =>
requireRepoMap().getRepeatedFragments(limit as number | undefined),
getDuplicateStructures: (limit: unknown) =>
requireRepoMap().getDuplicateStructures(limit as number | undefined),
getNearDuplicates: (threshold: unknown, limit: unknown) =>
requireRepoMap().getNearDuplicates(
threshold as number | undefined,
limit as number | undefined,
),
getFileDuplicates: (relPath: unknown) => requireRepoMap().getFileDuplicates(relPath as string),
getCallees: (symbolId: unknown) => requireRepoMap().getCallees(symbolId as number),
// ── Stats ──
getStats: () => requireRepoMap().getStats(),
dbSizeBytes: () => requireRepoMap().dbSizeBytes(),
getTopFiles: (limit: unknown) => requireRepoMap().getTopFiles(limit as number | undefined),
getExternalPackages: (limit: unknown) =>
requireRepoMap().getExternalPackages(limit as number | undefined),
// ── Ready State ──
getIsReady: () => requireRepoMap().isReady,
// ── Shiki Highlighting ──
codeToAnsi: async (code: unknown, lang: unknown) => {
const hl = await ensureHighlighter();
const normalized = lang ? normalizeLang(lang as string) : "text";
const langId = hl.getLoadedLanguages().includes(normalized) ? normalized : "text";
try {
const RST = "\x1b[0m";
const result = hl.codeToTokens(code as string, {
lang: langId as import("shiki").BundledLanguage,
theme: "catppuccin-mocha",
});
const lines: string[] = [];
for (const line of result.tokens) {
let lineStr = "";
for (const token of line) {
if (token.color) {
const h = token.color.startsWith("#") ? token.color.slice(1) : token.color;
const r = Number.parseInt(h.slice(0, 2), 16);
const g = Number.parseInt(h.slice(2, 4), 16);
const b = Number.parseInt(h.slice(4, 6), 16);
lineStr += `\x1b[38;2;${String(r)};${String(g)};${String(b)}m${token.content}${RST}`;
} else {
lineStr += token.content;
}
}
lines.push(lineStr);
}
return lines.join("\n");
} catch {
return code as string;
}
},
codeToStyledTokens: async (code: unknown, lang: unknown) => {
const hl = await ensureHighlighter();
const normalized = lang ? normalizeLang(lang as string) : "text";
const langId = hl.getLoadedLanguages().includes(normalized) ? normalized : "text";
try {
const result = hl.codeToTokens(code as string, {
lang: langId as import("shiki").BundledLanguage,
theme: "catppuccin-mocha",
});
return result.tokens.map((line) =>
line.map((token) => ({
content: token.content,
color: token.color ?? undefined,
})),
);
} catch {
return (code as string).split("\n").map((line) => [{ content: line }]);
}
},
isShikiLanguage: async (lang: unknown) => {
const hl = await ensureHighlighter();
const normalized = normalizeLang(lang as string);
return hl.getLoadedLanguages().includes(normalized);
},
};
ctx = createWorkerHandler(
handlers,
async (config) => {
const { RepoMap } = await import("../intelligence/repo-map.js");
repoMap = new RepoMap(config.cwd as string);
repoMap.onProgress = (indexed, total) => {
const rm = repoMap;
if (!rm) return;
const stats = rm.getStats();
const dbSize = rm.dbSizeBytes();
ctx.emit("progress", { indexed, total, stats, dbSize });
};
repoMap.onScanComplete = (success) => {
const rm = repoMap;
if (!rm) return;
const stats = rm.getStats();
const dbSize = rm.dbSizeBytes();
ctx.emit("scan-complete", { success, stats, dbSize, isReady: rm.isReady });
};
repoMap.onStaleSymbols = (count) => {
ctx.emit("stale-symbols", { count });
};
},
async () => {
if (repoMap) {
await repoMap.close();
repoMap = null;
}
},
);
+111
View File
@@ -0,0 +1,111 @@
import { join } from "node:path";
import type { ModelMessage } from "ai";
import { useWorkerStore } from "../../stores/workers.js";
import type { ChatMessage } from "../../types/index.js";
import type { WorkingState } from "../compaction/types.js";
import type { SessionMeta } from "../sessions/types.js";
import { WorkerClient } from "./rpc.js";
interface CompressResult {
text: string;
original: string | null;
}
interface GitLogEntry {
hash: string;
subject: string;
date: string;
}
interface LoadSessionResult {
meta: SessionMeta;
tabEntries: [string, ChatMessage[]][];
}
let _instance: IOClient | null = null;
export function getIOClient(): IOClient {
if (!_instance) _instance = new IOClient();
return _instance;
}
export function disposeIOClient(): void {
_instance?.dispose();
_instance = null;
}
export class IOClient extends WorkerClient {
constructor() {
const workerPath = join(import.meta.dir, "io.worker.ts");
super(workerPath, undefined, { smol: true });
useWorkerStore.getState().markStarted("io");
this.onStatusChange = (status) => {
const store = useWorkerStore.getState();
if (status === "crashed") store.setWorkerError("io", "IO worker crashed");
else if (status === "restarting") {
store.incrementRestarts("io");
store.setWorkerStatus("io", "restarting");
} else if (status === "starting") store.setWorkerStatus("io", "starting");
else store.markStarted("io");
};
this.onRpcStart = () => {
useWorkerStore.getState().updateRpcInFlight("io", 1);
};
this.onRpcEnd = (error) => {
const store = useWorkerStore.getState();
store.updateRpcInFlight("io", -1);
store.incrementCalls("io");
if (error) store.incrementErrors("io");
};
}
// ── Shell Compression ────────────────────────────────────────────
async compressShellOutput(raw: string): Promise<string> {
return this.call("compressShellOutput", raw);
}
async compressShellOutputFull(raw: string): Promise<CompressResult> {
return this.call("compressShellOutputFull", raw);
}
// ── File Tree ────────────────────────────────────────────────────
async walkDir(dir: string, prefix: string, depth: number): Promise<string[]> {
return this.call("walkDir", dir, prefix, depth);
}
// ── Git Parsing ──────────────────────────────────────────────────
async parseGitLogLine(line: string): Promise<GitLogEntry> {
return this.call("parseGitLogLine", line);
}
async parseGitLogBatch(lines: string[]): Promise<GitLogEntry[]> {
return this.call("parseGitLogBatch", lines);
}
// ── Compaction Serialization ─────────────────────────────────────
async serializeWorkingState(state: Readonly<WorkingState>): Promise<string> {
return this.call("serializeWorkingState", state);
}
async buildConvoText(messages: ModelMessage[], charBudget: number): Promise<string> {
return this.call("buildConvoText", messages, charBudget);
}
// ── Session Persistence ──────────────────────────────────────────
async saveSession(
sessionDir: string,
meta: SessionMeta,
tabEntries: [string, ChatMessage[]][],
): Promise<void> {
return this.call("saveSession", sessionDir, meta, tabEntries);
}
async loadSession(sessionDir: string): Promise<LoadSessionResult | null> {
return this.call("loadSession", sessionDir);
}
}
+118
View File
@@ -0,0 +1,118 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createWorkerHandler } from "./rpc.js";
const handlers: Record<string, (...args: unknown[]) => unknown> = {
// ── Shell Output Compression ───────────────────────────────────────
compressShellOutput: async (raw: unknown) => {
const { compressShellOutput } = await import("../tools/shell-compress.js");
return compressShellOutput(raw as string);
},
compressShellOutputFull: async (raw: unknown) => {
const { compressShellOutputFull } = await import("../tools/shell-compress.js");
return compressShellOutputFull(raw as string);
},
// ── File Tree ──────────────────────────────────────────────────────
walkDir: async (dir: unknown, prefix: unknown, depth: unknown) => {
const { walkDir } = await import("../context/file-tree.js");
const lines: string[] = [];
walkDir(dir as string, prefix as string, depth as number, lines);
return lines;
},
// ── Git Parsing ────────────────────────────────────────────────────
parseGitLogLine: async (line: unknown) => {
const { parseGitLogLine } = await import("../git/status.js");
return parseGitLogLine(line as string);
},
parseGitLogBatch: async (lines: unknown) => {
const { parseGitLogLine } = await import("../git/status.js");
return (lines as string[]).map(parseGitLogLine);
},
// ── Compaction Serialization ───────────────────────────────────────
serializeWorkingState: async (state: unknown) => {
const { serializeState } = await import("../compaction/working-state.js");
const s = state as import("../compaction/types.js").WorkingState;
return serializeState(s);
},
buildConvoText: async (messages: unknown, charBudget: unknown) => {
const { buildFullConvoText } = await import("../compaction/summarize.js");
type ModelMessage = import("ai").ModelMessage;
return buildFullConvoText(messages as ModelMessage[], charBudget as number);
},
// ── Session Persistence ────────────────────────────────────────────
saveSession: async (sessionDir: unknown, meta: unknown, tabEntries: unknown) => {
const dir = sessionDir as string;
const sessionMeta = meta as import("../sessions/types.js").SessionMeta;
const entries = tabEntries as [string, unknown[]][];
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
const allMessages: unknown[] = [];
const updatedTabs = sessionMeta.tabs.map((tab) => {
const msgs = entries.find(([id]) => id === tab.id)?.[1] ?? [];
const startLine = allMessages.length;
for (const msg of msgs) allMessages.push(msg);
const endLine = allMessages.length;
return { ...tab, messageRange: { startLine, endLine } };
});
const updatedMeta = { ...sessionMeta, tabs: updatedTabs };
const metaJson = JSON.stringify(updatedMeta, null, 2);
const lines = allMessages.map((m) => JSON.stringify(m)).join("\n");
const metaPath = join(dir, "meta.json");
const jsonlPath = join(dir, "messages.jsonl");
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const metaTmp = `${metaPath}.${suffix}.tmp`;
const jsonlTmp = `${jsonlPath}.${suffix}.tmp`;
await writeFile(metaTmp, metaJson, { encoding: "utf-8", mode: 0o600 });
await writeFile(jsonlTmp, lines ? `${lines}\n` : "", { encoding: "utf-8", mode: 0o600 });
await rename(jsonlTmp, jsonlPath);
await rename(metaTmp, metaPath);
},
loadSession: async (sessionDir: unknown) => {
const dir = sessionDir as string;
const metaPath = join(dir, "meta.json");
if (!existsSync(metaPath)) return null;
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
const jsonlPath = join(dir, "messages.jsonl");
const allMessages: unknown[] = [];
if (existsSync(jsonlPath)) {
const content = readFileSync(jsonlPath, "utf-8").trim();
if (content) {
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
allMessages.push(JSON.parse(line));
} catch {
break;
}
}
}
}
const tabEntries: [string, unknown[]][] = [];
for (const tab of meta.tabs) {
const { startLine, endLine } = tab.messageRange;
tabEntries.push([tab.id, allMessages.slice(startLine, endLine)]);
}
return { meta, tabEntries };
},
};
createWorkerHandler(handlers);
+394
View File
@@ -0,0 +1,394 @@
declare var self: Worker;
// ── Message Types ──────────────────────────────────────────────────────
export interface CallMessage {
type: "call";
id: number;
method: string;
args: unknown[];
}
export interface ResultMessage {
type: "result";
id: number;
data: unknown;
}
export interface ErrorMessage {
type: "error";
id: number;
message: string;
stack?: string;
}
export interface EventMessage {
type: "event";
event: string;
data: unknown;
}
export interface CallbackRequest {
type: "callback";
id: number;
name: string;
data: unknown;
}
export interface CallbackResponse {
type: "callback-result";
id: number;
data: unknown;
error?: string;
}
export interface InitMessage {
type: "init";
config: Record<string, unknown>;
}
export interface DisposeMessage {
type: "dispose";
}
export type WorkerInbound = CallMessage | InitMessage | DisposeMessage | CallbackResponse;
export type WorkerOutbound = ResultMessage | ErrorMessage | EventMessage | CallbackRequest;
// ── Worker-Side Handler ────────────────────────────────────────────────
type HandlerFn = (...args: unknown[]) => unknown;
export interface WorkerHandlerContext {
emit(event: string, data: unknown): void;
requestCallback<T>(name: string, data: unknown): Promise<T>;
}
export function createWorkerHandler(
handlers: Record<string, HandlerFn>,
onInit?: (config: Record<string, unknown>, ctx: WorkerHandlerContext) => void | Promise<void>,
onDispose?: () => void | Promise<void>,
): WorkerHandlerContext {
let callbackIdCounter = 0;
const pendingCallbacks = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
>();
const ctx: WorkerHandlerContext = {
emit(event: string, data: unknown) {
postMessage({ type: "event", event, data } satisfies EventMessage);
},
requestCallback<T>(name: string, data: unknown, timeoutMs = 60_000): Promise<T> {
const id = callbackIdCounter++;
postMessage({ type: "callback", id, name, data } satisfies CallbackRequest);
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (pendingCallbacks.delete(id)) {
reject(new Error(`Callback timeout: ${name}`));
}
}, timeoutMs);
pendingCallbacks.set(id, {
resolve: (v: unknown) => {
clearTimeout(timer);
(resolve as (v: unknown) => void)(v);
},
reject: (e: Error) => {
clearTimeout(timer);
reject(e);
},
});
});
},
};
self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
const msg = e.data;
switch (msg.type) {
case "call": {
const fn = handlers[msg.method];
if (!fn) {
postMessage({
type: "error",
id: msg.id,
message: `Unknown method: ${msg.method}`,
} satisfies ErrorMessage);
return;
}
try {
const result = await fn(...msg.args);
postMessage({ type: "result", id: msg.id, data: result } satisfies ResultMessage);
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
postMessage({
type: "error",
id: msg.id,
message: error.message,
stack: error.stack,
} satisfies ErrorMessage);
}
break;
}
case "callback-result": {
const pending = pendingCallbacks.get(msg.id);
if (pending) {
pendingCallbacks.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error));
} else {
pending.resolve(msg.data);
}
}
break;
}
case "init": {
try {
await onInit?.(msg.config, ctx);
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
ctx.emit("init-error", { message: error.message, stack: error.stack });
}
break;
}
case "dispose": {
try {
await onDispose?.();
} catch {}
process.exit(0);
}
}
};
return ctx;
}
// ── Main Thread Client ─────────────────────────────────────────────────
type EventListener = (data: unknown) => void;
type CallbackHandler = (data: unknown) => Promise<unknown>;
export class WorkerClient {
protected worker: Worker;
private pending = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
>();
private nextId = 0;
private eventListeners = new Map<string, Set<EventListener>>();
private callbackHandlers = new Map<string, CallbackHandler>();
private disposed = false;
private workerPath: string;
private initConfig?: Record<string, unknown>;
private workerOpts?: { smol?: boolean };
private restartCount = 0;
private static readonly MAX_RESTARTS = 3;
onStatusChange?: (status: "starting" | "ready" | "crashed" | "restarting") => void;
onRpcStart?: () => void;
onRpcEnd?: (error?: boolean) => void;
constructor(
workerPath: string,
initConfig?: Record<string, unknown>,
workerOpts?: { smol?: boolean },
) {
this.workerPath = workerPath;
this.initConfig = initConfig;
this.workerOpts = workerOpts;
this.worker = this.spawnWorker();
}
private spawnWorker(): Worker {
const w = new Worker(this.workerPath, this.workerOpts);
w.unref();
w.onmessage = (e: MessageEvent<WorkerOutbound | CallbackRequest>) => this.handleMessage(e.data);
w.onerror = (e: ErrorEvent) => this.handleWorkerError(e);
w.addEventListener("close", () => this.handleWorkerClose());
if (this.initConfig) {
w.postMessage({ type: "init", config: this.initConfig } satisfies InitMessage);
}
this.onStatusChange?.("starting");
return w;
}
private tryRestart(): boolean {
if (this.disposed || this.restartCount >= WorkerClient.MAX_RESTARTS) return false;
this.restartCount++;
this.onStatusChange?.("restarting");
try {
this.worker.terminate();
} catch {}
try {
this.worker = this.spawnWorker();
return true;
} catch {
this.onStatusChange?.("crashed");
return false;
}
}
protected resetRestartCount(): void {
this.restartCount = 0;
}
private static readonly CALL_TIMEOUT = 30_000;
protected call<T>(method: string, ...args: unknown[]): Promise<T> {
return this.callWithTimeout<T>(WorkerClient.CALL_TIMEOUT, method, ...args);
}
protected callWithTimeout<T>(timeoutMs: number, method: string, ...args: unknown[]): Promise<T> {
if (this.disposed) return Promise.reject(new Error("Worker disposed"));
const id = this.nextId++;
this.worker.postMessage({ type: "call", id, method, args } satisfies CallMessage);
this.onRpcStart?.();
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending.delete(id)) {
this.onRpcEnd?.(true);
reject(new Error(`Worker RPC timeout: ${method}`));
}
}, timeoutMs);
this.pending.set(id, {
resolve: (v: unknown) => {
clearTimeout(timer);
this.onRpcEnd?.();
(resolve as (v: unknown) => void)(v);
},
reject: (e: Error) => {
clearTimeout(timer);
this.onRpcEnd?.(true);
reject(e);
},
});
});
}
protected fire(method: string, ...args: unknown[]): void {
if (this.disposed) return;
const id = this.nextId++;
this.worker.postMessage({ type: "call", id, method, args } satisfies CallMessage);
}
on(event: string, fn: EventListener): void {
let set = this.eventListeners.get(event);
if (!set) {
set = new Set();
this.eventListeners.set(event, set);
}
set.add(fn);
}
off(event: string, fn: EventListener): void {
this.eventListeners.get(event)?.delete(fn);
}
registerCallback(name: string, handler: CallbackHandler): void {
this.callbackHandlers.set(name, handler);
}
private handleMessage(msg: WorkerOutbound | CallbackRequest) {
switch (msg.type) {
case "result": {
const p = this.pending.get(msg.id);
if (p) {
this.pending.delete(msg.id);
p.resolve(msg.data);
}
break;
}
case "error": {
const p = this.pending.get(msg.id);
if (p) {
this.pending.delete(msg.id);
const err = new Error(msg.message);
if (msg.stack) err.stack = msg.stack;
p.reject(err);
}
break;
}
case "event": {
const fns = this.eventListeners.get(msg.event);
if (fns) {
for (const fn of fns) {
try {
fn(msg.data);
} catch {}
}
}
break;
}
case "callback": {
const handler = this.callbackHandlers.get(msg.name);
const respond = (resp: CallbackResponse) => {
try {
this.worker.postMessage(resp);
} catch {}
};
if (handler) {
Promise.resolve()
.then(() => handler(msg.data))
.then((result) => {
respond({
type: "callback-result",
id: msg.id,
data: result,
} satisfies CallbackResponse);
})
.catch((err: unknown) => {
const error = err instanceof Error ? err : new Error(String(err));
respond({
type: "callback-result",
id: msg.id,
data: null,
error: error.message,
} satisfies CallbackResponse);
});
} else {
respond({
type: "callback-result",
id: msg.id,
data: null,
error: `No callback handler for: ${msg.name}`,
} satisfies CallbackResponse);
}
break;
}
}
}
private handleWorkerError(e: ErrorEvent) {
for (const [, p] of this.pending) {
p.reject(new Error(`Worker error: ${e.message}`));
}
this.pending.clear();
if (!this.disposed) this.onStatusChange?.("crashed");
}
private handleWorkerClose() {
for (const [, p] of this.pending) {
p.reject(new Error("Worker closed unexpectedly"));
}
this.pending.clear();
if (!this.disposed) {
this.onStatusChange?.("crashed");
this.tryRestart();
}
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
for (const [, p] of this.pending) {
p.reject(new Error("Worker disposed"));
}
this.pending.clear();
this.worker.terminate();
}
}
+44 -4
View File
@@ -340,6 +340,20 @@ export async function runPrompt(opts: HeadlessRunOptions, merged: AppConfig): Pr
env.contextManager.updateConversationContext(prompt, 0);
// Header
const workerInfo = () => {
try {
const { useWorkerStore } =
require("../stores/workers.js") as typeof import("../stores/workers.js");
const wk = useWorkerStore.getState();
return {
intelligence: wk.intelligence.status,
io: wk.io.status,
};
} catch {
return null;
}
};
if (isEvents) {
writeEvent({
type: "start",
@@ -347,16 +361,22 @@ export async function runPrompt(opts: HeadlessRunOptions, merged: AppConfig): Pr
mode: env.mode,
session: opts.sessionId ?? null,
repoMap: env.repoMap
? { files: env.repoMap.getStats().files, symbols: env.repoMap.getStats().symbols }
? {
files: env.repoMap.getStatsCached().files,
symbols: env.repoMap.getStatsCached().symbols,
}
: null,
workers: workerInfo(),
});
} else if (showProgress) {
stderrLabel("Model", env.modelId);
if (env.mode !== "default") stderrLabel("Mode", env.mode);
if (env.repoMap) {
const stats = env.repoMap.getStats();
const stats = env.repoMap.getStatsCached();
stderrLabel("Repo", `${String(stats.files)} files, ${String(stats.symbols)} symbols`);
}
const wk = workerInfo();
if (wk) stderrDim(`Workers: intelligence=${wk.intelligence}, io=${wk.io}`);
separator();
}
@@ -540,6 +560,20 @@ export async function runChat(opts: HeadlessChatOptions, merged: AppConfig): Pro
}
// Header
const workerInfo = () => {
try {
const { useWorkerStore } =
require("../stores/workers.js") as typeof import("../stores/workers.js");
const wk = useWorkerStore.getState();
return {
intelligence: wk.intelligence.status,
io: wk.io.status,
};
} catch {
return null;
}
};
if (isEvents) {
writeEvent({
type: "start",
@@ -547,16 +581,22 @@ export async function runChat(opts: HeadlessChatOptions, merged: AppConfig): Pro
mode: env.mode,
chat: true,
repoMap: env.repoMap
? { files: env.repoMap.getStats().files, symbols: env.repoMap.getStats().symbols }
? {
files: env.repoMap.getStatsCached().files,
symbols: env.repoMap.getStatsCached().symbols,
}
: null,
workers: workerInfo(),
});
} else if (showProgress) {
stderrLabel("Model", env.modelId);
if (env.mode !== "default") stderrLabel("Mode", env.mode);
if (env.repoMap) {
const stats = env.repoMap.getStats();
const stats = env.repoMap.getStatsCached();
stderrLabel("Repo", `${String(stats.files)} files, ${String(stats.symbols)} symbols`);
}
const wk = workerInfo();
if (wk) stderrDim(`Workers: intelligence=${wk.intelligence}, io=${wk.io}`);
stderrDim(
"Chat mode — type a prompt and hit Enter (backslash \\ for multiline, Ctrl+C to exit)",
);
+6
View File
@@ -36,6 +36,7 @@ import { emitCacheReset, onFileEdited } from "../core/tools/file-events.js";
import { planFileName } from "../core/tools/index.js";
import { setShellCoAuthorEnabled } from "../core/tools/shell.js";
import { completeInProgressTasks, resetInProgressTasks } from "../core/tools/task-list.js";
import { getIOClient } from "../core/workers/io-client.js";
import { logCompaction } from "../stores/compaction-logs.js";
import { logBackgroundError } from "../stores/errors.js";
import { useStatusBarStore } from "../stores/statusbar.js";
@@ -754,6 +755,10 @@ export function useChat({
})),
);
}
let ioClient: import("../core/workers/io-client.js").IOClient | undefined;
try {
ioClient = getIOClient();
} catch {}
summary = await buildV2Summary({
wsm,
olderMessages,
@@ -762,6 +767,7 @@ export function useChat({
headers,
skipLlm: compactionCfg?.llmExtraction === false,
abortSignal: compactAbort.signal,
ioClient,
});
wsm.reset();
} else {
+92
View File
@@ -0,0 +1,92 @@
import { create } from "zustand";
export type WorkerStatus = "idle" | "starting" | "ready" | "busy" | "crashed" | "restarting";
interface WorkerInfo {
status: WorkerStatus;
restarts: number;
lastError: string | null;
rpcInFlight: number;
totalCalls: number;
totalErrors: number;
uptimeMs: number;
startedAt: number;
}
const EMPTY_WORKER: WorkerInfo = {
status: "idle",
restarts: 0,
lastError: null,
rpcInFlight: 0,
totalCalls: 0,
totalErrors: 0,
uptimeMs: 0,
startedAt: 0,
};
interface WorkerStoreState {
intelligence: WorkerInfo;
io: WorkerInfo;
setWorkerStatus: (worker: "intelligence" | "io", status: WorkerStatus) => void;
setWorkerError: (worker: "intelligence" | "io", error: string) => void;
incrementRestarts: (worker: "intelligence" | "io") => void;
updateRpcInFlight: (worker: "intelligence" | "io", delta: number) => void;
incrementCalls: (worker: "intelligence" | "io") => void;
incrementErrors: (worker: "intelligence" | "io") => void;
markStarted: (worker: "intelligence" | "io") => void;
}
export const useWorkerStore = create<WorkerStoreState>()((set) => ({
intelligence: { ...EMPTY_WORKER },
io: { ...EMPTY_WORKER },
setWorkerStatus: (worker, status) =>
set((state) => ({
[worker]: { ...state[worker], status },
})),
setWorkerError: (worker, error) =>
set((state) => ({
[worker]: { ...state[worker], lastError: error, status: "crashed" as const },
})),
incrementRestarts: (worker) =>
set((state) => ({
[worker]: { ...state[worker], restarts: state[worker].restarts + 1 },
})),
updateRpcInFlight: (worker, delta) =>
set((state) => ({
[worker]: {
...state[worker],
rpcInFlight: Math.max(0, state[worker].rpcInFlight + delta),
status:
state[worker].status === "ready" && delta > 0
? ("busy" as const)
: state[worker].status === "busy" && state[worker].rpcInFlight + delta <= 0
? ("ready" as const)
: state[worker].status,
},
})),
incrementCalls: (worker) =>
set((state) => ({
[worker]: { ...state[worker], totalCalls: state[worker].totalCalls + 1 },
})),
incrementErrors: (worker) =>
set((state) => ({
[worker]: { ...state[worker], totalErrors: state[worker].totalErrors + 1 },
})),
markStarted: (worker) =>
set((state) => ({
[worker]: {
...state[worker],
status: "ready" as const,
startedAt: Date.now(),
uptimeMs: 0,
},
})),
}));
+33
View File
@@ -0,0 +1,33 @@
import { createWorkerHandler } from "../../src/core/workers/rpc.js";
let initConfig: Record<string, unknown> | null = null;
const ctx = createWorkerHandler(
{
echo: (x: unknown) => x,
add: (a: unknown, b: unknown) => (a as number) + (b as number),
fail: () => {
throw new Error("intentional error");
},
failCustom: (msg: unknown) => {
throw new Error(msg as string);
},
sleep: (ms: unknown) =>
new Promise((resolve) => setTimeout(resolve, ms as number, "done")),
emitEvent: (event: unknown, data: unknown) => {
ctx.emit(event as string, data);
return "emitted";
},
callbackTest: async (name: unknown, data: unknown) => {
return ctx.requestCallback(name as string, data);
},
getInitConfig: () => initConfig,
identity: (...args: unknown[]) => args,
crash: () => {
process.exit(1);
},
},
(config) => {
initConfig = config;
},
);
File diff suppressed because it is too large. Load diff