agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * OpenCode Zen & Go providers — runtime model discovery via /models +
3
+ * models.dev metadata enrichment.
4
+ *
5
+ * Registers two providers:
6
+ * - opencode — Zen tier (https://opencode.ai/zen/v1)
7
+ * - opencode-go — Go tier (https://opencode.ai/zen/go/v1)
8
+ */
9
+ import type { AgentContext } from "../host-types.js";
10
+ import { resolveApiKey } from "../../cli/auth/keys.js";
11
+
12
+ const ZEN_BASE_URL = "https://opencode.ai/zen/v1";
13
+ const GO_BASE_URL = "https://opencode.ai/zen/go/v1";
14
+ const MODELS_DEV_ENDPOINT = "https://models.dev/api.json";
15
+
16
+ const DEFAULT_CTX = 128_000;
17
+ const DEFAULT_MAX_TOKENS = 16_384;
18
+
19
+ const ZEN_FALLBACK = ["claude-sonnet-4-6"];
20
+ const GO_FALLBACK = ["gpt-5.2"];
21
+
22
+ // ── Types ────────────────────────────────────────────────────────
23
+
24
+ interface ModelsDevLimit { context?: number; output?: number; }
25
+ interface ModelsDevEntry {
26
+ id?: string; name?: string; reasoning?: boolean;
27
+ limit?: ModelsDevLimit; modalities?: { input?: readonly string[] };
28
+ }
29
+ type ModelsDevResponse = Record<string, { models?: Record<string, ModelsDevEntry> }>;
30
+
31
+ interface ModelDef {
32
+ id: string; reasoning: boolean; contextWindow: number;
33
+ maxTokens: number; modalities: ("text" | "image")[];
34
+ }
35
+
36
+ // ── Helpers ──────────────────────────────────────────────────────
37
+
38
+ async function fetchJson<T>(url: string): Promise<T> {
39
+ const res = await fetch(url, {
40
+ headers: { Accept: "application/json" },
41
+ signal: AbortSignal.timeout(15_000),
42
+ });
43
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
44
+ return res.json() as T;
45
+ }
46
+
47
+ function findEntry(provider: ModelsDevResponse[string] | undefined, id: string): ModelsDevEntry | undefined {
48
+ const direct = provider?.models?.[id];
49
+ if (direct) return direct;
50
+ if (!provider?.models) return undefined;
51
+ return Object.values(provider.models).find((m) => m.id === id);
52
+ }
53
+
54
+ function resolveModel(id: string, meta: ModelsDevEntry | undefined): ModelDef {
55
+ const raw = meta?.modalities?.input;
56
+ const modalities: ("text" | "image")[] = Array.isArray(raw)
57
+ ? raw.filter((v): v is "text" | "image" => v === "text" || v === "image")
58
+ : ["text"];
59
+ return {
60
+ id,
61
+ reasoning: meta?.reasoning ?? false,
62
+ contextWindow: (typeof meta?.limit?.context === "number" && meta.limit.context > 0)
63
+ ? meta.limit.context : DEFAULT_CTX,
64
+ maxTokens: (typeof meta?.limit?.output === "number" && meta.limit.output > 0)
65
+ ? meta.limit.output : DEFAULT_MAX_TOKENS,
66
+ modalities,
67
+ };
68
+ }
69
+
70
+ function reasoningParams(level: string): Record<string, unknown> {
71
+ if (level === "off") return { reasoning_effort: "none" };
72
+ return { reasoning_effort: level === "xhigh" ? "high" : level };
73
+ }
74
+
75
+ // ── Activation ───────────────────────────────────────────────────
76
+
77
+ export default function activate(ctx: AgentContext): void {
78
+ const apiKey =
79
+ process.env.OPENCODE_API_KEY ??
80
+ resolveApiKey("opencode").key ?? undefined;
81
+
82
+ ctx.agent.providers.configure("opencode", { reasoningParams });
83
+ ctx.agent.providers.register({
84
+ id: "opencode", apiKey, baseURL: ZEN_BASE_URL,
85
+ defaultModel: ZEN_FALLBACK[0], models: ZEN_FALLBACK,
86
+ supportsReasoningEffort: true,
87
+ });
88
+
89
+ ctx.agent.providers.configure("opencode-go", { reasoningParams });
90
+ ctx.agent.providers.register({
91
+ id: "opencode-go", apiKey, baseURL: GO_BASE_URL,
92
+ defaultModel: GO_FALLBACK[0], models: GO_FALLBACK,
93
+ supportsReasoningEffort: true,
94
+ });
95
+
96
+ if (!apiKey) return;
97
+
98
+ fetchModelsDev()
99
+ .then(async (md) => {
100
+ const zenIds = await fetchModelIds(ZEN_BASE_URL);
101
+ const goIds = await fetchModelIds(GO_BASE_URL);
102
+
103
+ const resolve = (ids: string[], prov: ModelsDevResponse[string] | undefined, fb: string[]) =>
104
+ (ids.length > 0 ? ids : fb).map((id) => resolveModel(id, findEntry(prov, id)));
105
+
106
+ const zen = resolve(zenIds, md?.opencode, ZEN_FALLBACK);
107
+ const go = resolve(goIds, md?.["opencode-go"], GO_FALLBACK);
108
+
109
+ ctx.agent.providers.register({
110
+ id: "opencode", apiKey, baseURL: ZEN_BASE_URL,
111
+ defaultModel: zen[0]?.id ?? ZEN_FALLBACK[0], models: zen,
112
+ supportsReasoningEffort: true,
113
+ });
114
+ ctx.agent.providers.register({
115
+ id: "opencode-go", apiKey, baseURL: GO_BASE_URL,
116
+ defaultModel: go[0]?.id ?? GO_FALLBACK[0], models: go,
117
+ supportsReasoningEffort: true,
118
+ });
119
+ })
120
+ .catch(() => {});
121
+ }
122
+
123
+ async function fetchModelsDev(): Promise<ModelsDevResponse | undefined> {
124
+ try {
125
+ const payload = await fetchJson<ModelsDevResponse>(MODELS_DEV_ENDPOINT);
126
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
127
+ return payload;
128
+ } catch { return undefined; }
129
+ }
130
+
131
+ async function fetchModelIds(baseURL: string): Promise<string[]> {
132
+ try {
133
+ const res = await fetch(`${baseURL}/models`, {
134
+ headers: { Accept: "application/json" },
135
+ signal: AbortSignal.timeout(10_000),
136
+ });
137
+ if (!res.ok) return [];
138
+ const payload = await res.json() as { data?: { id: string }[] };
139
+ if (!Array.isArray(payload.data)) return [];
140
+ return [...new Set(payload.data.map((e) => e.id).filter(Boolean))];
141
+ } catch { return []; }
142
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Built-in OpenRouter provider — auto-activates when OPENROUTER_API_KEY is set.
3
+ * Registers curated defaults synchronously so the first query works, then
4
+ * fetches the full catalog to populate /model autocomplete.
5
+ */
6
+ import type { AgentContext } from "../host-types.js";
7
+ import { getSettings } from "../../core/settings.js";
8
+ import { resolveApiKey } from "../../cli/auth/keys.js";
9
+
10
+ const BASE_URL = "https://openrouter.ai/api/v1";
11
+
12
+ const DEFAULT_MODELS = ["deepseek/deepseek-v4-flash"];
13
+
14
+ // Built-in defaults for models requiring reasoning_content echoed back
15
+ // (server 400s without it). Extend or override in settings.json:
16
+ // providers.openrouter.echoReasoningPatterns = ["deepseek", "..."]
17
+ // providers.openrouter.models[*].echoReasoning = true | false
18
+ const BUILTIN_ECHO_REASONING_PATTERNS: RegExp[] = [/deepseek/i];
19
+
20
+ // `effort: "none"` is the documented disable; honored by OpenAI/Grok, ignored
21
+ // by Anthropic/Gemini/DeepSeek-via-OpenRouter (use native deepseek for a hard off).
22
+ function buildReasoningParams(level: string, _model?: string): Record<string, unknown> {
23
+ return level === "off"
24
+ ? { reasoning: { effort: "none" } }
25
+ : { reasoning: { effort: level } };
26
+ }
27
+
28
+ interface OpenRouterModel {
29
+ id: string;
30
+ supported_parameters?: string[];
31
+ context_length?: number;
32
+ architecture?: { input_modalities?: string[] };
33
+ }
34
+
35
+ /** OpenRouter's input_modalities → the text/image subset; undefined when absent
36
+ * so the fail-closed image guard treats the model as text-only. */
37
+ function toModalities(input?: string[]): ("text" | "image")[] | undefined {
38
+ if (!Array.isArray(input)) return undefined;
39
+ const out = input.filter((v): v is "text" | "image" => v === "text" || v === "image");
40
+ return out.length ? out : undefined;
41
+ }
42
+
43
+ export default function activate(ctx: AgentContext): void {
44
+ const apiKey = resolveApiKey("openrouter").key;
45
+ ctx.agent.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
46
+ ctx.agent.providers.register({
47
+ id: "openrouter",
48
+ apiKey: apiKey ?? undefined,
49
+ baseURL: BASE_URL,
50
+ defaultModel: DEFAULT_MODELS[0],
51
+ models: DEFAULT_MODELS,
52
+ });
53
+ if (!apiKey) return;
54
+
55
+ fetchModels(apiKey).then((models) => {
56
+ if (models.length === 0) return;
57
+ const userOverrides = readUserOverrides();
58
+ const patterns = readEchoPatterns();
59
+ ctx.agent.providers.register({
60
+ id: "openrouter",
61
+ apiKey,
62
+ baseURL: BASE_URL,
63
+ defaultModel: DEFAULT_MODELS[0],
64
+ supportsReasoningEffort: true,
65
+ models: models.map((m) => ({
66
+ id: m.id,
67
+ reasoning: m.supported_parameters?.includes("reasoning") ?? false,
68
+ contextWindow: m.context_length,
69
+ echoReasoning: userOverrides.get(m.id) ?? patterns.some((re) => re.test(m.id)),
70
+ modalities: toModalities(m.architecture?.input_modalities),
71
+ })),
72
+ });
73
+ }).catch(() => { /* keep curated defaults */ });
74
+ }
75
+
76
+ function readEchoPatterns(): RegExp[] {
77
+ const userPatterns = getSettings().providers?.openrouter?.echoReasoningPatterns ?? [];
78
+ const compiled: RegExp[] = [];
79
+ for (const src of userPatterns) {
80
+ try { compiled.push(new RegExp(src, "i")); }
81
+ catch { /* skip invalid pattern */ }
82
+ }
83
+ return [...BUILTIN_ECHO_REASONING_PATTERNS, ...compiled];
84
+ }
85
+
86
+ function readUserOverrides(): Map<string, boolean> {
87
+ const out = new Map<string, boolean>();
88
+ const models = getSettings().providers?.openrouter?.models;
89
+ if (!Array.isArray(models)) return out;
90
+ for (const m of models) {
91
+ if (typeof m === "object" && m && m.echoReasoning !== undefined) {
92
+ out.set(m.id, m.echoReasoning);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ async function fetchModels(apiKey: string): Promise<OpenRouterModel[]> {
99
+ const res = await fetch(`${BASE_URL}/models`, {
100
+ headers: { Authorization: `Bearer ${apiKey}` },
101
+ });
102
+ if (!res.ok) return [];
103
+ const data = await res.json() as { data?: OpenRouterModel[] };
104
+ return data.data ?? [];
105
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Z.AI Coding Plan — Zhipu AI's subscription GLM models for coding.
3
+ */
4
+ import type { AgentContext } from "../host-types.js";
5
+ import { resolveApiKey } from "../../cli/auth/keys.js";
6
+
7
+ const BASE_URL = "https://api.z.ai/api/coding/paas/v4";
8
+ const ID = "zai-coding-plan";
9
+
10
+ const DEFAULT_MODELS = [
11
+ { id: "glm-5.1", reasoning: true, contextWindow: 200_000 },
12
+ { id: "glm-5-turbo", reasoning: true, contextWindow: 200_000 },
13
+ { id: "glm-4.7", reasoning: true, contextWindow: 204_800 },
14
+ { id: "glm-4.5-air", reasoning: true, contextWindow: 131_072 },
15
+ ];
16
+
17
+ function buildReasoningParams(level: string, _model?: string): Record<string, unknown> {
18
+ if (level === "off") return { thinking: { type: "disabled" } };
19
+ const effort = level === "xhigh" ? "high" : level;
20
+ return { thinking: { type: "enabled" }, reasoning_effort: effort };
21
+ }
22
+
23
+ export default function activate(ctx: AgentContext): void {
24
+ const { key } = resolveApiKey(ID);
25
+ ctx.agent.providers.configure(ID, { reasoningParams: buildReasoningParams });
26
+ ctx.agent.providers.register({
27
+ id: ID,
28
+ apiKey: key ?? process.env.ZAI_API_KEY ?? process.env.ZHIPU_API_KEY,
29
+ baseURL: BASE_URL,
30
+ defaultModel: DEFAULT_MODELS[0].id,
31
+ models: DEFAULT_MODELS,
32
+ });
33
+ }
@@ -0,0 +1,336 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import * as crypto from "node:crypto";
5
+ import type { AgentShMessage } from "./llm-client.js";
6
+ export type { AgentShMessage } from "./llm-client.js";
7
+
8
+ export interface SessionHeaderEntry {
9
+ type: "session";
10
+ id: string;
11
+ parentId: null;
12
+ timestamp: number;
13
+ cwd: string;
14
+ version: 1;
15
+ }
16
+
17
+ export interface MessageEntry {
18
+ type: "message";
19
+ id: string;
20
+ parentId: string;
21
+ timestamp: number;
22
+ message: AgentShMessage;
23
+ }
24
+
25
+ export interface CompactionEntry {
26
+ type: "compaction";
27
+ id: string;
28
+ parentId: string;
29
+ timestamp: number;
30
+ firstKeptId: string;
31
+ tokensBefore: number;
32
+ summary?: string;
33
+ }
34
+
35
+ /** Omitted from buildMessages — the agent already saw it via <shell_events>. */
36
+ export interface ShellExchangeEntry {
37
+ type: "shell-exchange";
38
+ id: string;
39
+ parentId: string;
40
+ timestamp: number;
41
+ command: string;
42
+ output: string;
43
+ exitCode: number | null;
44
+ cwd?: string;
45
+ private?: boolean;
46
+ }
47
+
48
+ export type SessionEntry =
49
+ | SessionHeaderEntry
50
+ | MessageEntry
51
+ | CompactionEntry
52
+ | ShellExchangeEntry;
53
+
54
+ export function newEntryId(): string {
55
+ return crypto.randomBytes(4).toString("hex");
56
+ }
57
+
58
+ function extractText(content: unknown): string {
59
+ if (typeof content === "string") return content;
60
+ if (Array.isArray(content)) {
61
+ return content.map((p) => {
62
+ if (typeof p === "string") return p;
63
+ const part = p as { text?: string; content?: string };
64
+ return part?.text ?? part?.content ?? "";
65
+ }).join(" ");
66
+ }
67
+ return "";
68
+ }
69
+
70
+ function snippet(text: string, max: number): string {
71
+ const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
72
+ if (cleaned.length <= max) return cleaned || "(empty)";
73
+ return cleaned.slice(0, max) + "…";
74
+ }
75
+
76
+ export function summarizeMessage(m: AgentShMessage): string {
77
+ const role = m.role ?? "?";
78
+ if (role === "assistant") {
79
+ const tc = (m as { tool_calls?: Array<{ function?: { name?: string; arguments?: string } }> }).tool_calls;
80
+ if (Array.isArray(tc) && tc.length > 0) {
81
+ const tools = tc.map((t) => {
82
+ const name = t.function?.name ?? "tool";
83
+ const args = t.function?.arguments;
84
+ return args ? `${name}(${snippet(args, 200)})` : name;
85
+ }).join(", ");
86
+ const text = extractText(m.content);
87
+ const prefix = text ? `${snippet(text, 400)} → ` : "";
88
+ return `assistant: ${prefix}called ${tools}`;
89
+ }
90
+ }
91
+ if (role === "tool") {
92
+ const text = typeof m.content === "string" ? m.content : extractText(m.content);
93
+ const isErr = /^error\b|: error\b/i.test(text.slice(0, 200));
94
+ return `tool result: ${snippet(text, isErr ? 1000 : 400)}`;
95
+ }
96
+ if (role === "user") {
97
+ return `user: ${snippet(extractText(m.content), 1000)}`;
98
+ }
99
+ return `${role}: ${snippet(extractText(m.content), 500)}`;
100
+ }
101
+
102
+ /** For displayed user text. Loops because both wrappers can stack at the head. */
103
+ export function stripContextWrappers(content: string): string {
104
+ let out = content;
105
+ for (;;) {
106
+ const next = out.replace(/^\s*<(query_context|dynamic_context)>[\s\S]*?<\/\1>\s*/, "");
107
+ if (next === out) return out;
108
+ out = next;
109
+ }
110
+ }
111
+
112
+ export function renderEvictedSummary(evicted: AgentShMessage[]): string {
113
+ const lines = evicted.map((m) => `- ${summarizeMessage(m)}`);
114
+ return `${lines.length} message(s) elided\n${lines.join("\n")}`;
115
+ }
116
+
117
+ export class SessionStore {
118
+ private entriesPath: string;
119
+ private leafPath: string;
120
+ private entries = new Map<string, SessionEntry>();
121
+ private rootId = "";
122
+ private activeLeaf = "";
123
+ private pendingHeader: SessionHeaderEntry | null = null;
124
+ readonly id: string;
125
+
126
+ constructor(filePath: string, opts?: { create?: { cwd: string; sessionId: string } }) {
127
+ this.entriesPath = filePath;
128
+ this.leafPath = filePath + ".leaf";
129
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
130
+
131
+ if (opts?.create) {
132
+ this.id = opts.create.sessionId;
133
+ const header: SessionHeaderEntry = {
134
+ type: "session",
135
+ id: opts.create.sessionId,
136
+ parentId: null,
137
+ timestamp: Date.now(),
138
+ cwd: opts.create.cwd,
139
+ version: 1,
140
+ };
141
+ this.entries.set(header.id, header);
142
+ this.rootId = header.id;
143
+ this.activeLeaf = header.id;
144
+ this.pendingHeader = header;
145
+ } else {
146
+ this.id = "";
147
+ this.load();
148
+ if (!this.rootId) throw new Error(`session file lacks a session header: ${filePath}`);
149
+ this.id = this.rootId;
150
+ }
151
+ }
152
+
153
+ /** Deferred so an opened-but-unused session leaves no files on disk. */
154
+ private flushHeader(): void {
155
+ if (!this.pendingHeader) return;
156
+ const headerLine = JSON.stringify(this.pendingHeader) + "\n";
157
+ this.pendingHeader = null;
158
+ fs.writeFileSync(this.entriesPath, headerLine);
159
+ this.persistLeaf();
160
+ }
161
+
162
+ getActiveLeaf(): string { return this.activeLeaf; }
163
+ setActiveLeaf(id: string): void {
164
+ if (!this.entries.has(id)) throw new Error(`unknown entry: ${id}`);
165
+ this.activeLeaf = id;
166
+ this.persistLeaf();
167
+ }
168
+ getRootId(): string { return this.rootId; }
169
+ getEntry(id: string): SessionEntry | undefined { return this.entries.get(id); }
170
+ getAllEntries(): SessionEntry[] { return [...this.entries.values()]; }
171
+
172
+ async appendMessages(messages: AgentShMessage[]): Promise<string[]> {
173
+ if (messages.length === 0) return [];
174
+ this.flushHeader();
175
+ let parent = this.activeLeaf;
176
+ const lines: string[] = [];
177
+ const newIds: string[] = [];
178
+ for (const m of messages) {
179
+ const e: MessageEntry = {
180
+ type: "message",
181
+ id: newEntryId(),
182
+ parentId: parent,
183
+ timestamp: Date.now(),
184
+ message: m,
185
+ };
186
+ this.entries.set(e.id, e);
187
+ lines.push(JSON.stringify(e));
188
+ newIds.push(e.id);
189
+ parent = e.id;
190
+ }
191
+ this.activeLeaf = parent;
192
+ await fsp.appendFile(this.entriesPath, lines.join("\n") + "\n");
193
+ this.persistLeaf();
194
+ return newIds;
195
+ }
196
+
197
+ async appendShellExchange(e: {
198
+ command: string;
199
+ output: string;
200
+ exitCode: number | null;
201
+ cwd?: string;
202
+ private?: boolean;
203
+ }): Promise<string> {
204
+ this.flushHeader();
205
+ const entry: ShellExchangeEntry = {
206
+ type: "shell-exchange",
207
+ id: newEntryId(),
208
+ parentId: this.activeLeaf,
209
+ timestamp: Date.now(),
210
+ command: e.command,
211
+ output: e.output,
212
+ exitCode: e.exitCode,
213
+ ...(e.cwd !== undefined ? { cwd: e.cwd } : {}),
214
+ ...(e.private ? { private: true } : {}),
215
+ };
216
+ this.entries.set(entry.id, entry);
217
+ this.activeLeaf = entry.id;
218
+ await fsp.appendFile(this.entriesPath, JSON.stringify(entry) + "\n");
219
+ this.persistLeaf();
220
+ return entry.id;
221
+ }
222
+
223
+ async appendCompaction(firstKeptId: string, tokensBefore: number, summary?: string): Promise<string> {
224
+ if (!this.entries.has(firstKeptId)) throw new Error(`firstKeptId unknown: ${firstKeptId}`);
225
+ this.flushHeader();
226
+ const e: CompactionEntry = {
227
+ type: "compaction",
228
+ id: newEntryId(),
229
+ parentId: this.activeLeaf,
230
+ timestamp: Date.now(),
231
+ firstKeptId,
232
+ tokensBefore,
233
+ ...(summary !== undefined ? { summary } : {}),
234
+ };
235
+ this.entries.set(e.id, e);
236
+ this.activeLeaf = e.id;
237
+ await fsp.appendFile(this.entriesPath, JSON.stringify(e) + "\n");
238
+ this.persistLeaf();
239
+ return e.id;
240
+ }
241
+
242
+ /** Returns oldest-first. */
243
+ getBranch(leafId: string = this.activeLeaf): SessionEntry[] {
244
+ const out: SessionEntry[] = [];
245
+ const seen = new Set<string>();
246
+ let cur: string | null = leafId;
247
+ while (cur && !seen.has(cur)) {
248
+ seen.add(cur);
249
+ const e = this.entries.get(cur);
250
+ if (!e) break;
251
+ out.push(e);
252
+ cur = e.parentId;
253
+ }
254
+ return out.reverse();
255
+ }
256
+
257
+ /** Latest compaction on the branch replaces the evicted prefix with
258
+ * its stored summary, or a rendered one if no summary was stored. */
259
+ buildMessages(leafId: string = this.activeLeaf): AgentShMessage[] {
260
+ return this.buildBranchWithIds(leafId).messages;
261
+ }
262
+
263
+ /** Parallel entryIds array — null for the synthetic compaction-summary
264
+ * slot — so callers can map message indices back to on-disk ids. */
265
+ buildBranchWithIds(leafId: string = this.activeLeaf): { messages: AgentShMessage[]; entryIds: (string | null)[] } {
266
+ const branch = this.getBranch(leafId);
267
+ let compactionIdx = -1;
268
+ for (let i = branch.length - 1; i >= 0; i--) {
269
+ if (branch[i]!.type === "compaction") { compactionIdx = i; break; }
270
+ }
271
+ const messages: AgentShMessage[] = [];
272
+ const entryIds: (string | null)[] = [];
273
+ let startIdx = 0;
274
+ if (compactionIdx >= 0) {
275
+ const c = branch[compactionIdx] as CompactionEntry;
276
+ const firstKeptIdx = branch.findIndex((e) => e.id === c.firstKeptId);
277
+ startIdx = firstKeptIdx >= 0 ? firstKeptIdx : 0;
278
+ const summary = c.summary ?? renderEvictedSummary(
279
+ branch.slice(0, startIdx)
280
+ .filter((e): e is MessageEntry => e.type === "message")
281
+ .map((e) => e.message),
282
+ );
283
+ messages.push({ role: "user", content: `[Compacted conversation summary]\n${summary}` });
284
+ entryIds.push(null);
285
+ }
286
+ for (let i = startIdx; i < branch.length; i++) {
287
+ const e = branch[i]!;
288
+ if (e.type === "message") {
289
+ messages.push(e.message);
290
+ entryIds.push(e.id);
291
+ }
292
+ }
293
+ return { messages, entryIds };
294
+ }
295
+
296
+ getPreview(): string {
297
+ for (const e of this.entries.values()) {
298
+ if (e.type === "message" && e.message.role === "user") {
299
+ const raw = typeof e.message.content === "string" ? e.message.content : "";
300
+ const txt = stripContextWrappers(raw).replace(/\s+/g, " ").trim();
301
+ if (txt) return txt.slice(0, 80);
302
+ }
303
+ }
304
+ return "(empty)";
305
+ }
306
+
307
+ private load(): void {
308
+ let raw: string;
309
+ try { raw = fs.readFileSync(this.entriesPath, "utf-8"); }
310
+ catch { return; }
311
+ for (const line of raw.split("\n")) {
312
+ if (!line) continue;
313
+ try {
314
+ const e = JSON.parse(line) as SessionEntry;
315
+ if (!e.id) continue;
316
+ this.entries.set(e.id, e);
317
+ if (e.type === "session") this.rootId = e.id;
318
+ } catch { /* skip malformed */ }
319
+ }
320
+ try {
321
+ this.activeLeaf = fs.readFileSync(this.leafPath, "utf-8").trim();
322
+ if (!this.entries.has(this.activeLeaf)) this.activeLeaf = this.rootId;
323
+ } catch { this.activeLeaf = this.lastEntryId(); }
324
+ }
325
+
326
+ private lastEntryId(): string {
327
+ let lastId = this.rootId;
328
+ for (const e of this.entries.values()) lastId = e.id;
329
+ return lastId;
330
+ }
331
+
332
+ private persistLeaf(): void {
333
+ if (this.pendingHeader) return;
334
+ fs.writeFileSync(this.leafPath, this.activeLeaf);
335
+ }
336
+ }