@mono-agent/web 0.20.8 → 0.20.9

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/dist/store.js CHANGED
@@ -8,7 +8,117 @@ import { WEB_MAX_FILES_PER_TURN, WEB_MAX_LIVE_INPUTS_PER_THREAD, WEB_MAX_TURN_AT
8
8
  import { WebConsoleError } from "./errors.js";
9
9
  import { webPushPreview } from "./push-preview.js";
10
10
  import { prepareWebStatePaths } from "./state-paths.js";
11
- const WEB_STORAGE_SCHEMA_VERSION = 8;
11
+ /**
12
+ * The searchable text of one message row, derived in SQL so the index cannot
13
+ * drift from `parts_json`: every write path in this store goes through the
14
+ * triggers below rather than remembering to maintain a second copy.
15
+ *
16
+ * Only `text` parts are indexed. Reasoning is the agent's working-out and tool
17
+ * payloads are machine JSON; both would drown a search of what was actually
18
+ * said in a conversation.
19
+ *
20
+ * The two highlight sentinels are stripped here rather than trusted to be
21
+ * absent: a message that quotes one would otherwise come back in a snippet as
22
+ * an unbalanced marker and corrupt the client's highlighting.
23
+ */
24
+ const MESSAGE_SEARCH_BODY_SQL = `
25
+ SELECT replace(replace(group_concat(json_extract(value, '$.text'), ' '), char(2), ''), char(3), '')
26
+ FROM json_each(%SOURCE%.parts_json)
27
+ WHERE json_extract(value, '$.type') = 'text'
28
+ AND json_extract(value, '$.text') IS NOT NULL`;
29
+ const messageSearchBody = (source) => MESSAGE_SEARCH_BODY_SQL.replaceAll("%SOURCE%", source);
30
+ /**
31
+ * `unicode61 remove_diacritics 2` folds accents, so an unaccented query still
32
+ * finds accented prose. The write triggers are guarded on `json_valid` because
33
+ * an unindexed message is one missing search hit, while a failed insert would
34
+ * be a lost message.
35
+ */
36
+ const MESSAGE_SEARCH_REINDEX_SQL = `
37
+ DELETE FROM message_search WHERE rowid = old.rowid;
38
+ INSERT INTO message_search(rowid, body)
39
+ SELECT new.rowid, (${messageSearchBody("new")});`;
40
+ /**
41
+ * A streaming answer is rewritten every ~50 ms, and re-extracting a large
42
+ * message's text on each snapshot costs several times the row write itself
43
+ * (measured at ~6x, and ~23 ms per snapshot on the largest real messages). A
44
+ * running message is therefore left out of the index entirely — including at
45
+ * insert, because a cron run is inserted with real prose while still running,
46
+ * and indexing that body once would freeze it: the thread would keep matching
47
+ * narration it no longer contains. It is swept in when it settles, by either
48
+ * statement shape, since some paths write `parts_json` and `status` together and
49
+ * others write only one of them. `reindexUnsettledMessages` closes the remaining
50
+ * hole, a process that dies mid-turn.
51
+ */
52
+ const MESSAGE_SEARCH_SCHEMA_SQL = `
53
+ CREATE VIRTUAL TABLE IF NOT EXISTS message_search USING fts5(
54
+ body,
55
+ tokenize='unicode61 remove_diacritics 2'
56
+ );
57
+ CREATE TRIGGER IF NOT EXISTS message_search_insert
58
+ AFTER INSERT ON messages
59
+ WHEN json_valid(new.parts_json) AND new.status <> 'running' BEGIN
60
+ INSERT INTO message_search(rowid, body)
61
+ SELECT new.rowid, (${messageSearchBody("new")});
62
+ END;
63
+ CREATE TRIGGER IF NOT EXISTS message_search_update
64
+ AFTER UPDATE OF parts_json ON messages
65
+ WHEN json_valid(new.parts_json) AND new.status <> 'running' BEGIN
66
+ ${MESSAGE_SEARCH_REINDEX_SQL}
67
+ END;
68
+ CREATE TRIGGER IF NOT EXISTS message_search_settle
69
+ AFTER UPDATE OF status ON messages
70
+ WHEN json_valid(new.parts_json) AND old.status = 'running' AND new.status <> 'running' BEGIN
71
+ ${MESSAGE_SEARCH_REINDEX_SQL}
72
+ END;
73
+ CREATE TRIGGER IF NOT EXISTS message_search_delete
74
+ AFTER DELETE ON messages BEGIN
75
+ DELETE FROM message_search WHERE rowid = old.rowid;
76
+ END;`;
77
+ /**
78
+ * Messages still marked running carry whatever text the last snapshot left, and
79
+ * the triggers deliberately skipped them. Sweeping them at open means a turn
80
+ * killed by a crash is searchable rather than silently missing forever.
81
+ */
82
+ const MESSAGE_SEARCH_UNSETTLED_SQL = `
83
+ DELETE FROM message_search
84
+ WHERE rowid IN (SELECT rowid FROM messages WHERE status = 'running');
85
+ INSERT INTO message_search(rowid, body)
86
+ SELECT m.rowid, (${messageSearchBody("m")})
87
+ FROM messages m
88
+ WHERE m.status = 'running' AND json_valid(m.parts_json);`;
89
+ const MESSAGE_SEARCH_BACKFILL_SQL = `
90
+ DELETE FROM message_search;
91
+ INSERT INTO message_search(rowid, body)
92
+ SELECT m.rowid, (${messageSearchBody("m")})
93
+ FROM messages m
94
+ WHERE json_valid(m.parts_json);`;
95
+ /** Rows scanned before ranking cuts off; bounds the cost of a one-letter term. */
96
+ const MESSAGE_SEARCH_SCAN_LIMIT = 400;
97
+ export const WEB_THREAD_SEARCH_MAX = 50;
98
+ /** Below this a query matches almost everything, so it is not worth running. */
99
+ export const WEB_THREAD_SEARCH_MIN_QUERY = 2;
100
+ /**
101
+ * Wrap each match inside a returned snippet so the client can highlight it.
102
+ * Control characters, and stripped from the indexed body above, so a snippet's
103
+ * markers are always the ones `snippet()` added.
104
+ */
105
+ export const WEB_SEARCH_HIGHLIGHT_OPEN = "\u0002";
106
+ export const WEB_SEARCH_HIGHLIGHT_CLOSE = "\u0003";
107
+ /**
108
+ * An FTS5 MATCH expression for a typed query. Each token is quoted (so the
109
+ * user's punctuation can never be read as FTS operator syntax) and given a
110
+ * prefix `*` so typing narrows results as you go. Tokens are ANDed: adding a
111
+ * word should cut the result list, not grow it.
112
+ */
113
+ export function messageSearchMatchExpression(raw) {
114
+ const tokens = raw.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
115
+ return tokens.length === 0 ? undefined : tokens.map((token) => `"${token}"*`).join(" ");
116
+ }
117
+ /** Escape the LIKE wildcards so a literal `%` in a title search stays literal. */
118
+ export function escapeLikeTerm(raw) {
119
+ return raw.replaceAll(/[\\%_]/gu, (character) => `\\${character}`);
120
+ }
121
+ const WEB_STORAGE_SCHEMA_VERSION = 9;
12
122
  const MAX_REVISIONS_PER_THREAD = 1_000;
13
123
  export const WEB_THREAD_PAGE_MAX = 200;
14
124
  export const WEB_MESSAGE_PAGE_MAX = 100;
@@ -53,6 +163,9 @@ export class WebStore {
53
163
  store.recoverInterruptedTurns();
54
164
  store.recoverLiveInputs();
55
165
  store.recoverWebPushDeliveries();
166
+ // After recovery, so anything the recovery settled is already indexed by
167
+ // its own trigger and this only sweeps what genuinely stayed running.
168
+ store.reindexUnsettledMessages();
56
169
  return store;
57
170
  }
58
171
  catch (error) {
@@ -919,6 +1032,108 @@ export class WebStore {
919
1032
  : {}),
920
1033
  };
921
1034
  }
