march-cli 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/agent/code-search/tool.mjs +1 -1
  3. package/src/agent/runner/runner-utils.mjs +20 -0
  4. package/src/agent/runner.mjs +16 -18
  5. package/src/agent/runtime/remote-ui-client.mjs +0 -1
  6. package/src/agent/runtime/runner-process-client.mjs +7 -0
  7. package/src/agent/runtime/runner-process-factory.mjs +7 -3
  8. package/src/agent/runtime/runner-runtime-host.mjs +2 -2
  9. package/src/agent/runtime/ui-event-bridge.mjs +0 -2
  10. package/src/agent/session/session-options.mjs +2 -2
  11. package/src/agent/tools.mjs +5 -23
  12. package/src/agent/turn/turn-events.mjs +41 -0
  13. package/src/agent/turn/turn-runner.mjs +5 -2
  14. package/src/cli/args.mjs +0 -3
  15. package/src/cli/commands/registry/slash-command-registry.mjs +2 -0
  16. package/src/cli/fallback-ui.mjs +0 -2
  17. package/src/cli/input/history-store.mjs +65 -3
  18. package/src/cli/input/mode-state.mjs +1 -1
  19. package/src/cli/repl-loop.mjs +75 -25
  20. package/src/cli/startup/app-runtime.mjs +72 -31
  21. package/src/cli/startup/create-runtime-runner.mjs +5 -46
  22. package/src/cli/startup/startup-session.mjs +3 -13
  23. package/src/cli/tui/input/history-navigation-controller.mjs +56 -0
  24. package/src/cli/turn/turn-input-preparer.mjs +0 -1
  25. package/src/cli/ui.mjs +9 -6
  26. package/src/cli/workspace/command.mjs +147 -0
  27. package/src/cli/workspace/output-router.mjs +108 -0
  28. package/src/cli/workspace/project-runtime.mjs +92 -0
  29. package/src/config/features.mjs +0 -1
  30. package/src/context/engine.mjs +4 -2
  31. package/src/context/system-core/base.md +4 -1
  32. package/src/extensions/lifecycle-adapter.mjs +1 -1
  33. package/src/history/runner.mjs +11 -0
  34. package/src/history/store.mjs +129 -0
  35. package/src/history/tool.mjs +39 -0
  36. package/src/lsp/client.mjs +12 -5
  37. package/src/lsp/service.mjs +15 -3
  38. package/src/main.mjs +5 -2
  39. package/src/notification/desktop-notifier.mjs +16 -8
  40. package/src/web-ui/command.mjs +2 -2
  41. package/src/web-ui/dist/assets/index-BQtl1uQs.css +1 -0
  42. package/src/web-ui/dist/assets/index-DrlJis_D.js +1845 -0
  43. package/src/web-ui/dist/index.html +13 -0
  44. package/src/web-ui/runtime-host.mjs +5 -25
  45. package/src/web-ui/session-manager.mjs +2 -2
  46. package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +2 -10
  47. package/src/web-ui/src/mockData.ts +1 -8
  48. package/src/web-ui/src/model.ts +0 -2
  49. package/src/web-ui/src/runtime/client.ts +0 -1
  50. package/src/web-ui/src/runtime/runtimeTimeline.ts +1 -3
  51. package/src/web-ui/src/styles/shell.css +1 -2
  52. package/src/web-ui/src/timelineAdapter.ts +1 -2
  53. package/src/workspace/project-id.mjs +14 -0
  54. package/src/workspace/project-registry.mjs +74 -0
  55. package/src/workspace/session-index.mjs +75 -0
  56. package/src/workspace/supervisor.mjs +172 -0
  57. package/src/cli/permissions.mjs +0 -103
  58. package/src/cli/tui/permission-request-ui.mjs +0 -18
