@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.
- package/CHANGELOG.md +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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) {
|