@oar-sh/oar 0.9.11 → 0.9.12

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 (63) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
@@ -12,16 +12,59 @@
12
12
  * post). `reported` remembers which workers this runtime has heard from
13
13
  * about their tasks at all: until then "no set" means "not told yet", not
14
14
  * "no tasks".
15
+ *
16
+ * Ended tasks: a task that leaves the set while it names an `outputPath` (a
17
+ * shell or Monitor task with a console) is kept as an ended row for an hour,
18
+ * at most 20 per key, so the panel can still show how it ended and open its
19
+ * output. A settle post (`settle`) says how; one that arrives before the set
20
+ * drops the task is kept until it does. Memory only, like the sets.
21
+ *
22
+ * Health: the health poller's last reading per live task (`setHealth`), served
23
+ * with the task rows (`withHealth`) and carried onto the ended row, where the
24
+ * poller's reading after the end replaces it (`setEndedHealth`).
15
25
  */
16
26
 
17
27
  import { backgroundTaskDigest, sameBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
28
+ import { TASK_OUTPUT_LIMITS } from '../../shared/task-output-text.mjs';
18
29
 
19
30
  function keyOf(value) {
20
31
  return String(value || '').trim();
21
32
  }
22
33
 
23
- export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
34
+ const ENDED_STATUSES = new Set(['completed', 'failed', 'stopped', 'unknown']);
35
+
36
+ /** A worker's word for how a task ended, as one of completed, failed, stopped, unknown. */
37
+ export function normalizeEndedTaskStatus(status, exitCode = null) {
38
+ const text = String(status || '').trim().toLowerCase();
39
+ if (ENDED_STATUSES.has(text)) return text;
40
+ if (['complete', 'done', 'success', 'succeeded', 'ok', 'exited'].includes(text)) {
41
+ return Number.isInteger(exitCode) && exitCode !== 0 ? 'failed' : 'completed';
42
+ }
43
+ if (['error', 'errored', 'failure'].includes(text)) return 'failed';
44
+ if (['killed', 'cancelled', 'canceled', 'aborted', 'interrupted', 'timeout', 'timed_out'].includes(text)) return 'stopped';
45
+ if (Number.isInteger(exitCode)) return exitCode === 0 ? 'completed' : 'failed';
46
+ return 'unknown';
47
+ }
48
+
49
+ function exitCodeOf(value) {
50
+ if (value === null || value === undefined || value === '') return null;
51
+ const number = Number(value);
52
+ return Number.isInteger(number) ? number : null;
53
+ }
54
+
55
+ export function createBackgroundTaskStore({
56
+ now = () => Date.now(),
57
+ endedKeepMs = TASK_OUTPUT_LIMITS.endedKeepMs,
58
+ endedMax = TASK_OUTPUT_LIMITS.endedMax,
59
+ } = {}) {
24
60
  const sets = new Map();
61
+ // key → Map(taskId → ended row), newest last.
62
+ const endedRows = new Map();
63
+ // key → Map(taskId → { status, exitCode, outputPath, at }): how a task
64
+ // ended, told before the set dropped it.
65
+ const settledAhead = new Map();
66
+ // key → Map(taskId → the health poller's last reading).
67
+ const healthByKey = new Map();
25
68
  const reported = new Set();
26
69
  // key → the order of the newest set applied, `{ epoch, seq }` as the
27
70
  // worker numbered it. `epoch` names the worker process; it is compared
@@ -39,11 +82,159 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
39
82
  const id = keyOf(conversationId);
40
83
  if (!id) return;
41
84
  reported.add(id);
42
- if (!Array.isArray(tasks) || tasks.length === 0) {
85
+ const next = Array.isArray(tasks) ? tasks : [];
86
+ const nextIds = new Set(next.map((task) => keyOf(task?.taskId)).filter(Boolean));
87
+ // Tasks that left the set with a console become ended rows.
88
+ for (const task of sets.get(id)?.tasks || []) {
89
+ const taskId = keyOf(task?.taskId);
90
+ if (!taskId || nextIds.has(taskId) || !task?.outputPath) continue;
91
+ const ahead = settledAhead.get(id)?.get(taskId) || null;
92
+ addEnded(id, {
93
+ ...task,
94
+ ...(ahead?.steps !== undefined ? { steps: ahead.steps } : {}),
95
+ status: ahead?.status || 'unknown',
96
+ exitCode: ahead ? ahead.exitCode : (task.exitCode ?? null),
97
+ health: healthByKey.get(id)?.get(taskId) || task.health || undefined,
98
+ });
99
+ }
100
+ // A task back in the set (a resync after a restart) is not ended.
101
+ for (const taskId of nextIds) {
102
+ endedRows.get(id)?.delete(taskId);
103
+ settledAhead.get(id)?.delete(taskId);
104
+ }
105
+ const health = healthByKey.get(id);
106
+ if (health) {
107
+ for (const taskId of [...health.keys()]) if (!nextIds.has(taskId)) health.delete(taskId);
108
+ if (!health.size) healthByKey.delete(id);
109
+ }
110
+ if (next.length === 0) {
43
111
  sets.delete(id);
44
112
  return;
45
113
  }
46
- sets.set(id, { tasks, updatedAt: now() });
114
+ sets.set(id, { tasks: next, updatedAt: now() });
115
+ },
116
+ /**
117
+ * How a task ended (`/api/background-task-settled`): updates its ended
118
+ * row, or is kept for the moment the set drops it, or, for a task this
119
+ * runtime never saw live that names its output, becomes an ended row of
120
+ * its own. Returns the ended row when there is one.
121
+ */
122
+ settle(conversationId, taskId, { status, exitCode, outputPath, description, taskType, startedAt, outputKind, steps } = {}) {
123
+ const id = keyOf(conversationId);
124
+ const task = keyOf(taskId);
125
+ if (!id || !task) return null;
126
+ prune(id);
127
+ const code = exitCodeOf(exitCode);
128
+ const normalized = normalizeEndedTaskStatus(status, code);
129
+ const stepCount = Number.isInteger(steps) && steps >= 0 ? steps : undefined;
130
+ const existing = endedRows.get(id)?.get(task);
131
+ if (existing) {
132
+ existing.status = normalized;
133
+ existing.exitCode = code;
134
+ if (!existing.outputPath && outputPath) {
135
+ existing.outputPath = outputPath;
136
+ if (existing.outputKind !== 'file' && existing.outputKind !== 'spool') existing.outputKind = outputKind === 'spool' ? 'spool' : 'file';
137
+ }
138
+ if (stepCount !== undefined) existing.steps = stepCount;
139
+ return existing;
140
+ }
141
+ const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
142
+ if (live) {
143
+ let ahead = settledAhead.get(id);
144
+ if (!ahead) settledAhead.set(id, ahead = new Map());
145
+ ahead.set(task, { status: normalized, exitCode: code, steps: stepCount, at: now() });
146
+ // The first path a task names wins. A row that had none was read
147
+ // from a snapshot (or not at all): from now on it is the file.
148
+ if (outputPath && !live.outputPath) {
149
+ live.outputPath = outputPath;
150
+ if (live.outputKind !== 'file' && live.outputKind !== 'spool') live.outputKind = outputKind === 'spool' ? 'spool' : 'file';
151
+ }
152
+ return null;
153
+ }
154
+ if (!outputPath) return null;
155
+ return addEnded(id, {
156
+ taskId: task,
157
+ taskType: keyOf(taskType) || 'local_bash',
158
+ description: String(description || '').trim().slice(0, 500),
159
+ startedAt: Number(startedAt) || null,
160
+ outputPath,
161
+ outputKind: outputKind || 'file',
162
+ stoppable: false,
163
+ status: normalized,
164
+ exitCode: code,
165
+ ...(stepCount !== undefined ? { steps: stepCount } : {}),
166
+ });
167
+ },
168
+ /** The ended rows of these keys, newest first. */
169
+ ended(...keys) {
170
+ const rows = [];
171
+ for (const id of new Set(keys.map(keyOf).filter(Boolean))) {
172
+ prune(id);
173
+ rows.push(...(endedRows.get(id)?.values() || []));
174
+ }
175
+ return rows.sort((a, b) => b.endedAt - a.endedAt).map((row) => ({ ...row }));
176
+ },
177
+ /** The poller's last reading for a live task, or null. */
178
+ health(conversationId, taskId) {
179
+ return healthByKey.get(keyOf(conversationId))?.get(keyOf(taskId)) || null;
180
+ },
181
+ setHealth(conversationId, taskId, value) {
182
+ const id = keyOf(conversationId);
183
+ const task = keyOf(taskId);
184
+ if (!id || !task) return;
185
+ let health = healthByKey.get(id);
186
+ if (!value) {
187
+ health?.delete(task);
188
+ return;
189
+ }
190
+ if (!health) healthByKey.set(id, health = new Map());
191
+ health.set(task, value);
192
+ },
193
+ /** The keys that hold ended rows. */
194
+ endedKeys() {
195
+ return [...endedRows.keys()];
196
+ },
197
+ /**
198
+ * The poller's reading of an ended task's file, taken after the end: the
199
+ * last output (a test runner's summary) often lands between two readings
200
+ * of the live task. Returns false when there is no such row.
201
+ */
202
+ setEndedHealth(conversationId, taskId, value) {
203
+ const row = endedRows.get(keyOf(conversationId))?.get(keyOf(taskId));
204
+ if (!row || !value) return false;
205
+ row.health = value;
206
+ return true;
207
+ },
208
+ /** Task rows with the poller's last reading on each as `health`. */
209
+ withHealth(tasks, ...keys) {
210
+ const ids = keys.map(keyOf).filter(Boolean);
211
+ return (Array.isArray(tasks) ? tasks : []).map((task) => {
212
+ const taskId = keyOf(task?.taskId);
213
+ const health = ids.map((id) => healthByKey.get(id)?.get(taskId)).find(Boolean);
214
+ return health ? { ...task, health } : task;
215
+ });
216
+ },
217
+ /**
218
+ * A task of these keys, live or ended: `{ key, task, ended }` or null.
219
+ * The live set wins over an ended row.
220
+ */
221
+ findTask(keys, taskId) {
222
+ const task = keyOf(taskId);
223
+ const ids = [...new Set((Array.isArray(keys) ? keys : [keys]).map(keyOf).filter(Boolean))];
224
+ if (!task) return null;
225
+ for (const id of ids) {
226
+ const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
227
+ if (live) {
228
+ const health = healthByKey.get(id)?.get(task);
229
+ return { key: id, task: health ? { ...live, health } : live, ended: false };
230
+ }
231
+ }
232
+ for (const id of ids) {
233
+ prune(id);
234
+ const row = endedRows.get(id)?.get(task);
235
+ if (row) return { key: id, task: { ...row }, ended: true };
236
+ }
237
+ return null;
47
238
  },
48
239
  get(conversationId) {
49
240
  return sets.get(keyOf(conversationId))?.tasks || [];
@@ -115,6 +306,14 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
115
306
  return true;
116
307
  },
117
308
  /** The conversation is gone: everything held for these keys goes. */
309
+ /**
310
+ * Drops expired ended rows and pending settles of every key, also of
311
+ * conversations nobody reads again (the per-key prune runs on reads).
312
+ * Called from the health poller's tick.
313
+ */
314
+ pruneAll() {
315
+ for (const id of new Set([...endedRows.keys(), ...settledAhead.keys()])) prune(id);
316
+ },
118
317
  forget(...keys) {
119
318
  for (const id of keys.map(keyOf).filter(Boolean)) {
120
319
  sets.delete(id);
@@ -122,7 +321,45 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
122
321
  applied.delete(id);
123
322
  holders.delete(id);
124
323
  announced.delete(id);
324
+ endedRows.delete(id);
325
+ settledAhead.delete(id);
326
+ healthByKey.delete(id);
125
327
  }
126
328
  },
127
329
  };
330
+
331
+ function prune(id) {
332
+ const cutoff = now() - endedKeepMs;
333
+ const rows = endedRows.get(id);
334
+ if (rows) {
335
+ for (const [taskId, row] of rows) if (row.endedAt < cutoff) rows.delete(taskId);
336
+ if (!rows.size) endedRows.delete(id);
337
+ }
338
+ const ahead = settledAhead.get(id);
339
+ if (ahead) {
340
+ for (const [taskId, entry] of ahead) if (entry.at < cutoff) ahead.delete(taskId);
341
+ if (!ahead.size) settledAhead.delete(id);
342
+ }
343
+ }
344
+
345
+ function addEnded(id, task) {
346
+ let rows = endedRows.get(id);
347
+ if (!rows) endedRows.set(id, rows = new Map());
348
+ const taskId = keyOf(task.taskId);
349
+ rows.delete(taskId);
350
+ const row = {
351
+ ...task,
352
+ taskId,
353
+ status: normalizeEndedTaskStatus(task.status, exitCodeOf(task.exitCode)),
354
+ exitCode: exitCodeOf(task.exitCode),
355
+ endedAt: now(),
356
+ stoppable: false,
357
+ };
358
+ if (!row.health) delete row.health;
359
+ rows.set(taskId, row);
360
+ settledAhead.get(id)?.delete(taskId);
361
+ while (rows.size > endedMax) rows.delete(rows.keys().next().value);
362
+ prune(id);
363
+ return row;
364
+ }
128
365
  }
