archymedes-cli 2.2.0 → 2.5.0

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.
@@ -0,0 +1,273 @@
1
+ import {
2
+ GUIDE_TOPICS,
3
+ applyViewport,
4
+ newViewport,
5
+ visibleLines,
6
+ wrapText
7
+ } from "./archymedes-d3azqkay.mjs";
8
+ import {
9
+ joinHorizontal,
10
+ padToWidth,
11
+ scrollIndicator,
12
+ scrollPercent,
13
+ sliceToWidth
14
+ } from "./archymedes-5vhvv8kr.mjs";
15
+ import"./archymedes-bmtdqqwt.mjs";
16
+ import {
17
+ NO_COLOR_PALETTE,
18
+ exitTopTermUIApp,
19
+ visibleWidth
20
+ } from "./archymedes-130jt0wr.mjs";
21
+ import {
22
+ __require
23
+ } from "./archymedes-x67v3n58.mjs";
24
+
25
+ // packages/archymedes-cli/src/ui/guide-screen.tsx
26
+ import { useInput, useState } from "@termuijs/jsx";
27
+ import { Box, Text } from "@termuijs/widgets";
28
+
29
+ // packages/archymedes-cli/src/ui/guide-browser.ts
30
+ function initialGuideState(columns, rows, palette = NO_COLOR_PALETTE) {
31
+ return { topics: GUIDE_TOPICS, selected: 0, scroll: 0, query: "", searching: false, columns, rows, palette };
32
+ }
33
+ function visibleTopics(state) {
34
+ const needle = state.query.trim().toLowerCase();
35
+ if (!needle)
36
+ return [...state.topics];
37
+ return state.topics.filter((topic) => [topic.title, topic.summary, topic.id, ...topic.body, ...(topic.examples ?? []).flatMap((example) => [example.input, example.effect])].some((text) => text.toLowerCase().includes(needle)));
38
+ }
39
+ function currentTopic(state) {
40
+ const topics = visibleTopics(state);
41
+ if (topics.length === 0)
42
+ return;
43
+ return topics[Math.max(0, Math.min(state.selected, topics.length - 1))];
44
+ }
45
+ function sidebarWidth(columns) {
46
+ const width = Math.max(1, Math.floor(columns));
47
+ return Math.min(width, Math.max(14, Math.min(26, Math.floor(width * 0.28))));
48
+ }
49
+ function bodyHeight(rows) {
50
+ return Math.max(0, Math.floor(rows) - 2);
51
+ }
52
+ function keyToGuideAction(key, character, searching) {
53
+ const name = key.name ?? "";
54
+ if (key.ctrl && name === "c")
55
+ return { kind: "exit" };
56
+ const isEnter = name === "return" || name === "enter" || name === "\r" || name === `
57
+ `;
58
+ const isEscape = name === "escape" || name === "\x1B";
59
+ if (searching) {
60
+ if (isEscape || isEnter)
61
+ return { kind: "commit" };
62
+ if (name === "backspace")
63
+ return { kind: "backspace" };
64
+ if (name === "up")
65
+ return { kind: "move", step: -1 };
66
+ if (name === "down")
67
+ return { kind: "move", step: 1 };
68
+ if (character && character.length === 1 && character >= " ")
69
+ return { kind: "type", character };
70
+ return { kind: "none" };
71
+ }
72
+ if (isEscape || name === "q")
73
+ return { kind: "exit" };
74
+ if (character === "/" || name === "f3")
75
+ return { kind: "search" };
76
+ if (name === "up" || name === "k")
77
+ return { kind: "move", step: -1 };
78
+ if (name === "down" || name === "j")
79
+ return { kind: "move", step: 1 };
80
+ if (name === "tab")
81
+ return { kind: "move", step: key.shift ? -1 : 1 };
82
+ if (name === "pagedown" || character === " ")
83
+ return { kind: "page", step: 1 };
84
+ if (name === "pageup")
85
+ return { kind: "page", step: -1 };
86
+ if (name === "right")
87
+ return { kind: "scroll", rows: 1 };
88
+ if (name === "left")
89
+ return { kind: "scroll", rows: -1 };
90
+ return { kind: "none" };
91
+ }
92
+ function applyGuideAction(state, action) {
93
+ const topics = visibleTopics(state);
94
+ switch (action.kind) {
95
+ case "move": {
96
+ if (topics.length === 0)
97
+ return state;
98
+ const next = ((state.selected + action.step) % topics.length + topics.length) % topics.length;
99
+ return { ...state, selected: next, scroll: 0 };
100
+ }
101
+ case "scroll":
102
+ case "page": {
103
+ const topic = currentTopic(state);
104
+ if (!topic)
105
+ return state;
106
+ const lines = topicLines(topic, bodyWidth(state.columns)).map((line) => line.text);
107
+ const viewport = { ...newViewport(lines, bodyHeight(state.rows)), top: state.scroll };
108
+ const moved = action.kind === "page" ? applyViewport(viewport, { kind: action.step === 1 ? "pageDown" : "pageUp" }) : applyViewport(viewport, action.rows > 0 ? { kind: "down", rows: action.rows } : { kind: "up", rows: -action.rows });
109
+ return { ...state, scroll: moved.top };
110
+ }
111
+ case "search":
112
+ return { ...state, searching: true };
113
+ case "commit":
114
+ return { ...state, searching: false };
115
+ case "backspace":
116
+ return { ...state, query: state.query.slice(0, -1), selected: 0, scroll: 0 };
117
+ case "type":
118
+ return { ...state, query: state.query + action.character, selected: 0, scroll: 0 };
119
+ default:
120
+ return state;
121
+ }
122
+ }
123
+ function bodyWidth(columns) {
124
+ const width = Math.max(1, Math.floor(columns));
125
+ return width < 40 ? width : Math.max(1, width - sidebarWidth(width) - 3);
126
+ }
127
+ function topicLines(topic, width) {
128
+ const measure = Math.max(1, Math.floor(width));
129
+ const wrap = (text, available = measure) => wrapText(text, Math.max(1, available)).flatMap((line) => hardWrap(line, Math.max(1, available)));
130
+ const rows = [
131
+ ...wrap(topic.title).map((line) => ({ text: line, bold: true })),
132
+ { text: "" }
133
+ ];
134
+ for (const paragraph of topic.body) {
135
+ for (const line of wrap(paragraph))
136
+ rows.push({ text: line });
137
+ rows.push({ text: "" });
138
+ }
139
+ if (topic.examples && topic.examples.length > 0) {
140
+ rows.push({ text: "try", bold: true });
141
+ for (const example of topic.examples) {
142
+ const commandIndent = " ".repeat(Math.min(2, Math.max(0, measure - 1)));
143
+ const detailIndent = " ".repeat(Math.min(4, Math.max(0, measure - 1)));
144
+ const [head, ...rest] = wrap(example.input, measure - visibleWidth(detailIndent));
145
+ rows.push({ text: `${commandIndent}${head ?? ""}`, bold: true });
146
+ for (const line of rest)
147
+ rows.push({ text: `${detailIndent}${line}`, bold: true });
148
+ for (const line of wrap(example.effect, measure - visibleWidth(detailIndent)))
149
+ rows.push({ text: `${detailIndent}${line}`, dim: true });
150
+ }
151
+ }
152
+ return rows;
153
+ }
154
+ function hardWrap(text, width) {
155
+ if (visibleWidth(text) <= width)
156
+ return [text];
157
+ const lines = [];
158
+ let line = "";
159
+ const segments = typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)].map((entry) => entry.segment) : [...text];
160
+ for (const segment of segments) {
161
+ if (line && visibleWidth(line + segment) > width) {
162
+ lines.push(line);
163
+ line = "";
164
+ }
165
+ if (visibleWidth(segment) > width) {
166
+ lines.push("…");
167
+ continue;
168
+ }
169
+ line += segment;
170
+ }
171
+ if (line)
172
+ lines.push(line);
173
+ return lines;
174
+ }
175
+ function composeGuideFrame(state) {
176
+ const columns = Math.max(1, Math.floor(state.columns));
177
+ const rowCount = Math.max(1, Math.floor(state.rows));
178
+ const compact = columns < 40;
179
+ const sidebar = compact ? 0 : sidebarWidth(columns);
180
+ const body = bodyWidth(columns);
181
+ const height = bodyHeight(rowCount);
182
+ const topics = visibleTopics(state);
183
+ const selectedIndex = topics.length === 0 ? -1 : Math.max(0, Math.min(state.selected, topics.length - 1));
184
+ const topic = selectedIndex === -1 ? undefined : topics[selectedIndex];
185
+ const theme = state.palette.tokens;
186
+ const rows = [];
187
+ rows.push({
188
+ text: padToWidth(compact ? ` archymedes guide · ${topic?.title ?? "no matches"}` : " archymedes guide", columns),
189
+ bold: true,
190
+ color: theme.primary
191
+ });
192
+ if (rowCount === 1)
193
+ return rows;
194
+ const lines = topic ? topicLines(topic, body) : [{ text: "Nothing matches that." }];
195
+ const indices = visibleLines({ ...newViewport(lines.map((_, index) => `${index}`), height), top: state.scroll });
196
+ const start = indices.length > 0 ? Number(indices[0]) : 0;
197
+ const window = lines.slice(start, start + height);
198
+ const listStart = Math.max(0, Math.min(selectedIndex - Math.floor(height / 2), Math.max(0, topics.length - height)));
199
+ for (let offset = 0;offset < height; offset += 1) {
200
+ const listed = topics[listStart + offset];
201
+ const chosen = listed !== undefined && listStart + offset === selectedIndex;
202
+ const cell = listed ? `${chosen ? "›" : " "} ${listed.title}` : "";
203
+ const line = window[offset];
204
+ rows.push({
205
+ text: compact ? padToWidth(line?.text ?? "", columns) : joinHorizontal(sliceToWidth(cell, sidebar), line?.text ?? "", { leftWidth: sidebar, rightWidth: body, separator: " │ " }),
206
+ bold: chosen || line?.bold,
207
+ dim: line?.dim && !chosen,
208
+ ...chosen ? { color: theme.accent } : line?.bold ? { color: theme.primary } : {}
209
+ });
210
+ }
211
+ const position = lines.length > height ? ` ${scrollIndicator(scrollPercent(start, lines.length, height))}` : "";
212
+ const footer = state.searching ? `search: ${state.query}▏ Enter done · Esc done` : `↑↓ topic · ←→ scroll · space page · / search · Esc back${state.query ? ` filter: ${state.query}` : ""}${position}`;
213
+ rows.push({ text: padToWidth(` ${footer}`, columns), dim: true, color: theme.textMuted });
214
+ return rows;
215
+ }
216
+
217
+ // packages/archymedes-cli/src/ui/guide-screen.tsx
218
+ import { jsxDEV } from "@termuijs/jsx/jsx-dev-runtime";
219
+ var Panel = Box;
220
+ var Line = Text;
221
+ function GuideScreen({ columns, rows, onExit, startAt, palette = NO_COLOR_PALETTE }) {
222
+ const [state, setState] = useState(() => {
223
+ const initial = initialGuideState(columns, rows, palette);
224
+ const index = startAt ? initial.topics.findIndex((topic) => topic.id === startAt) : -1;
225
+ return index >= 0 ? { ...initial, selected: index } : initial;
226
+ });
227
+ useInput((input, key) => {
228
+ const action = keyToGuideAction({ name: key?.key, ctrl: key?.ctrl, shift: key?.shift }, input, state.searching);
229
+ if (action.kind === "exit") {
230
+ onExit();
231
+ return;
232
+ }
233
+ setState((current) => applyGuideAction(current, action));
234
+ });
235
+ const frame = composeGuideFrame(state);
236
+ return /* @__PURE__ */ jsxDEV(Panel, {
237
+ flexDirection: "column",
238
+ width: state.columns,
239
+ height: state.rows,
240
+ children: frame.map((row, index) => /* @__PURE__ */ jsxDEV(Line, {
241
+ bold: row.bold,
242
+ dimColor: row.dim,
243
+ inverse: row.inverse,
244
+ color: row.color,
245
+ children: row.text
246
+ }, `row-${index}`, false, undefined, this))
247
+ }, undefined, false, undefined, this);
248
+ }
249
+ async function runGuideScreen(options) {
250
+ const { renderApp } = await import("@termuijs/jsx");
251
+ await new Promise((resolve) => {
252
+ let settled = false;
253
+ const finish = () => {
254
+ if (settled)
255
+ return;
256
+ settled = true;
257
+ exitTopTermUIApp();
258
+ resolve();
259
+ };
260
+ renderApp(GuideScreen, {
261
+ columns: options.columns,
262
+ rows: options.rows,
263
+ startAt: options.startAt,
264
+ palette: options.palette,
265
+ onExit: finish,
266
+ fullscreen: true
267
+ }).catch(finish);
268
+ });
269
+ }
270
+ export {
271
+ runGuideScreen,
272
+ GuideScreen
273
+ };
@@ -0,0 +1,155 @@
1
+ import {
2
+ __require
3
+ } from "./archymedes-x67v3n58.mjs";
4
+
5
+ // packages/archymedes-cli/src/ui/pager-screen.tsx
6
+ import { useInput, useState } from "@termuijs/jsx";
7
+ import { Box, Text } from "@termuijs/widgets";
8
+
9
+ // packages/archymedes-cli/src/ui/text-pager.ts
10
+ var ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
11
+ function initialPagerState(text, height) {
12
+ const lines = text.replace(ANSI, "").replace(/\r/g, "").split(`
13
+ `);
14
+ const rows = Math.max(1, height);
15
+ return { lines, top: Math.max(0, lines.length - rows), height: rows, search: { query: "", typing: false } };
16
+ }
17
+ function keyToPagerAction(key, character, state) {
18
+ const name = key.name ?? "";
19
+ if (key.ctrl && name === "c")
20
+ return { kind: "exit" };
21
+ if (state.search.typing) {
22
+ if (name === "escape")
23
+ return { kind: "searchCancel" };
24
+ if (name === "return" || name === "enter")
25
+ return { kind: "searchCommit" };
26
+ if (name === "backspace")
27
+ return { kind: "searchBackspace" };
28
+ if (character && character.length === 1 && character >= " ")
29
+ return { kind: "searchType", character };
30
+ return { kind: "none" };
31
+ }
32
+ if (name === "escape" || character === "q")
33
+ return { kind: "exit" };
34
+ if (name === "up" || character === "k")
35
+ return { kind: "scroll", rows: -1 };
36
+ if (name === "down" || character === "j" || name === "return" || name === "enter")
37
+ return { kind: "scroll", rows: 1 };
38
+ if (name === "pageup" || character === "b")
39
+ return { kind: "scroll", rows: -(state.height - 1) };
40
+ if (name === "pagedown" || name === "space" || character === " ")
41
+ return { kind: "scroll", rows: state.height - 1 };
42
+ if (name === "home" || character === "g")
43
+ return { kind: "home" };
44
+ if (name === "end" || character === "G")
45
+ return { kind: "end" };
46
+ if (character === "/")
47
+ return { kind: "search" };
48
+ if (character === "n")
49
+ return { kind: "searchNext" };
50
+ return { kind: "none" };
51
+ }
52
+ function clampTop(state, top) {
53
+ return Math.max(0, Math.min(top, Math.max(0, state.lines.length - state.height)));
54
+ }
55
+ function findFrom(state, from) {
56
+ const needle = state.search.query.toLowerCase();
57
+ if (!needle)
58
+ return -1;
59
+ for (let offset = 0;offset < state.lines.length; offset += 1) {
60
+ const row = (from + offset) % state.lines.length;
61
+ if (state.lines[row].toLowerCase().includes(needle))
62
+ return row;
63
+ }
64
+ return -1;
65
+ }
66
+ function applyPagerAction(state, action) {
67
+ switch (action.kind) {
68
+ case "exit":
69
+ return { state, exit: true };
70
+ case "scroll":
71
+ return { state: { ...state, top: clampTop(state, state.top + action.rows), message: undefined } };
72
+ case "home":
73
+ return { state: { ...state, top: 0, message: undefined } };
74
+ case "end":
75
+ return { state: { ...state, top: clampTop(state, state.lines.length), message: undefined } };
76
+ case "search":
77
+ return { state: { ...state, search: { query: "", typing: true }, message: undefined } };
78
+ case "searchType":
79
+ return { state: { ...state, search: { ...state.search, query: state.search.query + action.character } } };
80
+ case "searchBackspace":
81
+ return { state: { ...state, search: { ...state.search, query: state.search.query.slice(0, -1) } } };
82
+ case "searchCancel":
83
+ return { state: { ...state, search: { query: "", typing: false } } };
84
+ case "searchCommit":
85
+ case "searchNext": {
86
+ const settled = { ...state, search: { ...state.search, typing: false } };
87
+ const row = findFrom(settled, action.kind === "searchCommit" ? state.top : state.top + 1);
88
+ if (row < 0)
89
+ return { state: { ...settled, message: state.search.query ? `no match for "${state.search.query}"` : undefined } };
90
+ return { state: { ...settled, top: clampTop(settled, row), message: undefined } };
91
+ }
92
+ default:
93
+ return { state };
94
+ }
95
+ }
96
+ function composePagerFrame(state, columns, title = "transcript") {
97
+ const clip = (text) => text.length <= columns ? text : text.slice(0, Math.max(0, columns - 1));
98
+ const last = Math.min(state.lines.length, state.top + state.height);
99
+ const position = `${state.lines.length === 0 ? 0 : state.top + 1}-${last} of ${state.lines.length}`;
100
+ const body = state.lines.slice(state.top, state.top + state.height).map((text) => ({ text: clip(text) }));
101
+ while (body.length < state.height)
102
+ body.push({ text: "" });
103
+ const bar = state.search.typing ? `/${state.search.query} Enter find · Esc cancel` : state.message ?? "↑↓ scroll · PgUp/PgDn page · g/G ends · / find · Esc back";
104
+ return [
105
+ { text: clip(`${title} ${position}`), bold: true },
106
+ ...body,
107
+ { text: clip(bar), dim: true }
108
+ ];
109
+ }
110
+
111
+ // packages/archymedes-cli/src/ui/pager-screen.tsx
112
+ import { jsxDEV } from "@termuijs/jsx/jsx-dev-runtime";
113
+ var Panel = Box;
114
+ var Line = Text;
115
+ var CHROME_ROWS = 2;
116
+ function PagerScreen({ columns, rows, text, title, onExit }) {
117
+ const [state, setState] = useState(() => initialPagerState(text, rows - CHROME_ROWS));
118
+ useInput((input, key) => {
119
+ const action = keyToPagerAction({ name: key?.key, ctrl: key?.ctrl }, input, state);
120
+ const step = applyPagerAction(state, action);
121
+ if (step.exit) {
122
+ onExit();
123
+ return;
124
+ }
125
+ setState(step.state);
126
+ });
127
+ const frame = composePagerFrame(state, columns, title);
128
+ return /* @__PURE__ */ jsxDEV(Panel, {
129
+ flexDirection: "column",
130
+ width: columns,
131
+ height: rows,
132
+ children: frame.map((row, index) => /* @__PURE__ */ jsxDEV(Line, {
133
+ bold: row.bold,
134
+ dimColor: row.dim,
135
+ children: row.text
136
+ }, `row-${index}`, false, undefined, this))
137
+ }, undefined, false, undefined, this);
138
+ }
139
+ async function runPagerScreen(options) {
140
+ const { renderApp } = await import("@termuijs/jsx");
141
+ await new Promise((resolve) => {
142
+ let settled = false;
143
+ const finish = () => {
144
+ if (settled)
145
+ return;
146
+ settled = true;
147
+ resolve();
148
+ };
149
+ renderApp(PagerScreen, { ...options, onExit: finish, fullscreen: true }).catch(finish);
150
+ });
151
+ }
152
+ export {
153
+ runPagerScreen,
154
+ PagerScreen
155
+ };
@@ -0,0 +1,248 @@
1
+ import {
2
+ eventJournalPath,
3
+ readEventJournal
4
+ } from "./archymedes-w4bjfye7.mjs";
5
+ import {
6
+ __require
7
+ } from "./archymedes-x67v3n58.mjs";
8
+
9
+ // packages/core/src/cli/projection.ts
10
+ import { promises as fs } from "node:fs";
11
+ import path from "node:path";
12
+ var PROJECTION_SCHEMA_VERSION = 1;
13
+ async function loadSqlite() {
14
+ const original = process.emitWarning;
15
+ process.emitWarning = (warning, ...rest) => {
16
+ const text = typeof warning === "string" ? warning : warning?.message ?? "";
17
+ const type = typeof rest[0] === "string" ? rest[0] : rest[0]?.type;
18
+ if (type === "ExperimentalWarning" && /sqlite/i.test(text))
19
+ return;
20
+ return original.call(process, warning, ...rest);
21
+ };
22
+ try {
23
+ return await import("node:sqlite");
24
+ } finally {
25
+ process.emitWarning = original;
26
+ }
27
+ }
28
+ var SCHEMA = `
29
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
30
+ CREATE TABLE events (
31
+ session_id TEXT NOT NULL,
32
+ sequence INTEGER NOT NULL,
33
+ timestamp TEXT NOT NULL,
34
+ type TEXT NOT NULL,
35
+ turn_id TEXT,
36
+ hash TEXT NOT NULL,
37
+ payload TEXT NOT NULL,
38
+ PRIMARY KEY (session_id, sequence)
39
+ ) STRICT;
40
+ CREATE INDEX events_by_type ON events (session_id, type, sequence);
41
+ -- One row per approval request, updated in place when its decision arrives. Querying "still
42
+ -- waiting" is then an index lookup rather than a join of every request against every decision.
43
+ CREATE TABLE approvals (
44
+ session_id TEXT NOT NULL,
45
+ action_digest TEXT NOT NULL,
46
+ requested_seq INTEGER NOT NULL,
47
+ turn_id TEXT,
48
+ tool_name TEXT NOT NULL,
49
+ summary TEXT NOT NULL,
50
+ decided_seq INTEGER,
51
+ decision TEXT,
52
+ PRIMARY KEY (session_id, requested_seq)
53
+ ) STRICT;
54
+ CREATE INDEX approvals_pending ON approvals (session_id, decided_seq);
55
+ -- Searchable text, lowercased once at write time so a query is a scan of prepared values rather
56
+ -- than a per-row LOWER(). FTS5 is deliberately not used: it is a compile-time option, and a
57
+ -- projection that only works on some builds of Node is worse than one that is merely linear.
58
+ CREATE TABLE searchable (
59
+ session_id TEXT NOT NULL,
60
+ sequence INTEGER NOT NULL,
61
+ kind TEXT NOT NULL,
62
+ text TEXT NOT NULL,
63
+ PRIMARY KEY (session_id, sequence, kind)
64
+ ) STRICT;
65
+ `;
66
+ function textOf(payload) {
67
+ if (payload.type === "runtime") {
68
+ const event = payload.event;
69
+ if (event.type === "tool_call")
70
+ return [{ kind: "tool_call", text: `${event.toolName} ${JSON.stringify(event.arguments)}` }];
71
+ if (event.type === "tool_result")
72
+ return [{ kind: "tool_result", text: event.content }];
73
+ if (event.type === "runtime_stop")
74
+ return [{ kind: "summary", text: event.summary }];
75
+ return [];
76
+ }
77
+ if (payload.type === "approval_requested")
78
+ return [{ kind: "approval", text: payload.request.summary }];
79
+ return [];
80
+ }
81
+
82
+ class SessionProjection {
83
+ database;
84
+ root;
85
+ file;
86
+ constructor(database, root, file) {
87
+ this.database = database;
88
+ this.root = root;
89
+ this.file = file;
90
+ }
91
+ static async open(root, options = {}) {
92
+ const file = options.file ?? path.join(root, ".archymedes", "projection.db");
93
+ await fs.mkdir(path.dirname(file), { recursive: true });
94
+ const { DatabaseSync } = await loadSqlite();
95
+ const build = () => {
96
+ const database2 = new DatabaseSync(file);
97
+ database2.exec(SCHEMA);
98
+ database2.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("schemaVersion", String(PROJECTION_SCHEMA_VERSION));
99
+ return database2;
100
+ };
101
+ let database;
102
+ try {
103
+ database = new DatabaseSync(file);
104
+ const row = database.prepare("SELECT value FROM meta WHERE key = 'schemaVersion'").get();
105
+ if (row?.value !== String(PROJECTION_SCHEMA_VERSION)) {
106
+ database.close();
107
+ database = undefined;
108
+ await fs.rm(file, { force: true });
109
+ database = build();
110
+ }
111
+ } catch {
112
+ try {
113
+ database?.close();
114
+ } catch {}
115
+ await fs.rm(file, { force: true });
116
+ database = build();
117
+ }
118
+ return new SessionProjection(database, root, file);
119
+ }
120
+ close() {
121
+ this.database.close();
122
+ }
123
+ async rebuild(sessionId) {
124
+ const events = await readEventJournal(this.root, sessionId);
125
+ this.database.exec("BEGIN");
126
+ try {
127
+ for (const table of ["events", "approvals", "searchable"]) {
128
+ this.database.prepare(`DELETE FROM ${table} WHERE session_id = ?`).run(sessionId);
129
+ }
130
+ const insertEvent = this.database.prepare("INSERT INTO events (session_id, sequence, timestamp, type, turn_id, hash, payload) VALUES (?, ?, ?, ?, ?, ?, ?)");
131
+ const insertApproval = this.database.prepare("INSERT INTO approvals (session_id, action_digest, requested_seq, turn_id, tool_name, summary) VALUES (?, ?, ?, ?, ?, ?)");
132
+ const decideApproval = this.database.prepare("UPDATE approvals SET decided_seq = ?, decision = ? WHERE session_id = ? AND action_digest = ? AND decided_seq IS NULL");
133
+ const insertText = this.database.prepare("INSERT INTO searchable (session_id, sequence, kind, text) VALUES (?, ?, ?, ?)");
134
+ for (const event of events) {
135
+ const payload = event.payload;
136
+ const turnId = "turnId" in payload ? payload.turnId : null;
137
+ insertEvent.run(sessionId, event.sequence, event.timestamp, payload.type, turnId, event.hash, JSON.stringify(payload));
138
+ if (payload.type === "approval_requested") {
139
+ insertApproval.run(sessionId, payload.request.actionDigest, event.sequence, turnId, payload.request.toolName, payload.request.summary);
140
+ }
141
+ if (payload.type === "approval_decided") {
142
+ decideApproval.run(event.sequence, payload.decision, sessionId, payload.actionDigest);
143
+ }
144
+ for (const entry of textOf(payload))
145
+ insertText.run(sessionId, event.sequence, entry.kind, entry.text.toLowerCase());
146
+ }
147
+ this.database.exec("COMMIT");
148
+ return { events: events.length };
149
+ } catch (error) {
150
+ this.database.exec("ROLLBACK");
151
+ throw error;
152
+ }
153
+ }
154
+ async rebuildAll() {
155
+ const directory = path.dirname(eventJournalPath(this.root, "placeholder"));
156
+ const files = await fs.readdir(directory).catch(() => []);
157
+ let events = 0;
158
+ let sessions = 0;
159
+ for (const file of files) {
160
+ if (!file.endsWith(".jsonl"))
161
+ continue;
162
+ const result = await this.rebuild(file.slice(0, -6));
163
+ events += result.events;
164
+ sessions += 1;
165
+ }
166
+ return { sessions, events };
167
+ }
168
+ eventsAfter(sessionId, cursor, limit = 500) {
169
+ const rows = this.database.prepare("SELECT session_id, sequence, timestamp, type, turn_id, hash, payload FROM events WHERE session_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(sessionId, cursor, limit);
170
+ return rows.map((row) => ({
171
+ sessionId: row.session_id,
172
+ sequence: row.sequence,
173
+ timestamp: row.timestamp,
174
+ type: row.type,
175
+ turnId: row.turn_id ?? null,
176
+ hash: row.hash,
177
+ payload: JSON.parse(row.payload)
178
+ }));
179
+ }
180
+ cursor(sessionId) {
181
+ const row = this.database.prepare("SELECT MAX(sequence) AS last FROM events WHERE session_id = ?").get(sessionId);
182
+ return row?.last ?? 0;
183
+ }
184
+ pendingApprovals(sessionId) {
185
+ const rows = sessionId ? this.database.prepare("SELECT * FROM approvals WHERE decided_seq IS NULL AND session_id = ? ORDER BY requested_seq").all(sessionId) : this.database.prepare("SELECT * FROM approvals WHERE decided_seq IS NULL ORDER BY session_id, requested_seq").all();
186
+ return rows.map((row) => ({
187
+ sessionId: row.session_id,
188
+ actionDigest: row.action_digest,
189
+ requestedSeq: row.requested_seq,
190
+ turnId: row.turn_id ?? null,
191
+ toolName: row.tool_name,
192
+ summary: row.summary
193
+ }));
194
+ }
195
+ search(query, options = {}) {
196
+ const needle = query.trim().toLowerCase();
197
+ if (!needle)
198
+ return [];
199
+ const pattern = `%${needle.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
200
+ const limit = options.limit ?? 50;
201
+ const rows = options.sessionId ? this.database.prepare("SELECT * FROM searchable WHERE text LIKE ? ESCAPE '\\' AND session_id = ? ORDER BY sequence LIMIT ?").all(pattern, options.sessionId, limit) : this.database.prepare("SELECT * FROM searchable WHERE text LIKE ? ESCAPE '\\' ORDER BY session_id, sequence LIMIT ?").all(pattern, limit);
202
+ return rows.map((row) => ({ sessionId: row.session_id, sequence: row.sequence, kind: row.kind, text: row.text }));
203
+ }
204
+ sessions() {
205
+ const rows = this.database.prepare("SELECT session_id, COUNT(*) AS events, MIN(timestamp) AS first_at, MAX(timestamp) AS last_at, MAX(sequence) AS last_sequence FROM events GROUP BY session_id ORDER BY last_at DESC").all();
206
+ return rows.map((row) => ({
207
+ sessionId: row.session_id,
208
+ events: row.events,
209
+ firstAt: row.first_at,
210
+ lastAt: row.last_at,
211
+ lastSequence: row.last_sequence
212
+ }));
213
+ }
214
+ fork(sessionId, newSessionId, throughSequence) {
215
+ if (newSessionId === sessionId)
216
+ throw new Error("A fork needs a different session id");
217
+ this.database.exec("BEGIN");
218
+ try {
219
+ for (const table of ["events", "approvals", "searchable"]) {
220
+ this.database.prepare(`DELETE FROM ${table} WHERE session_id = ?`).run(newSessionId);
221
+ }
222
+ this.database.prepare("INSERT INTO events (session_id, sequence, timestamp, type, turn_id, hash, payload) SELECT ?, sequence, timestamp, type, turn_id, hash, payload FROM events WHERE session_id = ? AND sequence <= ?").run(newSessionId, sessionId, throughSequence);
223
+ this.database.prepare("INSERT INTO approvals (session_id, action_digest, requested_seq, turn_id, tool_name, summary, decided_seq, decision) SELECT ?, action_digest, requested_seq, turn_id, tool_name, summary, decided_seq, decision FROM approvals WHERE session_id = ? AND requested_seq <= ?").run(newSessionId, sessionId, throughSequence);
224
+ this.database.prepare("INSERT INTO searchable (session_id, sequence, kind, text) SELECT ?, sequence, kind, text FROM searchable WHERE session_id = ? AND sequence <= ?").run(newSessionId, sessionId, throughSequence);
225
+ this.database.exec("COMMIT");
226
+ return { events: this.cursor(newSessionId) === 0 ? 0 : this.eventsAfter(newSessionId, 0, Number.MAX_SAFE_INTEGER).length };
227
+ } catch (error) {
228
+ this.database.exec("ROLLBACK");
229
+ throw error;
230
+ }
231
+ }
232
+ archive(sessionId) {
233
+ this.database.exec("BEGIN");
234
+ try {
235
+ for (const table of ["events", "approvals", "searchable"]) {
236
+ this.database.prepare(`DELETE FROM ${table} WHERE session_id = ?`).run(sessionId);
237
+ }
238
+ this.database.exec("COMMIT");
239
+ } catch (error) {
240
+ this.database.exec("ROLLBACK");
241
+ throw error;
242
+ }
243
+ }
244
+ }
245
+ export {
246
+ SessionProjection,
247
+ PROJECTION_SCHEMA_VERSION
248
+ };