1035
+ /**
1036
+ * Conversations of one agent whose title or message prose matches `query`.
1037
+ *
1038
+ * The sidebar filter this replaces could only see the threads already loaded
1039
+ * into the browser, so anything past the first page was unfindable. This runs
1040
+ * against the whole store instead: the FTS index ranks message hits by bm25
1041
+ * and returns a highlighted snippet, and a separate title pass catches
1042
+ * conversations named after something never said inside them.
1043
+ */
1044
+ searchThreads(input) {
1045
+ if (this.getAgent(input.sourceId) === undefined) {
1046
+ throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
1047
+ }
1048
+ const query = input.query.trim();
1049
+ const limit = boundedPageLimit(input.limit, WEB_THREAD_SEARCH_MAX);
1050
+ const match = query.length < WEB_THREAD_SEARCH_MIN_QUERY
1051
+ ? undefined
1052
+ : messageSearchMatchExpression(query);
1053
+ if (match === undefined)
1054
+ return { hits: [], truncated: false };
1055
+ const messageRows = this.database.prepare(`
1056
+ SELECT m.thread_id AS thread_id,
1057
+ snippet(message_search, 0, ?, ?, char(8230), 12) AS snippet,
1058
+ bm25(message_search) AS rank
1059
+ FROM message_search
1060
+ JOIN messages m ON m.rowid = message_search.rowid
1061
+ JOIN threads t ON t.id = m.thread_id
1062
+ WHERE message_search MATCH ? AND t.source_id = ?
1063
+ ORDER BY rank
1064
+ LIMIT ?
1065
+ `).all(WEB_SEARCH_HIGHLIGHT_OPEN, WEB_SEARCH_HIGHLIGHT_CLOSE, match, input.sourceId, MESSAGE_SEARCH_SCAN_LIMIT + 1);
1066
+ // Ranked rows arrive best-first, so the first row seen for a thread is that
1067
+ // thread's best snippet and its rank. The extra probe row exists only to
1068
+ // detect truncation and must not inflate a count or admit a thread.
1069
+ const byThread = new Map();
1070
+ for (const row of messageRows.slice(0, MESSAGE_SEARCH_SCAN_LIMIT)) {
1071
+ const existing = byThread.get(row.thread_id);
1072
+ if (existing === undefined) {
1073
+ byThread.set(row.thread_id, {
1074
+ ...(row.snippet === null || row.snippet.length === 0 ? {} : { snippet: row.snippet }),
1075
+ rank: row.rank,
1076
+ matches: 1,
1077
+ });
1078
+ continue;
1079
+ }
1080
+ byThread.set(row.thread_id, { ...existing, matches: existing.matches + 1 });
1081
+ }
1082
+ // A conversation the user renamed after what it is about may not repeat that
1083
+ // word in any message, so titles are matched separately — as a substring,
1084
+ // because a title is short enough to scan and short enough to type part of.
1085
+ const titleRows = this.database.prepare(`
1086
+ SELECT t.id AS id FROM threads t
1087
+ WHERE t.source_id = ? AND t.title LIKE '%' || ? || '%' ESCAPE '\\'
1088
+ ORDER BY t.updated_at DESC, t.id DESC
1089
+ LIMIT ?
1090
+ `).all(input.sourceId, escapeLikeTerm(query), limit + 1);
1091
+ const titleMatches = new Set(titleRows.slice(0, limit).map((row) => row.id));
1092
+ const rankedIds = [...byThread.entries()]
1093
+ .map(([threadId, hit]) => ({ threadId, ...hit }))
1094
+ .sort((left, right) => left.rank - right.rank)
1095
+ .map((hit) => hit.threadId)
1096
+ .filter((threadId) => !titleMatches.has(threadId));
1097
+ // Title hits lead, because naming a conversation is a deliberate act. But
1098
+ // titles are auto-derived from first prompts, so a common word can match
1099
+ // more of them than fit one page — and letting titles take every slot would
1100
+ // silently return this feature to the title-only search it replaces. Half
1101
+ // the page is therefore reserved for ranked message hits whenever there are
1102
+ // any, and each side takes the other's unused room.
1103
+ const titleIds = [...titleMatches];
1104
+ const titleBudget = rankedIds.length === 0
1105
+ ? limit
1106
+ : Math.max(limit - rankedIds.length, Math.ceil(limit / 2));
1107
+ const ordering = [
1108
+ ...titleIds.slice(0, titleBudget),
1109
+ ...rankedIds,
1110
+ ...titleIds.slice(titleBudget),
1111
+ ];
1112
+ const selectThread = this.database.prepare(threadSelectSql("WHERE t.id = ?"));
1113
+ const hits = [];
1114
+ for (const threadId of ordering) {
1115
+ if (hits.length >= limit)
1116
+ break;
1117
+ const row = selectThread.get(threadId);
1118
+ if (row === undefined)
1119
+ continue;
1120
+ const hit = byThread.get(threadId);
1121
+ hits.push({
1122
+ thread: this.mapThread(row),
1123
+ messageMatches: hit?.matches ?? 0,
1124
+ titleMatch: titleMatches.has(threadId),
1125
+ ...(hit?.snippet === undefined ? {} : { snippet: hit.snippet }),
1126
+ });
1127
+ }
1128
+ return {
1129
+ hits,
1130
+ // Both queries fetch one row past their cap, so this reports a real cut
1131
+ // rather than firing on an exact fill.
1132
+ truncated: messageRows.length > MESSAGE_SEARCH_SCAN_LIMIT
1133
+ || titleRows.length > limit
1134
+ || ordering.length > hits.length,
1135
+ };
1136
+ }
922
1137
  resolveThreadId(id) {
923
1138
  let resolved = id;
924
1139
  const seen = new Set();
@@ -2200,6 +2415,7 @@ export class WebStore {
2200
2415
  );
2201
2416
  CREATE INDEX IF NOT EXISTS push_deliveries_due
2202
2417
  ON push_deliveries(status, next_attempt_at, created_at);
2418
+ ${MESSAGE_SEARCH_SCHEMA_SQL}
2203
2419
  `);
2204
2420
  if (versionRow.user_version === 1) {
2205
2421
  const columns = this.database.prepare("PRAGMA table_info(threads)").all();
@@ -2216,6 +2432,12 @@ export class WebStore {
2216
2432
  this.database.exec("ALTER TABLE cron_overviews ADD COLUMN jobs_truncated INTEGER NOT NULL DEFAULT 0 CHECK (jobs_truncated IN (0, 1))");
2217
2433
  }
2218
2434
  }
2435
+ // The search index derives from parts_json, so an existing database has
2436
+ // to be swept once. Migration writes no message, so the triggers above
2437
+ // cannot have fired yet; the clear-then-insert is a no-op on a fresh
2438
+ // database and idempotent if the sweep is ever re-run.
2439
+ if (versionRow.user_version < 9)
2440
+ this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL);
2219
2441
  if (migrating)
2220
2442
  this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
2221
2443
  }
@@ -2391,6 +2613,7 @@ export class WebStore {
2391
2613
  "push_subscriptions",
2392
2614
  "push_events",
2393
2615
  "push_deliveries",
2616
+ "message_search",
2394
2617
  ]);
2395
2618
  const tables = this.database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
2396
2619
  for (const table of tables)
@@ -2408,6 +2631,16 @@ export class WebStore {
2408
2631
  }
2409
2632
  }
2410
2633
  }
2634
+ /**
2635
+ * Index the messages the streaming gate skipped and nothing settled — the
2636
+ * residue of a process that died mid-turn. Bounded to whatever is still
2637
+ * marked running, which is at most one message per interrupted thread.
2638
+ */
2639
+ reindexUnsettledMessages() {
2640
+ this.transaction(() => {
2641
+ this.database.exec(MESSAGE_SEARCH_UNSETTLED_SQL);
2642
+ });
2643
+ }
2411
2644
  recoverInterruptedTurns() {
2412
2645
  const active = this.listActiveTurnIds();
2413
2646
  for (const turnId of active) {
@@ -3294,6 +3527,7 @@ function applyEvent(parts, event) {
3294
3527
  if (event.type === "tool_call_completed") {
3295
3528
  const status = event.isError === true ? "failed" : "complete";
3296
3529
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3530
+ const executionMs = canonicalExecutionMs(event.executionMs);
3297
3531
  const subagent = subagentOf(event);
3298
3532
  if (subagent !== undefined) {
3299
3533
  const group = ensureSubagentPart(parts, subagent);
@@ -3301,7 +3535,7 @@ function applyEvent(parts, event) {
3301
3535
  replaceSubagentPart(parts, withEventHistoryUpdate({
3302
3536
  ...group,
3303
3537
  status,
3304
- ...(event.executionMs === undefined ? {} : { executionMs: event.executionMs }),
3538
+ ...(executionMs === undefined ? {} : { executionMs }),
3305
3539
  ...(subagent.costUsd === undefined ? {} : { costUsd: subagent.costUsd }),
3306
3540
  }, historyUpdate));
3307
3541
  return;
@@ -3312,6 +3546,7 @@ function applyEvent(parts, event) {
3312
3546
  ...(event.arguments === undefined ? {} : { args: event.arguments }),
3313
3547
  ...(event.content === undefined ? {} : { result: event.content }),
3314
3548
  ...(event.structuredContent === undefined ? {} : { structuredResult: event.structuredContent }),
3549
+ ...(executionMs === undefined ? {} : { executionMs }),
3315
3550
  status,
3316
3551
  }, historyUpdate);
3317
3552
  return;
@@ -3324,7 +3559,7 @@ function applyEvent(parts, event) {
3324
3559
  ...group,
3325
3560
  status,
3326
3561
  ...(event.content === undefined ? {} : { result: event.content }),
3327
- ...(event.executionMs === undefined ? {} : { executionMs: event.executionMs }),
3562
+ ...(executionMs === undefined ? {} : { executionMs }),
3328
3563
  }, historyUpdate));
3329
3564
  return;
3330
3565
  }
@@ -3339,6 +3574,7 @@ function applyEvent(parts, event) {
3339
3574
  // re-render an answered question after a reload, so keep the structured
3340
3575
  // payload beside the prose rather than reparsing the sentence.
3341
3576
  ...(event.structuredContent === undefined ? {} : { structuredResult: event.structuredContent }),
3577
+ ...(executionMs === undefined ? {} : { executionMs }),
3342
3578
  status,
3343
3579
  }, historyUpdate);
3344
3580
  return;
@@ -3378,6 +3614,16 @@ function existingToolName(parts, id) {
3378
3614
  const existing = parts.find((part) => part.type === "tool-call" && part.toolCallId === id);
3379
3615
  return existing?.type === "tool-call" ? existing.toolName : undefined;
3380
3616
  }
3617
+ /**
3618
+ * A duration is only worth persisting when it is a finite, non-negative number.
3619
+ * The stream wire validates `type` and nothing else, and providers derive this
3620
+ * from raw wall-clock subtraction, so a backward clock step or a provider that
3621
+ * reports `NaN` would otherwise be written verbatim and then rejected by the
3622
+ * read-back validator — permanently refusing to open the store.
3623
+ */
3624
+ function canonicalExecutionMs(value) {
3625
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
3626
+ }
3381
3627
  function canonicalEventHistoryUpdate(value) {
3382
3628
  return value === undefined
3383
3629
  ? undefined
@@ -4002,6 +4248,12 @@ function isWebToolCall(value) {
4002
4248
  return typeof call.toolCallId === "string"
4003
4249
  && typeof call.toolName === "string"
4004
4250
  && isWebToolCallStatus(call.status)
4251
+ // Nullish is accepted, not just absent: `JSON.stringify` writes NaN and
4252
+ // Infinity as `null`, so a database written before durations were
4253
+ // canonicalized can hold one. `validateStorage()` re-parses every message at
4254
+ // open, so being strict here would refuse the whole store over a
4255
+ // display-only value the renderers already drop.
4256
+ && (call.executionMs == null || typeof call.executionMs === "number")
4005
4257
  && (call.history === undefined || isSessionToolHistoryMetadata(call.history));
4006
4258
  }
4007
4259
  const SESSION_TOOL_HISTORY_TERMINAL_STATES = new Set([
@@ -4140,7 +4392,7 @@ function isWebMessagePart(value) {
4140
4392
  return typeof part.toolCallId === "string"
4141
4393
  && typeof part.name === "string"
4142
4394
  && (part.label === undefined || typeof part.label === "string")
4143
- && (part.executionMs === undefined || typeof part.executionMs === "number")
4395
+ && (part.executionMs == null || typeof part.executionMs === "number")
4144
4396
  && (part.costUsd === undefined || typeof part.costUsd === "number")
4145
4397
  && (part.history === undefined || isSessionToolHistoryMetadata(part.history))
4146
4398
  && isWebToolCallStatus(part.status)