@@ -0,0 +1,147 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { brightBlack } from "../tui/ui-theme.mjs";
4
+ import { registerProject, listRegisteredProjects } from "../../workspace/project-registry.mjs";
5
+ import { buildWorkspaceSessionSelectItems, listWorkspaceSessions, workspaceSessionSearchText } from "../../workspace/session-index.mjs";
6
+ import { resumePiSessionById } from "../session/pi-session-switch-command.mjs";
7
+ import { loadPiSessionTranscriptTurns } from "../../session/transcript.mjs";
8
+
9
+ export const WORKSPACE_SLASH_COMMANDS = [
10
+ {
11
+ metadata: [
12
+ { name: "project", description: "List registered projects" },
13
+ { name: "project add", helpSyntax: "project add <path>", description: "Register a project root" },
14
+ ],
15
+ match: (trimmed) => {
16
+ const parsed = parseProjectCommand(trimmed);
17
+ return parsed.type === "none" ? null : { parsed };
18
+ },
19
+ run: async (ctx, command) => writeLines(ctx.ui, await handleProjectCommand(command, ctx)),
20
+ },
21
+ {
22
+ metadata: [{ name: "switch", description: "Open cross-project session switcher" }],
23
+ match: (trimmed) => trimmed === "/switch" ? { parsed: { type: "switch" } } : null,
24
+ run: handleSwitchCommand,
25
+ },
26
+ ];
27
+
28
+ export function parseProjectCommand(trimmed) {
29
+ if (trimmed === "/project" || trimmed === "/project list") return { type: "list" };
30
+ if (trimmed.startsWith("/project add ")) return { type: "add", path: trimmed.slice("/project add ".length).trim() };
31
+ return { type: "none" };
32
+ }
33
+
34
+ export async function handleProjectCommand(command, { stateRoot }) {
35
+ if (!stateRoot) return ["Error: workspace registry is not available."];
36
+ if (command.type === "add") {
37
+ const rootPath = resolve(command.path);
38
+ if (!existsSync(rootPath)) return [`Error: project path does not exist: ${rootPath}`];
39
+ const project = registerProject({ stateRoot, rootPath });
40
+ return [`Registered project: ${project.displayName}`, brightBlack(project.rootPath)];
41
+ }
42
+
43
+ const projects = listRegisteredProjects({ stateRoot });
44
+ if (projects.length === 0) return ["No registered projects."];
45
+ return ["Registered projects:", ...projects.map((project) => `- ${project.displayName} ${brightBlack(project.rootPath)}`)];
46
+ }
47
+
48
+ export async function handleSwitchCommand({ stateRoot, currentProjectId, projectMarchDir, runner, workspaceSupervisor, workspaceOutputRouter, ui }) {
49
+ if (!stateRoot) {
50
+ ui.writeln("Session switcher is not available: workspace registry is missing.");
51
+ return { handled: true };
52
+ }
53
+ const projects = await listWorkspaceSessions({ stateRoot, currentProjectId });
54
+ const currentSessionId = runner.getSessionStats?.().sessionId ?? null;
55
+ const runtimeSummaries = workspaceSupervisor?.getRuntimeSummaries?.() ?? [];
56
+ const items = annotateWorkspaceItems(buildWorkspaceSessionSelectItems(projects, currentSessionId), runtimeSummaries);
57
+ if (items.length === 0) {
58
+ ui.writeln("No registered projects. Start March in a project or run /project add <path>.");
59
+ return { handled: true };
60
+ }
61
+ if (!ui.selectList) {
62
+ ui.writeln("Session switcher is only available in TUI.");
63
+ return { handled: true };
64
+ }
65
+ const selectedIndex = Math.max(0, items.findIndex((item) => item.project.current && item.session?.id === currentSessionId));
66
+ const item = await ui.selectList({
67
+ items,
68
+ selectedIndex,
69
+ width: 90,
70
+ suppressInitialConfirm: true,
71
+ searchable: true,
72
+ getSearchText: workspaceSessionSearchText,
73
+ });
74
+ if (!item) {
75
+ ui.writeln("Session unchanged.");
76
+ return { handled: true };
77
+ }
78
+ if (!item.session) {
79
+ if (!workspaceSupervisor?.startNewWorkspaceSession) {
80
+ ui.writeln("New session creation requires the workspace supervisor.");
81
+ return { handled: true };
82
+ }
83
+ try {
84
+ const { result } = await workspaceSupervisor.startNewWorkspaceSession(item.project);
85
+ ui.restoreTranscript?.([]);
86
+ ui.writeln(`Created session: ${item.project.displayName} / ${result?.sessionId ?? "new session"}`);
87
+ return { handled: true, refreshContextTokens: true, activeChanged: true };
88
+ } catch (err) {
89
+ ui.writeln(`Error: ${err.message}`);
90
+ return { handled: true };
91
+ }
92
+ }
93
+ if (workspaceSupervisor) {
94
+ try {
95
+ await workspaceSupervisor.activateWorkspaceSession({ project: item.project, session: item.session });
96
+ restoreTranscriptFromSession(item.session, ui);
97
+ const replayed = ctxReplayBufferedOutput({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
98
+ ui.writeln(`Switched to session: ${item.project.displayName} / ${item.session.name || item.session.id}${replayed ? ` (${replayed} buffered events replayed)` : ""}`);
99
+ return { handled: true, refreshContextTokens: true, activeChanged: true };
100
+ } catch (err) {
101
+ ui.writeln(`Error: ${err.message}`);
102
+ return { handled: true };
103
+ }
104
+ }
105
+ if (!item.project.current) {
106
+ ui.writeln(`Project switch target indexed: ${item.project.displayName}`);
107
+ ui.writeln(brightBlack("Cross-project attach requires the workspace supervisor."));
108
+ return { handled: true };
109
+ }
110
+ const sessions = projects.find((project) => project.current)?.sessions ?? [];
111
+ const lines = await resumePiSessionById(item.session.id, { runner, sessions, projectMarchDir });
112
+ if (isResumeSuccess(lines)) restoreTranscriptFromSession(item.session, ui);
113
+ for (const line of lines) ui.writeln(line);
114
+ return { handled: true, refreshContextTokens: isResumeSuccess(lines) };
115
+ }
116
+
117
+ function annotateWorkspaceItems(items, runtimeSummaries) {
118
+ if (!runtimeSummaries.length) return items;
119
+ const running = new Set(runtimeSummaries.filter((runtime) => runtime.running).map((runtime) => `${runtime.projectId}:${runtime.sessionId}`));
120
+ return items.map((item) => {
121
+ if (!item.session) return item;
122
+ if (!running.has(`${item.project.projectId}:${item.session.id}`)) return item;
123
+ return { ...item, description: `running · ${item.description}` };
124
+ });
125
+ }
126
+
127
+ function ctxReplayBufferedOutput({ workspaceOutputRouter, projectId, sessionId }) {
128
+ return workspaceOutputRouter?.replayBufferedCalls?.(projectId, sessionId) ?? 0;
129
+ }
130
+
131
+ function restoreTranscriptFromSession(session, ui) {
132
+ if (typeof ui.restoreTranscript !== "function") return;
133
+ try {
134
+ ui.restoreTranscript(loadPiSessionTranscriptTurns(session.path));
135
+ } catch (err) {
136
+ ui.writeln(`Warning: failed to restore session transcript: ${err.message}`);
137
+ }
138
+ }
139
+
140
+ function writeLines(ui, lines) {
141
+ for (const line of lines) ui.writeln(line);
142
+ return { handled: true };
143
+ }
144
+
145
+ function isResumeSuccess(lines) {
146
+ return Array.isArray(lines) && lines.some((line) => String(line).startsWith("Resumed pi session:"));
147
+ }
@@ -0,0 +1,108 @@
1
+ const BACKGROUND_METHODS_TO_BUFFER = new Set([
2
+ "turnStart",
3
+ "turnEnd",
4
+ "assistantReplyEnd",
5
+ "textDelta",
6
+ "thinkingStart",
7
+ "thinkingDelta",
8
+ "thinkingEnd",
9
+ "toolStart",
10
+ "toolEnd",
11
+ "retryStart",
12
+ "retryEnd",
13
+ "status",
14
+ "debugLines",
15
+ "recall",
16
+ "providerQuotaSnapshot",
17
+ "editDiff",
18
+ "writeln",
19
+ ]);
20
+
21
+ export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSessionId = null }) {
22
+ let active = routeKey(activeProjectId, activeSessionId);
23
+ const buffers = new Map();
24
+
25
+ return {
26
+ setActiveProject(projectId) {
27
+ active = routeKey(projectId, null);
28
+ },
29
+ setActiveSession(projectId, sessionId) {
30
+ active = routeKey(projectId, sessionId);
31
+ },
32
+ getActiveRouteKey() {
33
+ return active;
34
+ },
35
+ getActiveProject() {
36
+ return parseRouteKey(active).projectId;
37
+ },
38
+ createProjectUi(projectId, getSessionId = null) {
39
+ return this.createSessionUi({ projectId, getSessionId });
40
+ },
41
+ createSessionUi({ projectId, sessionId = null, getSessionId = null }) {
42
+ return new Proxy({}, {
43
+ get(_target, prop) {
44
+ if (prop === "__projectId") return projectId;
45
+ const value = ui[prop];
46
+ if (typeof value !== "function") return value;
47
+ return (...args) => {
48
+ const key = routeKey(projectId, typeof getSessionId === "function" ? getSessionId() : sessionId);
49
+ if (isActiveRoute(key) || !BACKGROUND_METHODS_TO_BUFFER.has(prop)) return value.apply(ui, args);
50
+ bufferBackgroundCall(key, prop, args);
51
+ return undefined;
52
+ };
53
+ },
54
+ set(_target, prop, value) {
55
+ ui[prop] = value;
56
+ return true;
57
+ },
58
+ has(_target, prop) {
59
+ return prop in ui;
60
+ },
61
+ });
62
+ },
63
+ getBufferedCalls(projectId, sessionId = null) {
64
+ return [...(buffers.get(routeKey(projectId, sessionId)) ?? [])];
65
+ },
66
+ getBufferedCallCount(projectId, sessionId = null) {
67
+ return buffers.get(routeKey(projectId, sessionId))?.length ?? 0;
68
+ },
69
+ replayBufferedCalls(projectId, sessionId = null) {
70
+ const key = routeKey(projectId, sessionId);
71
+ const calls = buffers.get(key) ?? [];
72
+ buffers.delete(key);
73
+ for (const call of calls) replayBufferedCall(call);
74
+ return calls.length;
75
+ },
76
+ clearBufferedCalls(projectId, sessionId = null) {
77
+ buffers.delete(routeKey(projectId, sessionId));
78
+ },
79
+ };
80
+
81
+ function isActiveRoute(key) {
82
+ if (key === active) return true;
83
+ const current = parseRouteKey(active);
84
+ const candidate = parseRouteKey(key);
85
+ return current.sessionId == null && current.projectId === candidate.projectId;
86
+ }
87
+
88
+ function replayBufferedCall({ method, args }) {
89
+ const value = ui[method];
90
+ if (typeof value === "function") value.apply(ui, args);
91
+ }
92
+
93
+ function bufferBackgroundCall(key, method, args) {
94
+ const calls = buffers.get(key) ?? [];
95
+ calls.push({ method, args, at: Date.now() });
96
+ if (calls.length > 2000) calls.splice(0, calls.length - 2000);
97
+ buffers.set(key, calls);
98
+ }
99
+ }
100
+
101
+ export function routeKey(projectId, sessionId = null) {
102
+ return `${projectId ?? ""}:${sessionId ?? ""}`;
103
+ }
104
+
105
+ function parseRouteKey(key) {
106
+ const [projectId, sessionId = ""] = String(key ?? "").split(":", 2);
107
+ return { projectId: projectId || null, sessionId: sessionId || null };
108
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { createRuntimeRunner } from "../startup/create-runtime-runner.mjs";
4
+ import { createCliShellRuntime } from "../../shell/cli-runtime.mjs";
5
+ import { createMarchAuthStorage } from "../../auth/storage.mjs";
6
+ import { discoverProjectExtensionPaths } from "../../extensions/discovery.mjs";
7
+ import { loadProjectLifecycleHookManifests } from "../../extensions/lifecycle-manifest.mjs";
8
+ import { loadKeybindings } from "../input/keybindings.mjs";
9
+ import { loadPromptTemplates } from "../input/prompt-templates.mjs";
10
+ import { loadOrCreateProjectId } from "../../workspace/project-id.mjs";
11
+
12
+ export async function createWorkspaceProjectRuntime({
13
+ project,
14
+ args,
15
+ config,
16
+ stateRoot,
17
+ memoryRoot,
18
+ profilePaths,
19
+ createMemoryStore,
20
+ provider,
21
+ serviceTier,
22
+ model,
23
+ remoteMemorySources,
24
+ createUi,
25
+ refreshStatusBar,
26
+ onNotificationActivation = null,
27
+ }) {
28
+ const cwd = project.rootPath;
29
+ const projectMarchDir = resolve(cwd, ".march");
30
+ if (!existsSync(projectMarchDir)) mkdirSync(projectMarchDir, { recursive: true });
31
+
32
+ const authConfig = createMarchAuthStorage({ provider: provider ?? "deepseek", providers: config.providers, cwd });
33
+ if (!authConfig.hasAuth) throw new Error(`no providers configured for project: ${cwd}`);
34
+
35
+ const namespace = loadOrCreateProjectId(projectMarchDir);
36
+ const extensionPaths = [
37
+ ...discoverProjectExtensionPaths(cwd),
38
+ ...args.extensions.map((extensionPath) => resolve(cwd, extensionPath)),
39
+ ];
40
+ const lifecycleManifests = loadProjectLifecycleHookManifests(cwd);
41
+ const keybindingConfig = loadKeybindings(cwd);
42
+ const promptTemplateConfig = loadPromptTemplates(cwd);
43
+ const projectShellRuntime = args.shellRuntime ? createCliShellRuntime({ cwd }) : null;
44
+ const sessionsRoot = join(projectMarchDir, "sessions");
45
+ const sessionState = { sessionId: Date.now().toString(36), sessionDir: null };
46
+ sessionState.sessionDir = join(sessionsRoot, sessionState.sessionId);
47
+ const contextDumpRoot = resolve(projectMarchDir, "context-dumps", sessionState.sessionId);
48
+ const memoryStore = createMemoryStore();
49
+ const ui = createUi(sessionState);
50
+ const runner = await createRuntimeRunner({
51
+ runnerOptions: {
52
+ cwd,
53
+ modelId: model,
54
+ provider,
55
+ serviceTier,
56
+ providers: config.providers,
57
+ config,
58
+ stateRoot,
59
+ memoryRoot,
60
+ profilePaths,
61
+ namespace,
62
+ projectMarchDir,
63
+ extensionPaths,
64
+ shellRuntime: Boolean(projectShellRuntime),
65
+ lifecycleHooks: lifecycleManifests.hooks,
66
+ lifecycleDiagnostics: lifecycleManifests.diagnostics,
67
+ modelContextDumper: { enabled: args.dumpContext, rootDir: contextDumpRoot },
68
+ remoteMemorySources,
69
+ notificationContext: { projectId: project.projectId },
70
+ },
71
+ ui,
72
+ shellRuntime: projectShellRuntime,
73
+ refreshStatusBar,
74
+ onNotificationActivation,
75
+ });
76
+
77
+ return {
78
+ project,
79
+ cwd,
80
+ currentProject: basename(cwd),
81
+ runner,
82
+ ui,
83
+ memoryStore,
84
+ sessionState,
85
+ sessionsRoot,
86
+ projectMarchDir,
87
+ extensionPaths,
88
+ keybindingConfig,
89
+ promptTemplateConfig,
90
+ contextDumpRoot,
91
+ };
92
+ }
@@ -7,7 +7,6 @@ const DEFAULTS = Object.freeze({
7
7
  "experimental.web_search": true,
8
8
  "experimental.web_fetch": true,
9
9
  "experimental.shell": true,
10
- "experimental.permissions": true,
11
10
  "ui.markdown_rendering": false,
12
11
  "ui.tool_expand_per_card": false,
13
12
  "agent.plan_mode": false,
@@ -71,18 +71,20 @@ export class ContextEngine {
71
71
  }
72
72
 
73
73
  recordTurn({ userMessage, assistantMessage, assistantContext = "", userRecallHints = [], assistantRecallHints = [] }) {
74
- this.turns.push({
74
+ const turn = {
75
75
  index: this.turns.length + 1,
76
76
  userMessage,
77
77
  assistantMessage: assistantMessage ?? "",
78
78
  assistantContext: assistantContext ?? "",
79
79
  userRecallHints,
80
80
  assistantRecallHints,
81
- });
81
+ };
82
+ this.turns.push(turn);
82
83
  if (this.turns.length > this.maxTurns) {
83
84
  const keep = Math.max(1, this.maxTurns - this.trimBatch);
84
85
  this.turns = this.turns.slice(-keep);
85
86
  }
87
+ return turn;
86
88
  }
87
89
 
88
90
  getRecentRecallMemoryIds() {
@@ -69,7 +69,10 @@ The user primarily asks for software engineering work: fixing bugs, adding behav
69
69
 
70
70
  <editing_contract>
71
71
  - Use read(path) for file inspection with 1-based line numbers.
72
- - Use grep(pattern), find(pattern), and ls(path) to explore the project before editing.
72
+ - Use code_search first when locating unknown implementations, responsibility boundaries, cross-module flows, or concept-level behavior.
73
+ - Use grep(pattern) and find(pattern) for exact symbol, string, filename, or call-site confirmation.
74
+ - Use ls(path) to inspect directory shape when structure matters.
75
+ - Treat code_search as a semantic map, not proof; verify important results with grep/read before editing or concluding.
73
76
  - Prefer dedicated read/search/edit tools over shell commands for file inspection and modification.
74
77
  - Use command_exec for one-shot commands. Use terminal_* only for interactive programs, long-running processes, or when preserving terminal state matters.
75
78
  - Keep the working directory stable; use paths instead of cd unless the user asks otherwise.
@@ -109,7 +109,7 @@ export function createMarchLifecycleAdapter({
109
109
  type: "info",
110
110
  message: hooks.size === 0
111
111
  ? "March lifecycle hook adapter is read-only; no March hooks are registered."
112
- : "March lifecycle hook adapter is read-only; registered hooks are permission-gated.",
112
+ : "March lifecycle hook adapter is read-only; registered hooks are policy-gated.",
113
113
  },
114
114
  ...adapterDiagnostics,
115
115
  ...getRuntimeDiagnostics(),
@@ -0,0 +1,11 @@
1
+ import { join } from "node:path";
2
+ import { HistoryStore } from "./store.mjs";
3
+
4
+ export function createRunnerHistoryStore({ stateRoot, cwd } = {}) {
5
+ if (!stateRoot) return null;
6
+ return new HistoryStore({ root: join(stateRoot, "history"), cwd });
7
+ }
8
+
9
+ export function appendRunnerTurnHistory({ store, turn, sessionStats, modelId, provider }) {
10
+ return store?.appendTurn({ turn, sessionStats, runtime: { modelId, provider } });
11
+ }
@@ -0,0 +1,129 @@
1
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { basename, join } from "node:path";
4
+ import { searchMarkdownRoot } from "../memory/search.mjs";
5
+
6
+ const DEFAULT_HISTORY_LIMIT = 20;
7
+ const MAX_HISTORY_LIMIT = 50;
8
+
9
+ export class HistoryStore {
10
+ constructor({ root, cwd, now = () => new Date() } = {}) {
11
+ if (!root) throw new Error("history root is required");
12
+ this.root = root;
13
+ this.cwd = cwd;
14
+ this.now = now;
15
+ }
16
+
17
+ appendTurn({ turn, sessionStats = {}, runtime = {} } = {}) {
18
+ if (!turn) return null;
19
+ const sessionId = sanitizeId(sessionStats.sessionId ?? sessionStats.sessionFile ?? "session");
20
+ const filePath = join(this.#projectDir(), `${sessionId}.md`);
21
+ mkdirSync(this.#projectDir(), { recursive: true });
22
+ if (!existsSync(filePath)) writeFileSync(filePath, this.#fileHeader({ sessionId, sessionStats }), "utf8");
23
+ appendFileSync(filePath, this.#formatTurn({ turn, sessionId, sessionStats, runtime }), "utf8");
24
+ return filePath;
25
+ }
26
+
27
+ searchRipgrep(query, { allProjects = false, sessionId = null, limit = DEFAULT_HISTORY_LIMIT, context = 2, syntax = "regex", case: caseMode = "smart" } = {}) {
28
+ const root = allProjects ? this.root : this.#projectDir();
29
+ const glob = sessionId ? [`**/${sanitizeId(sessionId)}.md`] : [];
30
+ return searchMarkdownRoot({
31
+ root,
32
+ query,
33
+ limit: clampInt(limit, 1, MAX_HISTORY_LIMIT, DEFAULT_HISTORY_LIMIT),
34
+ context,
35
+ syntax,
36
+ caseMode,
37
+ glob,
38
+ });
39
+ }
40
+
41
+ #projectDir() {
42
+ return join(this.root, "projects", cwdHash(this.cwd));
43
+ }
44
+
45
+ #fileHeader({ sessionId, sessionStats }) {
46
+ return [
47
+ `# March History · ${sessionId}`,
48
+ "",
49
+ `- cwd: ${this.cwd}`,
50
+ `- project: ${basename(this.cwd) || this.cwd}`,
51
+ sessionStats.sessionFile ? `- session_file: ${sessionStats.sessionFile}` : null,
52
+ "",
53
+ ].filter(Boolean).join("\n");
54
+ }
55
+
56
+ #formatTurn({ turn, sessionId, sessionStats, runtime }) {
57
+ const now = this.now().toISOString();
58
+ return [
59
+ `\n## Turn ${turn.index ?? "?"} · ${now}`,
60
+ "",
61
+ "metadata:",
62
+ `- session: ${sessionId}`,
63
+ `- cwd: ${this.cwd}`,
64
+ runtime.provider ? `- provider: ${runtime.provider}` : null,
65
+ runtime.modelId ? `- model: ${runtime.modelId}` : null,
66
+ sessionStats.sessionName ? `- session_name: ${sessionStats.sessionName}` : null,
67
+ "",
68
+ "### User",
69
+ safeBlock(turn.userMessage),
70
+ formatRecallSection("User memory recall", turn.userRecallHints),
71
+ "### Assistant",
72
+ safeBlock(turn.assistantMessage),
73
+ turn.thinking ? ["### Thinking", safeBlock(turn.thinking)].join("\n") : null,
74
+ "### Tool calls",
75
+ formatToolCalls(turn.toolCalls),
76
+ formatRecallSection("Assistant memory recall", turn.assistantRecallHints),
77
+ "",
78
+ ].filter(Boolean).join("\n");
79
+ }
80
+ }
81
+
82
+ function formatToolCalls(calls = []) {
83
+ if (!Array.isArray(calls) || calls.length === 0) return "(none)";
84
+ return calls.map((call) => {
85
+ const lines = [`- ${call.name ?? "unknown"} status=${call.status ?? "unknown"}`];
86
+ const args = JSON.stringify(call.args ?? null);
87
+ if (args && args !== "null") lines.push(` args: ${args}`);
88
+ if (call.status === "failed") {
89
+ lines.push(" error:");
90
+ if (call.error?.message) lines.push(` message: ${escapeSingleLine(call.error.message)}`);
91
+ if (call.error?.details) lines.push(` details: ${JSON.stringify(call.error.details)}`);
92
+ if (call.error?.excerpt) lines.push(" excerpt:", fence(call.error.excerpt));
93
+ }
94
+ return lines.join("\n");
95
+ }).join("\n");
96
+ }
97
+
98
+ function formatRecallSection(title, hints = []) {
99
+ if (!Array.isArray(hints) || hints.length === 0) return null;
100
+ return [`### ${title}`, ...hints.map((hint) => `- ${hint.id ?? "unknown"} | ${hint.name ?? hint.title ?? ""} | ${hint.description ?? ""}`)].join("\n");
101
+ }
102
+
103
+ function safeBlock(value) {
104
+ const text = String(value ?? "").trim();
105
+ return text || "(empty)";
106
+ }
107
+
108
+ function fence(value) {
109
+ return " ```text\n" + String(value ?? "").trimEnd() + "\n ```";
110
+ }
111
+
112
+ function sanitizeId(value) {
113
+ const raw = String(value ?? "session").trim() || "session";
114
+ return raw.replace(/[^a-zA-Z0-9_.-]+/g, "_").slice(0, 120) || "session";
115
+ }
116
+
117
+ function cwdHash(cwd) {
118
+ return createHash("sha1").update(String(cwd ?? "")).digest("hex").slice(0, 16);
119
+ }
120
+
121
+ function escapeSingleLine(value) {
122
+ return JSON.stringify(String(value ?? "").replace(/\s+/g, " "));
123
+ }
124
+
125
+ function clampInt(value, min, max, fallback) {
126
+ const number = Number(value);
127
+ if (!Number.isFinite(number)) return fallback;
128
+ return Math.min(max, Math.max(min, Math.floor(number)));
129
+ }
@@ -0,0 +1,39 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { toolText } from "../agent/tool-result.mjs";
4
+
5
+ export function createHistorySearchTool({ store } = {}) {
6
+ if (!store) return null;
7
+ return defineTool({
8
+ name: "history_search",
9
+ label: "History Search",
10
+ description: "Search archived March turn history with ripgrep. Use it when you need details from previous sessions or earlier turns. History stores user/assistant text, visible thinking, tool call metadata, memory recall hints, and failed tool error excerpts; successful tool results are not stored.",
11
+ parameters: Type.Object({
12
+ query: Type.String({ description: "Ripgrep query/pattern to search in archived turn history" }),
13
+ allProjects: Type.Optional(Type.Boolean({ description: "Search all project histories instead of the current cwd history. Default false." })),
14
+ sessionId: Type.Optional(Type.String({ description: "Limit search to a specific session id when known" })),
15
+ syntax: Type.Optional(Type.Union([Type.Literal("regex"), Type.Literal("literal")], { description: "Pattern syntax. Default: regex" })),
16
+ case: Type.Optional(Type.Union([Type.Literal("smart"), Type.Literal("sensitive"), Type.Literal("insensitive")], { description: "Case matching mode. Default: smart" })),
17
+ context: Type.Optional(Type.Number({ description: "Context lines around each match. Default: 2" })),
18
+ limit: Type.Optional(Type.Number({ description: "Maximum matches to return. Default: 20, max: 50" })),
19
+ }),
20
+ execute: async (_toolCallId, params) => {
21
+ try {
22
+ const results = store.searchRipgrep(params.query, params);
23
+ if (results.length === 0) return toolText(`history_search found no matches for ${JSON.stringify(params.query)}.`);
24
+ return toolText(formatHistorySearchResults(results), { results });
25
+ } catch (err) {
26
+ return toolText(`Error: ${err.message}`, { error: true });
27
+ }
28
+ },
29
+ });
30
+ }
31
+
32
+ function formatHistorySearchResults(results) {
33
+ const lines = [`history_search found ${results.length} match${results.length === 1 ? "" : "es"}.`];
34
+ results.forEach((result, index) => {
35
+ lines.push("", `[${index + 1}] ${result.path}:${result.line}`);
36
+ if (result.excerpt?.text) lines.push(result.excerpt.text);
37
+ });
38
+ return lines.join("\n");
39
+ }
@@ -73,13 +73,14 @@ export function languageIdForPath(path) {
73
73
  }
74
74
 
75
75
  export class LspClient {
76
- constructor({ serverId, command, args = [], cwd, initialization = {}, store }) {
76
+ constructor({ serverId, command, args = [], cwd, initialization = {}, store, onStatusChange = null }) {
77
77
  this.serverId = serverId;
78
78
  this.command = command;
79
79
  this.args = args;
80
80
  this.cwd = cwd;
81
81
  this.initialization = initialization;
82
82
  this.store = store;
83
+ this.onStatusChange = onStatusChange;
83
84
  this.status = "starting";
84
85
  this.process = null;
85
86
  this.buffer = Buffer.alloc(0);
@@ -98,7 +99,7 @@ export class LspClient {
98
99
  });
99
100
  this.process.stdout.on("data", (chunk) => this.#onData(chunk));
100
101
  this.process.on("exit", () => {
101
- this.status = "failed";
102
+ this.#setStatus("failed");
102
103
  for (const pending of this.pending.values()) pending.reject(new Error("LSP exited"));
103
104
  this.pending.clear();
104
105
  });
@@ -124,7 +125,7 @@ export class LspClient {
124
125
  }), INITIALIZE_TIMEOUT_MS);
125
126
  this.syncKind = getSyncKind(initialized?.capabilities);
126
127
  this.#notify("initialized", {});
127
- this.status = "ready";
128
+ this.#setStatus("ready");
128
129
  }
129
130
 
130
131
  touchFile(path) {
@@ -132,7 +133,7 @@ export class LspClient {
132
133
  const text = readFileSync(path, "utf8");
133
134
  const uri = pathToFileURL(path).href;
134
135
  const existing = this.documents.get(path);
135
- this.status = "busy";
136
+ this.#setStatus("busy");
136
137
  if (existing) {
137
138
  const version = existing.version + 1;
138
139
  this.documents.set(path, { version, text });
@@ -199,7 +200,7 @@ export class LspClient {
199
200
  uri: message.params?.uri,
200
201
  diagnostics: message.params?.diagnostics ?? [],
201
202
  });
202
- this.status = "idle";
203
+ this.#setStatus("idle");
203
204
  return;
204
205
  }
205
206
 
@@ -208,6 +209,12 @@ export class LspClient {
208
209
  }
209
210
  }
210
211
 
212
+ #setStatus(status) {
213
+ if (this.status === status) return;
214
+ this.status = status;
215
+ this.onStatusChange?.({ id: this.serverId, root: this.cwd, status });
216
+ }
217
+
211
218
  #requestResult(method) {
212
219
  if (method === "workspace/configuration") return [];
213
220
  if (method === "workspace/workspaceFolders") return [{ name: "workspace", uri: pathToFileURL(this.cwd).href }];