@@ -37,10 +37,6 @@ const OTHER_ESCAPE_PATTERN = /\x1b[@-Z\\-_]/g;
37
37
  // differently-sized terminal this degrades to the pre-wrap behaviour.
38
38
  const PTY_WRAP_COLUMNS = 80;
39
39
 
40
- function normalizeText(value) {
41
- return String(value == null ? '' : value).trim();
42
- }
43
-
44
40
  /**
45
41
  * Removes OSC sequences (including OSC-8 hyperlink wrappers), ANSI CSI
46
42
  * sequences and the CR the PTY adds to every line, leaving the human-visible
@@ -74,23 +70,10 @@ export function joinWrappedLines(value) {
74
70
  return joined.join('\n');
75
71
  }
76
72
 
77
- /**
78
- * Redacts anything token-shaped plus an exact secret the caller knows it
79
- * handed over (the pasted Claude login code), so an error tail can be surfaced
80
- * to the UI without leaking a credential. Long PKCE segments in an authorize
81
- * URL get caught by the generic rule too, which is fine: the URL is already
82
- * carried in the login payload.
83
- */
84
- export function scrubSecrets(text, submittedCode = '') {
85
- let output = String(text == null ? '' : text);
86
- const code = normalizeText(submittedCode);
87
- if (code.length >= 4) {
88
- output = output.split(code).join('[redacted]');
89
- }
90
- return output
91
- .replace(/sk-ant-[A-Za-z0-9_-]+/g, '[redacted]')
92
- .replace(/[A-Za-z0-9_-]{32,}/g, '[redacted]');
93
- }
73
+ // Redacts anything token-shaped plus an exact secret the caller handed over.
74
+ // The rules live in the shared text pipeline, which also scrubs background
75
+ // task output (browser-safe, so it cannot import this module).
76
+ export { scrubSecrets } from '../../shared/task-output-text.mjs';
94
77
 
