@alisio/core 0.1.0-alpha.17 → 0.1.0-alpha.19

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,154 @@
1
+ /**
2
+ * Unified line diffs for the `diff` UiBlock that `write_file`/`edit_file` attach for rich
3
+ * clients (spec §8.6, RF-13). Myers O((N+M)·D) on lines after trimming the common prefix and
4
+ * suffix; when the edit distance exceeds `maxD` the middle becomes a full replacement. Pure and
5
+ * portable (no dependency). The web UI ships its own copy of the algorithm for third-party blocks
6
+ * that only carry `before`/`after` (packages cannot share code with the browser bundle).
7
+ */
8
+ function myers(a, b, maxD) {
9
+ const n = a.length;
10
+ const m = b.length;
11
+ if (!n)
12
+ return b.map(() => 2);
13
+ if (!m)
14
+ return a.map(() => 1);
15
+ const max = n + m;
16
+ const offset = max + 1;
17
+ const v = new Int32Array(2 * max + 3);
18
+ const trace = [];
19
+ for (let d = 0; d <= Math.min(max, maxD); d++) {
20
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
21
+ for (let k = -d; k <= d; k += 2) {
22
+ let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])
23
+ ? v[offset + k + 1]
24
+ : v[offset + k - 1] + 1;
25
+ let y = x - k;
26
+ while (x < n && y < m && a[x] === b[y]) {
27
+ x++;
28
+ y++;
29
+ }
30
+ v[offset + k] = x;
31
+ if (x >= n && y >= m)
32
+ return backtrack(trace, n, m);
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+ function backtrack(trace, n, m) {
38
+ const ops = [];
39
+ let x = n;
40
+ let y = m;
41
+ for (let d = trace.length - 1; d > 0; d--) {
42
+ const snap = trace[d];
43
+ const at = (k) => snap[k + d + 1];
44
+ const k = x - y;
45
+ const prevK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;
46
+ const prevX = at(prevK);
47
+ const prevY = prevX - prevK;
48
+ while (x > prevX && y > prevY) {
49
+ ops.push(0);
50
+ x--;
51
+ y--;
52
+ }
53
+ ops.push(x === prevX ? 2 : 1);
54
+ x = prevX;
55
+ y = prevY;
56
+ }
57
+ while (x > 0 && y > 0) {
58
+ ops.push(0);
59
+ x--;
60
+ y--;
61
+ }
62
+ return ops.reverse();
63
+ }
64
+ /** Line edit script (0 equal, 1 delete, 2 insert). */
65
+ export function lineEdits(a, b, maxD = 2000) {
66
+ let start = 0;
67
+ while (start < a.length && start < b.length && a[start] === b[start])
68
+ start++;
69
+ let endA = a.length;
70
+ let endB = b.length;
71
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
72
+ endA--;
73
+ endB--;
74
+ }
75
+ const midA = a.slice(start, endA);
76
+ const midB = b.slice(start, endB);
77
+ const middle = myers(midA, midB, maxD) ?? [...midA.map(() => 1), ...midB.map(() => 2)];
78
+ return [...new Array(start).fill(0), ...middle, ...new Array(a.length - endA).fill(0)];
79
+ }
80
+ const NO_EOL = "\u0000no-eol";
81
+ /** Lines of a text; a last line without `\n` carries a marker so a newline change is a change. */
82
+ function linesOf(text) {
83
+ if (!text)
84
+ return [];
85
+ const lines = text.split("\n");
86
+ if (lines.at(-1) === "")
87
+ lines.pop();
88
+ else
89
+ lines[lines.length - 1] = `${lines.at(-1)}${NO_EOL}`;
90
+ return lines;
91
+ }
92
+ /**
93
+ * A unified patch (`--- a/path`, `+++ b/path`, hunks with `context` lines) from `before` to
94
+ * `after`; `undefined` means the file did not exist. Identical texts give `""`.
95
+ */
96
+ export function unifiedPatch(path, before, after, options = {}) {
97
+ const context = options.context ?? 3;
98
+ const a = linesOf(before);
99
+ const b = linesOf(after);
100
+ const rows = [];
101
+ let i = 0;
102
+ let j = 0;
103
+ for (const op of lineEdits(a, b)) {
104
+ if (op === 0)
105
+ rows.push({ op, text: a[i], old: i++, new: j++ });
106
+ else if (op === 1)
107
+ rows.push({ op, text: a[i], old: i++, new: j });
108
+ else
109
+ rows.push({ op, text: b[j], old: i, new: j++ });
110
+ }
111
+ const hunks = [];
112
+ for (let k = 0; k < rows.length; k++) {
113
+ if (rows[k].op === 0)
114
+ continue;
115
+ const from = Math.max(0, k - context);
116
+ const last = hunks.at(-1);
117
+ if (last && from <= last[1] + 1)
118
+ last[1] = Math.min(rows.length - 1, k + context);
119
+ else
120
+ hunks.push([from, Math.min(rows.length - 1, k + context)]);
121
+ }
122
+ if (!hunks.length)
123
+ return "";
124
+ const out = [
125
+ `--- ${before === undefined ? "/dev/null" : `a/${path}`}`,
126
+ `+++ ${after === undefined ? "/dev/null" : `b/${path}`}`,
127
+ ];
128
+ const range = (start, count) => count === 1 ? `${start + 1}` : `${count === 0 ? start : start + 1},${count}`;
129
+ for (const [from, to] of hunks) {
130
+ const slice = rows.slice(from, to + 1);
131
+ const first = slice[0];
132
+ const oldCount = slice.filter((r) => r.op !== 2).length;
133
+ const newCount = slice.filter((r) => r.op !== 1).length;
134
+ out.push(`@@ -${range(first.old, oldCount)} +${range(first.new, newCount)} @@`);
135
+ for (const row of slice) {
136
+ const noEol = row.text.endsWith(NO_EOL);
137
+ const text = noEol ? row.text.slice(0, -NO_EOL.length) : row.text;
138
+ out.push(`${row.op === 0 ? " " : row.op === 1 ? "-" : "+"}${text}`);
139
+ if (noEol)
140
+ out.push("\");
141
+ }
142
+ }
143
+ return out.join("\n");
144
+ }
145
+ /** Cuts `text` at a line boundary so it fits in `maxBytes` (UTF-8). */
146
+ export function clipLines(text, maxBytes) {
147
+ if (Buffer.byteLength(text) <= maxBytes)
148
+ return { text, clipped: false };
149
+ let cut = Buffer.from(text).subarray(0, maxBytes).toString("utf8");
150
+ // A split multi-byte character decodes to U+FFFD; drop the partial last line either way.
151
+ const newline = cut.lastIndexOf("\n");
152
+ cut = newline > 0 ? cut.slice(0, newline) : cut.replace(/�$/, "");
153
+ return { text: cut, clipped: true };
154
+ }
@@ -1,5 +1,5 @@
1
1
  import { type Message, type SqlDatabase, type ToolCall, type ToolResult } from "@alisio/sdk";
2
- import type { Session, SessionStore } from "../core/contracts.ts";
2
+ import type { BeginRunInput, EndRunInput, EventPage, MessagePage, PageOptions, RunRecord, Session, SessionStore, ToolCallMeta } from "../core/contracts.ts";
3
3
  export declare class SQLiteStore implements SessionStore {
4
4
  readonly db: SqlDatabase;
5
5
  constructor(path: string);
@@ -23,17 +23,89 @@ export declare class SQLiteStore implements SessionStore {
23
23
  interruptStale(): number;
24
24
  messages(id: string): Message[];
25
25
  append(id: string, message: Message): void;
26
+ private touch;
26
27
  setModel(id: string, model: string): void;
27
28
  compact(id: string, replaced: number, summary: Message): void;
28
29
  overwrite(id: string, messages: Message[]): void;
29
30
  acquire(id: string): void;
30
31
  release(id: string): void;
31
- beginCall(id: string, call: ToolCall): void;
32
+ beginCall(id: string, call: ToolCall, meta?: ToolCallMeta): void;
32
33
  endCall(id: string, call: ToolCall, result: ToolResult): void;
34
+ /**
35
+ * Calls of a session with one effect (for example `write`), oldest first. Needs the v4
36
+ * `effect`/`run_id`/`started_at` columns; rows written by older versions have no effect.
37
+ */
38
+ effectCalls(session: string, effect: string): Array<{
39
+ callId: string;
40
+ name: string;
41
+ runId?: string;
42
+ startedAt?: number;
43
+ }>;
33
44
  /** Rich (ui/image) parts persist as part of the serialized result and replay verbatim. */
34
45
  callResult(id: string, callId: string): ToolResult | undefined;
35
46
  reconcile(id: string, acknowledge?: boolean): void;
36
- event(id: string, runId: string, type: string, data: unknown): void;
47
+ /** Persists a durable event and returns its global `events.seq`. */
48
+ event(id: string, runId: string, type: string, data: unknown, meta?: {
49
+ correlationId?: string;
50
+ }): number;
51
+ private runRow;
52
+ private runById;
53
+ beginRun(input: BeginRunInput): {
54
+ run: RunRecord;
55
+ created: boolean;
56
+ };
57
+ endRun(id: string, input: EndRunInput): void;
58
+ runByRequest(session: string, requestId: string): RunRecord | undefined;
59
+ runs(session: string, options?: {
60
+ limit?: number;
61
+ }): RunRecord[];
62
+ interruptRuns(): number;
63
+ /**
64
+ * A page of messages by `seq`. With `after`, the first `limit` rows after it; otherwise the
65
+ * newest `limit` rows (before `before` when given). Items are always in ascending order.
66
+ */
67
+ messagesPage(session: string, options?: PageOptions & {
68
+ compacted?: boolean;
69
+ }): MessagePage;
70
+ /** Durable events of a session after `after` (a previous `eventId`), ascending. */
71
+ eventsPage(session: string, options?: PageOptions): EventPage;
72
+ /**
73
+ * Known workspaces for web clients: the workspaces of root sessions plus the ones recorded with
74
+ * `recordWorkspace` (v4 `workspaces` table), with their UI metadata (v5: `archivedAt`) and
75
+ * root-session count.
76
+ */
77
+ workspaces(): Array<{
78
+ path: string;
79
+ label?: string;
80
+ pinned: boolean;
81
+ lastOpenedAt?: number;
82
+ archivedAt?: number;
83
+ sessions: number;
84
+ }>;
85
+ /**
86
+ * Records (upserts) a workspace and patches its UI metadata (`label: null` clears the label;
87
+ * `archived` stamps or clears `archived_at` and never touches its sessions).
88
+ */
89
+ recordWorkspace(path: string, patch?: {
90
+ label?: string | null;
91
+ pinned?: boolean;
92
+ lastOpenedAt?: number;
93
+ archived?: boolean;
94
+ }): void;
95
+ /**
96
+ * Patches web-facing session metadata: title, pin, archive flag and `options` (shallow-merged
97
+ * into the stored options object). Does not touch the run-relevant columns.
98
+ */
99
+ updateSessionMeta(id: string, patch: {
100
+ title?: string | null;
101
+ pinned?: boolean;
102
+ archived?: boolean;
103
+ options?: Record<string, unknown>;
104
+ }): void;
105
+ /** `MAX(events.seq)` of a session (0 without events): the web snapshot cursor. */
106
+ lastEventId(session: string): number;
107
+ /** The pid of another live process holding the session lock, if any. */
108
+ lockedBy(id: string): number | undefined;
37
109
  getState(plugin: string, key: string): unknown;
38
110
  setState(plugin: string, key: string, value: unknown): void;
39
111
  close(): void;
@@ -1,5 +1,16 @@
1
1
  import { textResult, } from "@alisio/sdk";
2
2
  import { openDatabase } from "./sqlite.js";
3
+ /** Whether a process id belongs to a live process (EPERM still means alive). */
4
+ function alive(pid) {
5
+ try {
6
+ process.kill(pid, 0);
7
+ return true;
8
+ }
9
+ catch (e) {
10
+ return e.code !== "ESRCH";
11
+ }
12
+ }
13
+ const TERMINAL_RUN = "('completed','turns_exceeded','failed','cancelled','interrupted')";
3
14
  export class SQLiteStore {
4
15
  db;
5
16
  constructor(path) {
@@ -45,12 +56,69 @@ export class SQLiteStore {
45
56
  this.db.exec("CREATE INDEX IF NOT EXISTS sessions_parent ON sessions(parent_id)");
46
57
  this.db.exec("INSERT OR IGNORE INTO schema_migrations VALUES(3)");
47
58
  });
59
+ if (!has(4))
60
+ this.db.transaction(() => {
61
+ // v4 (additive): run journal, workspace UI metadata, blob index and nullable columns.
62
+ this.db.exec(`CREATE TABLE IF NOT EXISTS runs(
63
+ id TEXT PRIMARY KEY,
64
+ session TEXT NOT NULL REFERENCES sessions(id),
65
+ status TEXT NOT NULL,
66
+ request_id TEXT,
67
+ correlation_id TEXT,
68
+ owner_pid INTEGER,
69
+ model TEXT,
70
+ created_at INTEGER NOT NULL,
71
+ started_at INTEGER,
72
+ ended_at INTEGER,
73
+ error TEXT,
74
+ usage TEXT);
75
+ CREATE UNIQUE INDEX IF NOT EXISTS runs_request ON runs(session, request_id) WHERE request_id IS NOT NULL;
76
+ CREATE INDEX IF NOT EXISTS runs_session ON runs(session, created_at);
77
+ CREATE TABLE IF NOT EXISTS workspaces(
78
+ path TEXT PRIMARY KEY,
79
+ label TEXT,
80
+ pinned INTEGER NOT NULL DEFAULT 0,
81
+ last_opened_at INTEGER);
82
+ CREATE TABLE IF NOT EXISTS blobs(
83
+ hash TEXT PRIMARY KEY,
84
+ mime TEXT NOT NULL,
85
+ size INTEGER NOT NULL,
86
+ width INTEGER,
87
+ height INTEGER,
88
+ created_at INTEGER NOT NULL);`);
89
+ const add = (table, name, type) => {
90
+ const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
91
+ if (!columns.some((c) => c.name === name))
92
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`);
93
+ };
94
+ add("sessions", "pinned", "INTEGER NOT NULL DEFAULT 0");
95
+ add("sessions", "archived_at", "INTEGER");
96
+ add("events", "created_at", "INTEGER");
97
+ add("events", "correlation_id", "TEXT");
98
+ add("tool_calls", "run_id", "TEXT");
99
+ add("tool_calls", "name", "TEXT");
100
+ add("tool_calls", "effect", "TEXT");
101
+ add("tool_calls", "started_at", "INTEGER");
102
+ add("tool_calls", "ended_at", "INTEGER");
103
+ this.db.exec(`CREATE INDEX IF NOT EXISTS events_session ON events(session, seq);
104
+ CREATE INDEX IF NOT EXISTS messages_session ON messages(session, seq);
105
+ INSERT OR IGNORE INTO schema_migrations VALUES(4);`);
106
+ });
107
+ if (!has(5))
108
+ this.db.transaction(() => {
109
+ // v5 (additive): archived workspaces (hidden from the default web list).
110
+ const columns = this.db.prepare("PRAGMA table_info(workspaces)").all();
111
+ if (!columns.some((c) => c.name === "archived_at"))
112
+ this.db.exec("ALTER TABLE workspaces ADD COLUMN archived_at INTEGER");
113
+ this.db.exec("INSERT OR IGNORE INTO schema_migrations VALUES(5)");
114
+ });
48
115
  }
49
116
  create(workspace, provider, model) {
50
117
  const id = crypto.randomUUID();
118
+ const now = Date.now();
51
119
  this.db
52
- .prepare("INSERT INTO sessions(id,workspace,provider,model) VALUES(?,?,?,?)")
53
- .run(id, workspace, provider, model);
120
+ .prepare("INSERT INTO sessions(id,workspace,provider,model,created_at,updated_at) VALUES(?,?,?,?,?,?)")
121
+ .run(id, workspace, provider, model, now, now);
54
122
  return { id, workspace, provider, model };
55
123
  }
56
124
  row(r) {
@@ -68,6 +136,8 @@ export class SQLiteStore {
68
136
  ...(r.options ? { options: JSON.parse(String(r.options)) } : {}),
69
137
  ...(r.created_at ? { createdAt: Number(r.created_at) } : {}),
70
138
  ...(r.updated_at ? { updatedAt: Number(r.updated_at) } : {}),
139
+ ...(r.pinned ? { pinned: true } : {}),
140
+ ...(r.archived_at ? { archivedAt: Number(r.archived_at) } : {}),
71
141
  };
72
142
  }
73
143
  get(id) {
@@ -147,6 +217,10 @@ export class SQLiteStore {
147
217
  this.db
148
218
  .prepare("INSERT INTO messages(session,body) VALUES(?,?)")
149
219
  .run(id, JSON.stringify(message));
220
+ this.touch(id);
221
+ }
222
+ touch(id) {
223
+ this.db.prepare("UPDATE sessions SET updated_at=? WHERE id=?").run(Date.now(), id);
150
224
  }
151
225
  setModel(id, model) {
152
226
  if (!model.trim())
@@ -201,15 +275,30 @@ export class SQLiteStore {
201
275
  .prepare("UPDATE sessions SET locked_pid=NULL WHERE id=? AND locked_pid=?")
202
276
  .run(id, process.pid);
203
277
  }
204
- beginCall(id, call) {
278
+ beginCall(id, call, meta = {}) {
205
279
  this.db
206
- .prepare("INSERT INTO tool_calls(session,call_id,status) VALUES(?,?,'pending')")
207
- .run(id, call.id);
280
+ .prepare("INSERT INTO tool_calls(session,call_id,status,run_id,name,effect,started_at) VALUES(?,?,'pending',?,?,?,?)")
281
+ .run(id, call.id, meta.runId ?? null, call.name, meta.effect ?? null, Date.now());
208
282
  }
209
283
  endCall(id, call, result) {
210
284
  this.db
211
- .prepare("INSERT INTO tool_calls(session,call_id,status,result) VALUES(?,?,'completed',?) ON CONFLICT(session,call_id) DO UPDATE SET status='completed',result=excluded.result")
212
- .run(id, call.id, JSON.stringify(result));
285
+ .prepare("INSERT INTO tool_calls(session,call_id,status,result,name,ended_at) VALUES(?,?,'completed',?,?,?) ON CONFLICT(session,call_id) DO UPDATE SET status='completed',result=excluded.result,ended_at=excluded.ended_at,name=COALESCE(tool_calls.name,excluded.name)")
286
+ .run(id, call.id, JSON.stringify(result), call.name, Date.now());
287
+ }
288
+ /**
289
+ * Calls of a session with one effect (for example `write`), oldest first. Needs the v4
290
+ * `effect`/`run_id`/`started_at` columns; rows written by older versions have no effect.
291
+ */
292
+ effectCalls(session, effect) {
293
+ const rows = this.db
294
+ .prepare("SELECT call_id,name,run_id,started_at FROM tool_calls WHERE session=? AND effect=? ORDER BY started_at, rowid")
295
+ .all(session, effect);
296
+ return rows.map((row) => ({
297
+ callId: row.call_id,
298
+ name: row.name ?? "",
299
+ ...(row.run_id ? { runId: row.run_id } : {}),
300
+ ...(row.started_at != null ? { startedAt: Number(row.started_at) } : {}),
301
+ }));
213
302
  }
214
303
  /** Rich (ui/image) parts persist as part of the serialized result and replay verbatim. */
215
304
  callResult(id, callId) {
@@ -244,10 +333,219 @@ export class SQLiteStore {
244
333
  }
245
334
  });
246
335
  }
247
- event(id, runId, type, data) {
248
- this.db
249
- .prepare("INSERT INTO events(session,run_id,type,body) VALUES(?,?,?,?)")
250
- .run(id, runId, type, JSON.stringify(data));
336
+ /** Persists a durable event and returns its global `events.seq`. */
337
+ event(id, runId, type, data, meta = {}) {
338
+ const { lastInsertRowid } = this.db
339
+ .prepare("INSERT INTO events(session,run_id,type,body,created_at,correlation_id) VALUES(?,?,?,?,?,?)")
340
+ .run(id, runId, type, JSON.stringify(data), Date.now(), meta.correlationId ?? null);
341
+ return Number(lastInsertRowid);
342
+ }
343
+ runRow(r) {
344
+ return {
345
+ id: String(r.id),
346
+ session: String(r.session),
347
+ status: r.status,
348
+ ...(r.request_id != null ? { requestId: String(r.request_id) } : {}),
349
+ ...(r.correlation_id != null ? { correlationId: String(r.correlation_id) } : {}),
350
+ ...(r.owner_pid != null ? { ownerPid: Number(r.owner_pid) } : {}),
351
+ ...(r.model != null ? { model: String(r.model) } : {}),
352
+ createdAt: Number(r.created_at),
353
+ ...(r.started_at != null ? { startedAt: Number(r.started_at) } : {}),
354
+ ...(r.ended_at != null ? { endedAt: Number(r.ended_at) } : {}),
355
+ ...(r.error != null ? { error: String(r.error) } : {}),
356
+ ...(r.usage != null ? { usage: JSON.parse(String(r.usage)) } : {}),
357
+ };
358
+ }
359
+ runById(id) {
360
+ const row = this.db.prepare("SELECT * FROM runs WHERE id=?").get(id);
361
+ return row ? this.runRow(row) : undefined;
362
+ }
363
+ beginRun(input) {
364
+ return this.db.transaction(() => {
365
+ const status = input.status ?? "running";
366
+ const now = Date.now();
367
+ const existing = this.runById(input.id);
368
+ if (existing) {
369
+ if (existing.status === "queued" && status === "running")
370
+ this.db
371
+ .prepare("UPDATE runs SET status='running', started_at=?, owner_pid=?, model=COALESCE(?,model) WHERE id=?")
372
+ .run(now, process.pid, input.model ?? null, input.id);
373
+ return { run: this.runById(input.id), created: false };
374
+ }
375
+ if (input.requestId !== undefined) {
376
+ const retried = this.runByRequest(input.session, input.requestId);
377
+ if (retried)
378
+ return { run: retried, created: false };
379
+ }
380
+ this.db
381
+ .prepare(`INSERT INTO runs(id,session,status,request_id,correlation_id,owner_pid,model,created_at,started_at)
382
+ VALUES(?,?,?,?,?,?,?,?,?)`)
383
+ .run(input.id, input.session, status, input.requestId ?? null, input.correlationId ?? null, process.pid, input.model ?? null, now, status === "running" ? now : null);
384
+ return { run: this.runById(input.id), created: true };
385
+ });
386
+ }
387
+ endRun(id, input) {
388
+ const now = Date.now();
389
+ this.db.transaction(() => {
390
+ this.db
391
+ .prepare(`UPDATE runs SET status=?, ended_at=?, error=?, usage=COALESCE(?,usage)
392
+ WHERE id=? AND status NOT IN ${TERMINAL_RUN}`)
393
+ .run(input.status, now, input.error ?? null, input.usage ? JSON.stringify(input.usage) : null, id);
394
+ const run = this.runById(id);
395
+ if (run)
396
+ this.touch(run.session);
397
+ });
398
+ }
399
+ runByRequest(session, requestId) {
400
+ const row = this.db
401
+ .prepare("SELECT * FROM runs WHERE session=? AND request_id=?")
402
+ .get(session, requestId);
403
+ return row ? this.runRow(row) : undefined;
404
+ }
405
+ runs(session, options = {}) {
406
+ return this.db
407
+ .prepare("SELECT * FROM runs WHERE session=? ORDER BY created_at DESC, rowid DESC LIMIT ?")
408
+ .all(session, options.limit ?? 100)
409
+ .map((r) => this.runRow(r));
410
+ }
411
+ interruptRuns() {
412
+ const rows = this.db
413
+ .prepare("SELECT id, owner_pid FROM runs WHERE status IN ('queued','running')")
414
+ .all();
415
+ let count = 0;
416
+ for (const row of rows) {
417
+ // Runs of this process (another Application in it) or of another live process stay.
418
+ if (row.owner_pid && (row.owner_pid === process.pid || alive(row.owner_pid)))
419
+ continue;
420
+ this.endRun(row.id, { status: "interrupted" });
421
+ count++;
422
+ }
423
+ return count;
424
+ }
425
+ /**
426
+ * A page of messages by `seq`. With `after`, the first `limit` rows after it; otherwise the
427
+ * newest `limit` rows (before `before` when given). Items are always in ascending order.
428
+ */
429
+ messagesPage(session, options = {}) {
430
+ const limit = Math.max(1, Math.min(options.limit ?? 100, 1000));
431
+ const active = options.compacted ? "" : " AND compacted=0";
432
+ const rows = (options.after !== undefined
433
+ ? this.db
434
+ .prepare(`SELECT seq,body,compacted FROM messages WHERE session=? AND seq>?${active} ORDER BY seq LIMIT ?`)
435
+ .all(session, options.after, limit + 1)
436
+ : this.db
437
+ .prepare(`SELECT seq,body,compacted FROM messages WHERE session=? AND seq<?${active} ORDER BY seq DESC LIMIT ?`)
438
+ .all(session, options.before ?? Number.MAX_SAFE_INTEGER, limit + 1));
439
+ const hasMore = rows.length > limit;
440
+ const page = rows.slice(0, limit);
441
+ if (options.after === undefined)
442
+ page.reverse();
443
+ return {
444
+ items: page.map((r) => ({
445
+ seq: Number(r.seq),
446
+ message: JSON.parse(r.body),
447
+ compacted: !!r.compacted,
448
+ })),
449
+ hasMore,
450
+ };
451
+ }
452
+ /** Durable events of a session after `after` (a previous `eventId`), ascending. */
453
+ eventsPage(session, options = {}) {
454
+ const limit = Math.max(1, Math.min(options.limit ?? 500, 5000));
455
+ const rows = this.db
456
+ .prepare("SELECT seq,run_id,type,body,created_at,correlation_id FROM events WHERE session=? AND seq>? AND seq<? ORDER BY seq LIMIT ?")
457
+ .all(session, options.after ?? 0, options.before ?? Number.MAX_SAFE_INTEGER, limit + 1);
458
+ return {
459
+ items: rows.slice(0, limit).map((r) => ({
460
+ eventId: String(r.seq),
461
+ runId: String(r.run_id),
462
+ type: String(r.type),
463
+ data: r.body == null ? undefined : JSON.parse(String(r.body)),
464
+ ...(r.created_at != null ? { createdAt: Number(r.created_at) } : {}),
465
+ ...(r.correlation_id != null ? { correlationId: String(r.correlation_id) } : {}),
466
+ })),
467
+ hasMore: rows.length > limit,
468
+ };
469
+ }
470
+ /**
471
+ * Known workspaces for web clients: the workspaces of root sessions plus the ones recorded with
472
+ * `recordWorkspace` (v4 `workspaces` table), with their UI metadata (v5: `archivedAt`) and
473
+ * root-session count.
474
+ */
475
+ workspaces() {
476
+ const rows = this.db
477
+ .prepare(`SELECT p.path, w.label, COALESCE(w.pinned, 0) AS pinned, w.last_opened_at, w.archived_at,
478
+ (SELECT count(*) FROM sessions s WHERE s.workspace=p.path AND s.parent_id IS NULL) AS sessions
479
+ FROM (SELECT workspace AS path FROM sessions WHERE parent_id IS NULL AND workspace IS NOT NULL
480
+ UNION SELECT path FROM workspaces) p
481
+ LEFT JOIN workspaces w ON w.path=p.path
482
+ ORDER BY p.path`)
483
+ .all();
484
+ return rows.map((r) => ({
485
+ path: String(r.path),
486
+ ...(r.label != null ? { label: String(r.label) } : {}),
487
+ pinned: !!r.pinned,
488
+ ...(r.last_opened_at != null ? { lastOpenedAt: Number(r.last_opened_at) } : {}),
489
+ ...(r.archived_at != null ? { archivedAt: Number(r.archived_at) } : {}),
490
+ sessions: Number(r.sessions ?? 0),
491
+ }));
492
+ }
493
+ /**
494
+ * Records (upserts) a workspace and patches its UI metadata (`label: null` clears the label;
495
+ * `archived` stamps or clears `archived_at` and never touches its sessions).
496
+ */
497
+ recordWorkspace(path, patch = {}) {
498
+ this.db.transaction(() => {
499
+ this.db.prepare("INSERT OR IGNORE INTO workspaces(path) VALUES(?)").run(path);
500
+ if (patch.label !== undefined)
501
+ this.db.prepare("UPDATE workspaces SET label=? WHERE path=?").run(patch.label, path);
502
+ if (patch.pinned !== undefined)
503
+ this.db
504
+ .prepare("UPDATE workspaces SET pinned=? WHERE path=?")
505
+ .run(patch.pinned ? 1 : 0, path);
506
+ if (patch.lastOpenedAt !== undefined)
507
+ this.db
508
+ .prepare("UPDATE workspaces SET last_opened_at=? WHERE path=?")
509
+ .run(patch.lastOpenedAt, path);
510
+ if (patch.archived !== undefined)
511
+ this.db
512
+ .prepare("UPDATE workspaces SET archived_at=? WHERE path=?")
513
+ .run(patch.archived ? Date.now() : null, path);
514
+ });
515
+ }
516
+ /**
517
+ * Patches web-facing session metadata: title, pin, archive flag and `options` (shallow-merged
518
+ * into the stored options object). Does not touch the run-relevant columns.
519
+ */
520
+ updateSessionMeta(id, patch) {
521
+ this.db.transaction(() => {
522
+ const current = this.get(id);
523
+ if (patch.title !== undefined)
524
+ this.db.prepare("UPDATE sessions SET title=? WHERE id=?").run(patch.title, id);
525
+ if (patch.pinned !== undefined)
526
+ this.db.prepare("UPDATE sessions SET pinned=? WHERE id=?").run(patch.pinned ? 1 : 0, id);
527
+ if (patch.archived !== undefined)
528
+ this.db
529
+ .prepare("UPDATE sessions SET archived_at=? WHERE id=?")
530
+ .run(patch.archived ? Date.now() : null, id);
531
+ if (patch.options)
532
+ this.db
533
+ .prepare("UPDATE sessions SET options=? WHERE id=?")
534
+ .run(JSON.stringify({ ...(current.options ?? {}), ...patch.options }), id);
535
+ });
536
+ }
537
+ /** `MAX(events.seq)` of a session (0 without events): the web snapshot cursor. */
538
+ lastEventId(session) {
539
+ const row = this.db
540
+ .prepare("SELECT MAX(seq) AS seq FROM events WHERE session=?")
541
+ .get(session);
542
+ return Number(row?.seq ?? 0);
543
+ }
544
+ /** The pid of another live process holding the session lock, if any. */
545
+ lockedBy(id) {
546
+ const row = this.db.prepare("SELECT locked_pid FROM sessions WHERE id=?").get(id);
547
+ const pid = row?.locked_pid;
548
+ return pid && pid !== process.pid && alive(pid) ? pid : undefined;
251
549
  }
252
550
  getState(plugin, key) {
253
551
  const row = this.db
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `/btw` side questions: a question about a session answered by one separate, tool-less model
3
+ * call that sees the session's active history, without becoming part of the conversation.
4
+ *
5
+ * Guarantees: nothing is written to `messages`, `tool_calls`, `runs`, the durable `events` or the
6
+ * session usage, and the session lock is never claimed, so a side question may run while a run
7
+ * is active (it has its own abort signal). The only thing persisted is this service's own
8
+ * history, in `plugin_state` under the reserved namespace `core:btw` (plugin ids cannot contain
9
+ * `:`, so no plugin can read or overwrite it), keyed by session id and capped to the newest
10
+ * `SIDE_QUESTION_HISTORY_LIMIT` entries.
11
+ */
12
+ import type { Message, SideQuestionEntry } from "@alisio/sdk";
13
+ import type { AgentRunner } from "../core/runner.ts";
14
+ export type { SideQuestionEntry };
15
+ /** `plugin_state.plugin` value of the side-question history (never a valid plugin id). */
16
+ export declare const SIDE_QUESTIONS_NAMESPACE = "core:btw";
17
+ export declare const SIDE_QUESTION_HISTORY_LIMIT = 20;
18
+ export declare const SIDE_QUESTION_MAX_CHARS = 4000;
19
+ /** The line every surface prints for `/btw` without a question and without any history. */
20
+ export declare const SIDE_QUESTION_USAGE = "Usage: /btw <question>";
21
+ export declare const SIDE_QUESTION_DESCRIPTION = "Ask a side question about the current session without adding to the conversation.";
22
+ export declare const SIDE_QUESTION_INSTRUCTIONS: string;
23
+ interface StateStore {
24
+ getState(plugin: string, key: string): unknown;
25
+ setState(plugin: string, key: string, value: unknown): void;
26
+ }
27
+ export interface SideQuestionsOptions {
28
+ runner: Pick<AgentRunner, "sideContext">;
29
+ /** Where the history lives (the SQLite store's `plugin_state`). */
30
+ state: StateStore;
31
+ now?: () => number;
32
+ id?: () => string;
33
+ }
34
+ /**
35
+ * The transcript lines for the side call, newest kept first: when the whole history does not fit
36
+ * `budget` characters, the oldest messages are dropped (and counted in a leading marker). The
37
+ * newest message alone is clipped from its start if it is still too large.
38
+ */
39
+ export declare function sideTranscript(messages: Message[], budget: number): string;
40
+ export declare class SideQuestions {
41
+ private readonly options;
42
+ private readonly now;
43
+ private readonly newId;
44
+ constructor(options: SideQuestionsOptions);
45
+ /** The session's side questions, oldest first (newest last). */
46
+ history(sessionId: string): SideQuestionEntry[];
47
+ /**
48
+ * Answers `question` about the session with one tool-less call of the session's model and
49
+ * records it in the history. Throws on an empty or oversized question, on cancellation (the
50
+ * signal's reason) and on provider failures (nothing is recorded then).
51
+ */
52
+ ask(sessionId: string, question: string, options?: {
53
+ signal?: AbortSignal;
54
+ }): Promise<SideQuestionEntry>;
55
+ }
56
+ /** Plain Markdown for one entry (`position` is 1-based): readline, API callers and `/btw`. */
57
+ export declare function formatSideQuestion(entry: SideQuestionEntry, position: number, total: number): string;