mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
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:
41 files changed
+3922
-527
No files matched your search
+14
-12
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(", ")}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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[] = [];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(""));
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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:");
|
||||
|
||||
@@ -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.` };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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)",
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
Vendored
+33
@@ -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
Reference in new issue
Block a user