95
78
  /** Collapses captured output into one short single-line tail for an error. */
96
79
  export function tailOf(text, limit = MAX_ERROR_TAIL_CHARS) {
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ // One page of a conversation's history, selected in SQL.
4
+ //
5
+ // The history route used to load every message, hydrate every reply and only
6
+ // then cut the page out (selectConversationHistoryPage in sessions-routes.mjs),
7
+ // so each page and each live poll paid for the whole conversation. This module
8
+ // selects the page's rows in SQL with the same cursor rules and the same
9
+ // pageInfo, so only the page is hydrated.
10
+ //
11
+ // The JS order is (timestamp in ms, then id by localeCompare). SQL orders by
12
+ // (timestamp text, id text). The two agree when every timestamp has the fixed
13
+ // ISO shape the relay writes (YYYY-MM-DDTHH:MM:SS.mmmZ) and every id is made
14
+ // of lowercase letters, digits and dashes (the relay's UUIDs). A conversation
15
+ // with any other row is not paged here: the selector returns null and the
16
+ // caller keeps the in-memory path, which handles every shape.
17
+
18
+ const ISO_TIMESTAMP_GLOB = '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]Z';
19
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
20
+ const ORDERABLE_ID_PATTERN = /^[-0-9a-z]+$/;
21
+
22
+ export function normalizeConversationTimestampMs(value) {
23
+ const text = String(value || '').trim();
24
+ if (!text) return 0;
25
+ const sqliteUtcLike = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?)$/;
26
+ const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);
27
+ let normalized = text;
28
+ if (!hasExplicitTimezone && sqliteUtcLike.test(text)) {
29
+ const [, day, time] = text.match(sqliteUtcLike) || [];
30
+ normalized = `${day}T${time}Z`;
31
+ }
32
+ const parsed = Date.parse(normalized);
33
+ return Number.isFinite(parsed) ? parsed : 0;
34
+ }
35
+
36
+ export function normalizeConversationHistoryLimit(value, fallback = 20) {
37
+ const numeric = Number(value);
38
+ if (!Number.isFinite(numeric)) return fallback;
39
+ return Math.min(100, Math.max(1, Math.trunc(numeric)));
40
+ }
41
+
42
+ /**
43
+ * The pageInfo of a history page: `pageMessages` in conversation order, and
44
+ * whether the conversation has rows before the first and after the last.
45
+ */
46
+ export function buildConversationHistoryPageInfo(pageMessages = [], { hasMoreOlder = false, hasMoreNewer = false } = {}) {
47
+ const firstMessage = pageMessages[0] || null;
48
+ const lastMessage = pageMessages[pageMessages.length - 1] || null;
49
+ return {
50
+ hasMore: hasMoreOlder,
51
+ hasMoreOlder,
52
+ hasMoreNewer,
53
+ nextCursor: firstMessage ? {
54
+ beforeMessageId: String(firstMessage.id || '').trim(),
55
+ beforeTimestamp: String(firstMessage.timestamp || '').trim() || null,
56
+ } : null,
57
+ olderCursor: firstMessage ? {
58
+ beforeMessageId: String(firstMessage.id || '').trim(),
59
+ beforeTimestamp: String(firstMessage.timestamp || '').trim() || null,
60
+ } : null,
61
+ newerCursor: lastMessage ? {
62
+ afterMessageId: String(lastMessage.id || '').trim(),
63
+ afterTimestamp: String(lastMessage.timestamp || '').trim() || null,
64
+ } : null,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * The prepared statements for paging one conversation. `sharedOnly` pages
70
+ * over the rows a shared view shows (not hidden from shares).
71
+ */
72
+ export function createConversationHistoryPageStatements(db) {
73
+ const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
74
+ const sharedFilter = messageColumns.has('hidden_from_shares') ? 'AND hidden_from_shares = 0' : '';
75
+ const hasResendColumns = messageColumns.has('source_message_id') && messageColumns.has('resend_of_message_id');
76
+ const build = (visibility) => {
77
+ const base = `FROM messages WHERE conversation_id = @conversationId ${visibility}`;
78
+ const older = `(timestamp < @timestamp OR (timestamp = @timestamp AND @id IS NOT NULL AND id < @id))`;
79
+ const newer = `(timestamp > @timestamp OR (timestamp = @timestamp AND @id IS NOT NULL AND id > @id))`;
80
+ return {
81
+ irregular: db.prepare(`
82
+ SELECT EXISTS (
83
+ SELECT 1 ${base}
84
+ AND NOT (timestamp GLOB '${ISO_TIMESTAMP_GLOB}' AND id <> '' AND id NOT GLOB '*[^-0-9a-z]*')
85
+ ) AS irregular
86
+ `),
87
+ findById: db.prepare(`SELECT id, timestamp ${base} AND id = @id`),
88
+ older: db.prepare(`SELECT * ${base} AND ${older} ORDER BY timestamp DESC, id DESC LIMIT @limit`),
89
+ newer: db.prepare(`SELECT * ${base} AND ${newer} ORDER BY timestamp ASC, id ASC LIMIT @limit`),
90
+ fromInclusive: db.prepare(`
91
+ SELECT * ${base} AND (timestamp > @timestamp OR (timestamp = @timestamp AND id >= @id))
92
+ ORDER BY timestamp ASC, id ASC LIMIT @limit
93
+ `),
94
+ tail: db.prepare(`SELECT * ${base} ORDER BY timestamp DESC, id DESC LIMIT @limit`),
95
+ existsOlder: db.prepare(`SELECT EXISTS (SELECT 1 ${base} AND ${older}) AS found`),
96
+ existsNewer: db.prepare(`SELECT EXISTS (SELECT 1 ${base} AND ${newer}) AS found`),
97
+ // What liveResendsByOriginal reads, for the whole conversation: a
98
+ // stopped marker on the page may have its Resend outside it.
99
+ resendIndex: hasResendColumns
100
+ ? db.prepare(`SELECT id, role, source_message_id, resend_of_message_id ${base} ORDER BY timestamp ASC`)
101
+ : null,
102
+ };
103
+ };
104
+ const all = build('');
105
+ const shared = build(sharedFilter);
106
+ return {
107
+ selectConversationHistoryPage(conversationId, options = {}) {
108
+ return selectConversationHistoryPageFromStatements(options.sharedOnly ? shared : all, conversationId, options);
109
+ },
110
+ listConversationResendIndex(conversationId, { sharedOnly = false } = {}) {
111
+ const statement = (sharedOnly ? shared : all).resendIndex;
112
+ return statement ? statement.all({ conversationId }) : [];
113
+ },
114
+ };
115
+ }
116
+
117
+ // A cursor as the in-memory selector resolves it, in the SQL shape: the
118
+ // message's own timestamp when its id is in the conversation, else the given
119
+ // timestamp. Null when there is no cursor; `unsupported` when the given
120
+ // values cannot be compared in SQL the way the selector compares them.
121
+ function resolveCursor(statements, conversationId, { messageId = '', timestamp = '' } = {}) {
122
+ const cursorMessageId = String(messageId || '').trim();
123
+ const cursorTimestampText = String(timestamp || '').trim();
124
+ if (cursorMessageId) {
125
+ const row = statements.findById.get({ conversationId, id: cursorMessageId });
126
+ if (row) return { id: cursorMessageId, timestamp: String(row.timestamp) };
127
+ }
128
+ if (!cursorTimestampText) return null;
129
+ if (cursorMessageId && !ORDERABLE_ID_PATTERN.test(cursorMessageId)) return 'unsupported';
130
+ let canonical = cursorTimestampText;
131
+ if (!ISO_TIMESTAMP_PATTERN.test(canonical)) {
132
+ canonical = new Date(normalizeConversationTimestampMs(cursorTimestampText)).toISOString();
133
+ if (!ISO_TIMESTAMP_PATTERN.test(canonical)) return 'unsupported';
134
+ }
135
+ return { id: cursorMessageId || null, timestamp: canonical };
136
+ }
137
+
138
+ /**
139
+ * The page of rows (raw message rows, in conversation order) and its
140
+ * pageInfo, by the rules of selectConversationHistoryPage; null when the
141
+ * conversation or the cursor cannot be paged in SQL (see the top note).
142
+ */
143
+ function selectConversationHistoryPageFromStatements(statements, conversationId, {
144
+ limit = 20,
145
+ beforeMessageId = '',
146
+ beforeTimestamp = '',
147
+ afterMessageId = '',
148
+ afterTimestamp = '',
149
+ aroundMessageId = '',
150
+ } = {}) {
151
+ if (statements.irregular.get({ conversationId })?.irregular) return null;
152
+ const pageLimit = normalizeConversationHistoryLimit(limit);
153
+ const aroundId = String(aroundMessageId || '').trim();
154
+ const requestedAroundWindow = !!aroundId;
155
+ const requestedAfterWindow = !!String(afterMessageId || '').trim();
156
+ const beforeCursor = resolveCursor(statements, conversationId, { messageId: beforeMessageId, timestamp: beforeTimestamp });
157
+ const afterCursor = resolveCursor(statements, conversationId, { messageId: afterMessageId, timestamp: afterTimestamp });
158
+ if (beforeCursor === 'unsupported' || afterCursor === 'unsupported') return null;
159
+
160
+ let rows = [];
161
+ if (aroundId) {
162
+ const anchor = statements.findById.get({ conversationId, id: aroundId });
163
+ if (anchor) {
164
+ // Enough rows on both sides for the in-memory window rules to give the
165
+ // same window as over the whole conversation.
166
+ const before = statements.older
167
+ .all({ conversationId, timestamp: anchor.timestamp, id: anchor.id, limit: pageLimit - 1 })
168
+ .reverse();
169
+ const fromAnchor = statements.fromInclusive
170
+ .all({ conversationId, timestamp: anchor.timestamp, id: anchor.id, limit: pageLimit });
171
+ const local = [...before, ...fromAnchor];
172
+ const aroundIndex = before.length;
173
+ const halfWindow = Math.floor((pageLimit - 1) / 2);
174
+ let start = Math.max(0, aroundIndex - halfWindow);
175
+ const end = Math.min(local.length, start + pageLimit);
176
+ if ((end - start) < pageLimit) start = Math.max(0, end - pageLimit);
177
+ rows = local.slice(start, end);
178
+ }
179
+ }
180
+ if (!rows.length && !requestedAroundWindow && afterCursor) {
181
+ rows = statements.newer.all({ conversationId, ...afterCursor, limit: pageLimit });
182
+ }
183
+ if (!rows.length && !requestedAfterWindow && !requestedAroundWindow) {
184
+ rows = (beforeCursor
185
+ ? statements.older.all({ conversationId, ...beforeCursor, limit: pageLimit })
186
+ : statements.tail.all({ conversationId, limit: pageLimit })
187
+ ).reverse();
188
+ }
189
+
190
+ const first = rows[0] || null;
191
+ const last = rows[rows.length - 1] || null;
192
+ const hasMoreOlder = first
193
+ ? !!statements.existsOlder.get({ conversationId, timestamp: first.timestamp, id: first.id })?.found
194
+ : false;
195
+ const hasMoreNewer = last
196
+ ? !!statements.existsNewer.get({ conversationId, timestamp: last.timestamp, id: last.id })?.found
197
+ : false;
198
+ return { rows, pageInfo: buildConversationHistoryPageInfo(rows, { hasMoreOlder, hasMoreNewer }) };
199
+ }
@@ -100,12 +100,17 @@ function whitespaceFlexiblePattern(value) {
100
100
  .join('\\s+');
101
101
  }
102
102
 
103
+ // The patterns depend only on the mode, so they are built once per mode and
104
+ // reused: a long conversation sanitises every message on each history load.
105
+ const PROMPT_PREFIX_PATTERN_CACHE = new Map();
106
+
103
107
  function buildPromptPrefixPatterns(mode) {
104
108
  const requestedMode = String(mode || '').trim().toLowerCase();
105
- const modes = requestedMode && MODE_MARKERS[requestedMode]
106
- ? [requestedMode]
107
- : Object.keys(MODE_MARKERS);
108
- return modes.flatMap((key) => {
109
+ const cacheKey = requestedMode && MODE_MARKERS[requestedMode] ? requestedMode : '';
110
+ const cached = PROMPT_PREFIX_PATTERN_CACHE.get(cacheKey);
111
+ if (cached) return cached;
112
+ const modes = cacheKey ? [cacheKey] : Object.keys(MODE_MARKERS);
113
+ const patterns = modes.flatMap((key) => {
109
114
  const marker = MODE_MARKERS[key];
110
115
  const currentFullPrefix = `${marker} ${MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
111
116
  const legacyFullPrefix = `${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
@@ -115,6 +120,8 @@ function buildPromptPrefixPatterns(mode) {
115
120
  new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
116
121
  ];
117
122
  });
123
+ PROMPT_PREFIX_PATTERN_CACHE.set(cacheKey, patterns);
124
+ return patterns;
118
125
  }
119
126
 
120
127
  function stripAttachmentPromptArtifacts(text) {