@mquan/portal 0.0.1

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 (116) hide show
  1. package/README.md +168 -0
  2. package/dist/src/api.d.ts +5 -0
  3. package/dist/src/api.js +357 -0
  4. package/dist/src/api.js.map +1 -0
  5. package/dist/src/cli.d.ts +17 -0
  6. package/dist/src/cli.js +618 -0
  7. package/dist/src/cli.js.map +1 -0
  8. package/dist/src/daemon-lifecycle.d.ts +24 -0
  9. package/dist/src/daemon-lifecycle.js +107 -0
  10. package/dist/src/daemon-lifecycle.js.map +1 -0
  11. package/dist/src/daemon.d.ts +17 -0
  12. package/dist/src/daemon.js +90 -0
  13. package/dist/src/daemon.js.map +1 -0
  14. package/dist/src/doctor.d.ts +14 -0
  15. package/dist/src/doctor.js +51 -0
  16. package/dist/src/doctor.js.map +1 -0
  17. package/dist/src/entrypoint.d.ts +2 -0
  18. package/dist/src/entrypoint.js +14 -0
  19. package/dist/src/entrypoint.js.map +1 -0
  20. package/dist/src/index.d.ts +5 -0
  21. package/dist/src/index.js +5 -0
  22. package/dist/src/index.js.map +1 -0
  23. package/dist/src/launch.d.ts +41 -0
  24. package/dist/src/launch.js +186 -0
  25. package/dist/src/launch.js.map +1 -0
  26. package/dist/src/mcp.d.ts +9 -0
  27. package/dist/src/mcp.js +132 -0
  28. package/dist/src/mcp.js.map +1 -0
  29. package/dist/src/network/client.d.ts +67 -0
  30. package/dist/src/network/client.js +540 -0
  31. package/dist/src/network/client.js.map +1 -0
  32. package/dist/src/network/coordinator.d.ts +83 -0
  33. package/dist/src/network/coordinator.js +225 -0
  34. package/dist/src/network/coordinator.js.map +1 -0
  35. package/dist/src/network/protocol.d.ts +1352 -0
  36. package/dist/src/network/protocol.js +360 -0
  37. package/dist/src/network/protocol.js.map +1 -0
  38. package/dist/src/network/server.d.ts +28 -0
  39. package/dist/src/network/server.js +474 -0
  40. package/dist/src/network/server.js.map +1 -0
  41. package/dist/src/network/tasks.d.ts +65 -0
  42. package/dist/src/network/tasks.js +276 -0
  43. package/dist/src/network/tasks.js.map +1 -0
  44. package/dist/src/portal-index.d.ts +21 -0
  45. package/dist/src/portal-index.js +140 -0
  46. package/dist/src/portal-index.js.map +1 -0
  47. package/dist/src/provider/claude-managed.d.ts +34 -0
  48. package/dist/src/provider/claude-managed.js +125 -0
  49. package/dist/src/provider/claude-managed.js.map +1 -0
  50. package/dist/src/provider/claude.d.ts +24 -0
  51. package/dist/src/provider/claude.js +114 -0
  52. package/dist/src/provider/claude.js.map +1 -0
  53. package/dist/src/provider/codex/app-server-client.d.ts +43 -0
  54. package/dist/src/provider/codex/app-server-client.js +143 -0
  55. package/dist/src/provider/codex/app-server-client.js.map +1 -0
  56. package/dist/src/provider/codex/index.d.ts +3 -0
  57. package/dist/src/provider/codex/index.js +4 -0
  58. package/dist/src/provider/codex/index.js.map +1 -0
  59. package/dist/src/provider/codex/managed-runner.d.ts +12 -0
  60. package/dist/src/provider/codex/managed-runner.js +162 -0
  61. package/dist/src/provider/codex/managed-runner.js.map +1 -0
  62. package/dist/src/provider/codex/probe.d.ts +2 -0
  63. package/dist/src/provider/codex/probe.js +33 -0
  64. package/dist/src/provider/codex/probe.js.map +1 -0
  65. package/dist/src/provider/codex/protocol.d.ts +193 -0
  66. package/dist/src/provider/codex/protocol.js +10 -0
  67. package/dist/src/provider/codex/protocol.js.map +1 -0
  68. package/dist/src/provider/codex/provider.d.ts +35 -0
  69. package/dist/src/provider/codex/provider.js +289 -0
  70. package/dist/src/provider/codex/provider.js.map +1 -0
  71. package/dist/src/provider/codex/remote-app-server.d.ts +16 -0
  72. package/dist/src/provider/codex/remote-app-server.js +80 -0
  73. package/dist/src/provider/codex/remote-app-server.js.map +1 -0
  74. package/dist/src/provider/codex/websocket-client.d.ts +40 -0
  75. package/dist/src/provider/codex/websocket-client.js +174 -0
  76. package/dist/src/provider/codex/websocket-client.js.map +1 -0
  77. package/dist/src/provider/types.d.ts +35 -0
  78. package/dist/src/provider/types.js +2 -0
  79. package/dist/src/provider/types.js.map +1 -0
  80. package/dist/src/recall/embedding.d.ts +28 -0
  81. package/dist/src/recall/embedding.js +142 -0
  82. package/dist/src/recall/embedding.js.map +1 -0
  83. package/dist/src/recall/types.d.ts +35 -0
  84. package/dist/src/recall/types.js +2 -0
  85. package/dist/src/recall/types.js.map +1 -0
  86. package/dist/src/rpc/client.d.ts +6 -0
  87. package/dist/src/rpc/client.js +58 -0
  88. package/dist/src/rpc/client.js.map +1 -0
  89. package/dist/src/rpc/protocol.d.ts +21 -0
  90. package/dist/src/rpc/protocol.js +11 -0
  91. package/dist/src/rpc/protocol.js.map +1 -0
  92. package/dist/src/rpc/server.d.ts +12 -0
  93. package/dist/src/rpc/server.js +154 -0
  94. package/dist/src/rpc/server.js.map +1 -0
  95. package/dist/src/runtime/paths.d.ts +8 -0
  96. package/dist/src/runtime/paths.js +18 -0
  97. package/dist/src/runtime/paths.js.map +1 -0
  98. package/dist/src/service.d.ts +127 -0
  99. package/dist/src/service.js +637 -0
  100. package/dist/src/service.js.map +1 -0
  101. package/dist/src/session-profile.d.ts +39 -0
  102. package/dist/src/session-profile.js +186 -0
  103. package/dist/src/session-profile.js.map +1 -0
  104. package/dist/src/storage/database.d.ts +9 -0
  105. package/dist/src/storage/database.js +354 -0
  106. package/dist/src/storage/database.js.map +1 -0
  107. package/dist/src/storage/repository.d.ts +275 -0
  108. package/dist/src/storage/repository.js +1291 -0
  109. package/dist/src/storage/repository.js.map +1 -0
  110. package/dist/src/version.d.ts +1 -0
  111. package/dist/src/version.js +2 -0
  112. package/dist/src/version.js.map +1 -0
  113. package/docs/architecture-plan.md +191 -0
  114. package/docs/mvp-plan.md +111 -0
  115. package/docs/remote-demo.md +346 -0
  116. package/package.json +47 -0
@@ -0,0 +1,1291 @@
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { cosineSimilarity, embeddingBuckets } from "../recall/embedding.js";
3
+ const DEFAULT_INSTALLATION = "local";
4
+ const DEFAULT_ACCOUNT = "default";
5
+ export class PortalRepository {
6
+ database;
7
+ constructor(database) {
8
+ this.database = database;
9
+ }
10
+ upsertSession(input) {
11
+ const installation = input.installation ?? DEFAULT_INSTALLATION;
12
+ const accountFingerprint = input.accountFingerprint ?? DEFAULT_ACCOUNT;
13
+ const portalSessionId = stableId("ps", input.provider, installation, accountFingerprint, input.vendorSessionId);
14
+ this.database.connection
15
+ .prepare(`
16
+ INSERT INTO sessions (
17
+ portal_session_id, provider, installation, account_fingerprint,
18
+ vendor_session_id, workspace, title, managed_name, observed_at, updated_at, state,
19
+ state_confidence, indexed, connected, messageable
20
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
21
+ ON CONFLICT(provider, installation, account_fingerprint, vendor_session_id)
22
+ DO UPDATE SET
23
+ workspace = excluded.workspace,
24
+ title = excluded.title,
25
+ managed_name = coalesce(excluded.managed_name, sessions.managed_name),
26
+ observed_at = excluded.observed_at,
27
+ updated_at = excluded.updated_at,
28
+ state = excluded.state,
29
+ state_confidence = excluded.state_confidence,
30
+ indexed = max(sessions.indexed, excluded.indexed),
31
+ connected = excluded.connected,
32
+ messageable = excluded.messageable
33
+ `)
34
+ .run(portalSessionId, input.provider, installation, accountFingerprint, input.vendorSessionId, input.workspace, input.title, input.managedName ?? null, (input.observedAt ?? new Date()).getTime(), toEpoch(input.updatedAt), input.state, input.stateConfidence, Number(input.indexed), Number(input.connected), Number(input.messageable));
35
+ const stored = this.findSession({
36
+ provider: input.provider,
37
+ installation,
38
+ accountFingerprint,
39
+ vendorSessionId: input.vendorSessionId,
40
+ });
41
+ if (stored === null)
42
+ throw new Error(`failed to load upserted session ${portalSessionId}`);
43
+ return stored;
44
+ }
45
+ getSession(portalSessionId) {
46
+ const row = this.database.connection
47
+ .prepare("SELECT * FROM sessions WHERE portal_session_id = ?")
48
+ .get(portalSessionId);
49
+ return row === undefined ? null : mapSession(row);
50
+ }
51
+ bindSessionVendorId(portalSessionId, vendorSessionId) {
52
+ if (vendorSessionId.trim().length === 0)
53
+ throw new TypeError("vendor session ID cannot be empty");
54
+ try {
55
+ const changed = this.database.connection
56
+ .prepare("UPDATE sessions SET vendor_session_id = ?, observed_at = ? WHERE portal_session_id = ?")
57
+ .run(vendorSessionId, Date.now(), portalSessionId);
58
+ if (Number(changed.changes) !== 1)
59
+ throw new Error(`unknown Portal session: ${portalSessionId}`);
60
+ }
61
+ catch (error) {
62
+ if (String(error).includes("UNIQUE constraint failed")) {
63
+ throw new Error(`Codex session is already bound in Portal: ${vendorSessionId}`);
64
+ }
65
+ throw error;
66
+ }
67
+ const session = this.getSession(portalSessionId);
68
+ if (session === null)
69
+ throw new Error(`failed to bind Portal session: ${portalSessionId}`);
70
+ return session;
71
+ }
72
+ findSession(identity) {
73
+ const row = this.database.connection
74
+ .prepare(`
75
+ SELECT * FROM sessions
76
+ WHERE provider = ? AND installation = ? AND account_fingerprint = ?
77
+ AND vendor_session_id = ?
78
+ `)
79
+ .get(identity.provider, identity.installation ?? DEFAULT_INSTALLATION, identity.accountFingerprint ?? DEFAULT_ACCOUNT, identity.vendorSessionId);
80
+ return row === undefined ? null : mapSession(row);
81
+ }
82
+ listSessions(workspace) {
83
+ const rows = (workspace === undefined
84
+ ? this.database.connection.prepare("SELECT * FROM sessions ORDER BY updated_at DESC").all()
85
+ : this.database.connection
86
+ .prepare("SELECT * FROM sessions WHERE workspace = ? ORDER BY updated_at DESC")
87
+ .all(workspace));
88
+ return rows.map(mapSession);
89
+ }
90
+ upsertSessionProfile(input) {
91
+ if (this.getSession(input.sessionId) === null) {
92
+ throw new Error(`unknown Portal session: ${input.sessionId}`);
93
+ }
94
+ const displayName = input.displayName.trim();
95
+ if (displayName.length === 0)
96
+ throw new TypeError("session profile display name cannot be empty");
97
+ const topics = input.topics.map((topic) => topic.trim()).filter((topic) => topic.length > 0);
98
+ const updatedAt = input.updatedAt ?? new Date();
99
+ if (!Number.isFinite(updatedAt.getTime()))
100
+ throw new TypeError("session profile updatedAt is invalid");
101
+ this.database.connection.prepare(`
102
+ INSERT INTO session_profiles (
103
+ session_id, display_name, description, topics_json, source,
104
+ transcript_watermark, updated_at
105
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
106
+ ON CONFLICT(session_id) DO UPDATE SET
107
+ display_name = excluded.display_name,
108
+ description = excluded.description,
109
+ topics_json = excluded.topics_json,
110
+ source = excluded.source,
111
+ transcript_watermark = excluded.transcript_watermark,
112
+ updated_at = excluded.updated_at
113
+ `).run(input.sessionId, displayName, input.description.trim(), JSON.stringify(topics), input.source, input.transcriptWatermark, updatedAt.getTime());
114
+ const profile = this.getSessionProfile(input.sessionId);
115
+ if (profile === null)
116
+ throw new Error(`failed to upsert session profile: ${input.sessionId}`);
117
+ return profile;
118
+ }
119
+ getSessionProfile(sessionId) {
120
+ const row = this.database.connection
121
+ .prepare("SELECT * FROM session_profiles WHERE session_id = ?")
122
+ .get(sessionId);
123
+ return row === undefined ? null : mapSessionProfile(row);
124
+ }
125
+ listSessionProfiles(workspace) {
126
+ const rows = (workspace === undefined
127
+ ? this.database.connection
128
+ .prepare("SELECT * FROM session_profiles ORDER BY updated_at DESC, session_id")
129
+ .all()
130
+ : this.database.connection.prepare(`
131
+ SELECT p.* FROM session_profiles p
132
+ JOIN sessions s ON s.portal_session_id = p.session_id
133
+ WHERE s.workspace = ?
134
+ ORDER BY p.updated_at DESC, p.session_id
135
+ `).all(workspace));
136
+ return rows.map(mapSessionProfile);
137
+ }
138
+ ingestTranscript(portalSessionId, entries) {
139
+ const owningSession = this.getSession(portalSessionId);
140
+ if (owningSession === null) {
141
+ throw new Error(`unknown Portal session: ${portalSessionId}`);
142
+ }
143
+ const result = { inserted: 0, updated: 0, unchanged: 0, removed: 0, chunkIds: [] };
144
+ const now = Date.now();
145
+ this.database.transaction(() => {
146
+ const findEntry = this.database.connection.prepare("SELECT entry_id, content_hash FROM transcript_entries WHERE session_id = ? AND source_id = ?");
147
+ const insertEntry = this.database.connection.prepare(`
148
+ INSERT INTO transcript_entries (
149
+ entry_id, session_id, source_id, role, text, occurred_at,
150
+ content_hash, created_at, updated_at
151
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
152
+ `);
153
+ const updateEntry = this.database.connection.prepare(`
154
+ UPDATE transcript_entries
155
+ SET role = ?, text = ?, occurred_at = ?, content_hash = ?, updated_at = ?
156
+ WHERE entry_id = ?
157
+ `);
158
+ const deleteChunks = this.database.connection.prepare("DELETE FROM chunks WHERE first_entry_id = ?");
159
+ const insertChunk = this.database.connection.prepare(`
160
+ INSERT INTO chunks (
161
+ chunk_id, session_id, first_entry_id, last_entry_id, ordinal,
162
+ text, content_hash, occurred_at
163
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
164
+ `);
165
+ this.database.connection.exec(`
166
+ CREATE TEMP TABLE IF NOT EXISTS portal_current_transcript_sources (
167
+ source_id TEXT PRIMARY KEY
168
+ ) WITHOUT ROWID;
169
+ DELETE FROM portal_current_transcript_sources;
170
+ `);
171
+ const markSourcePresent = this.database.connection.prepare("INSERT OR IGNORE INTO portal_current_transcript_sources(source_id) VALUES (?)");
172
+ for (const entry of entries) {
173
+ markSourcePresent.run(entry.sourceId);
174
+ if (entry.vendorSessionId.length > 0) {
175
+ if (owningSession.vendorSessionId !== entry.vendorSessionId) {
176
+ throw new Error(`transcript entry ${entry.sourceId} belongs to ${entry.vendorSessionId}, expected ${owningSession.vendorSessionId}`);
177
+ }
178
+ }
179
+ const entryId = stableId("te", portalSessionId, entry.sourceId);
180
+ const contentHash = digest(entry.role, entry.text, entry.occurredAt?.toISOString() ?? "");
181
+ const existing = findEntry.get(portalSessionId, entry.sourceId);
182
+ if (existing?.content_hash === contentHash) {
183
+ result.unchanged += 1;
184
+ continue;
185
+ }
186
+ if (existing === undefined) {
187
+ insertEntry.run(entryId, portalSessionId, entry.sourceId, entry.role, entry.text, toEpoch(entry.occurredAt), contentHash, now, now);
188
+ result.inserted += 1;
189
+ }
190
+ else {
191
+ updateEntry.run(entry.role, entry.text, toEpoch(entry.occurredAt), contentHash, now, existing.entry_id);
192
+ deleteChunks.run(existing.entry_id);
193
+ result.updated += 1;
194
+ }
195
+ const chunks = splitIntoChunks(entry.text);
196
+ for (let ordinal = 0; ordinal < chunks.length; ordinal += 1) {
197
+ const text = chunks[ordinal];
198
+ if (text === undefined)
199
+ continue;
200
+ const chunkHash = digest(text);
201
+ const chunkId = stableId("ch", entryId, String(ordinal), chunkHash);
202
+ insertChunk.run(chunkId, portalSessionId, entryId, entryId, ordinal, text, chunkHash, toEpoch(entry.occurredAt));
203
+ result.chunkIds.push(chunkId);
204
+ }
205
+ }
206
+ const removed = this.database.connection.prepare(`
207
+ DELETE FROM transcript_entries
208
+ WHERE session_id = ?
209
+ AND NOT EXISTS (
210
+ SELECT 1 FROM portal_current_transcript_sources current
211
+ WHERE current.source_id = transcript_entries.source_id
212
+ )
213
+ `).run(portalSessionId);
214
+ result.removed = Number(removed.changes);
215
+ });
216
+ return result;
217
+ }
218
+ markSessionIndexed(portalSessionId) {
219
+ const changed = this.database.connection
220
+ .prepare("UPDATE sessions SET indexed = 1 WHERE portal_session_id = ?")
221
+ .run(portalSessionId);
222
+ if (Number(changed.changes) !== 1)
223
+ throw new Error(`unknown Portal session: ${portalSessionId}`);
224
+ }
225
+ chunksNeedingEmbedding(portalSessionId, model) {
226
+ const rows = this.database.connection
227
+ .prepare(`
228
+ SELECT chunk_id, session_id, text, embedding_model, embedding_json
229
+ FROM chunks
230
+ WHERE session_id = ? AND (embedding_model IS NULL OR embedding_model != ?)
231
+ ORDER BY occurred_at, chunk_id
232
+ `)
233
+ .all(portalSessionId, model);
234
+ return rows.map(mapChunk);
235
+ }
236
+ saveEmbedding(chunkId, model, vector) {
237
+ this.saveEmbeddings(model, [{ chunkId, vector }]);
238
+ }
239
+ saveEmbeddings(model, embeddings) {
240
+ for (const { vector } of embeddings) {
241
+ if (vector.some((value) => !Number.isFinite(value))) {
242
+ throw new TypeError("embedding contains a non-finite value");
243
+ }
244
+ }
245
+ if (embeddings.length === 0)
246
+ return;
247
+ this.database.transaction(() => {
248
+ const update = this.database.connection.prepare("UPDATE chunks SET embedding_model = ?, embedding_json = ? WHERE chunk_id = ?");
249
+ const removeBuckets = this.database.connection.prepare("DELETE FROM embedding_buckets WHERE chunk_id = ?");
250
+ const insert = this.database.connection.prepare("INSERT INTO embedding_buckets(model, bucket, chunk_id) VALUES (?, ?, ?)");
251
+ for (const { chunkId, vector } of embeddings) {
252
+ const changed = update.run(model, JSON.stringify(Array.from(vector)), chunkId);
253
+ if (Number(changed.changes) !== 1)
254
+ throw new Error(`unknown chunk: ${chunkId}`);
255
+ removeBuckets.run(chunkId);
256
+ for (const bucket of embeddingBuckets(vector))
257
+ insert.run(model, bucket, chunkId);
258
+ }
259
+ });
260
+ }
261
+ lexicalCandidates(ftsQuery, query) {
262
+ if (ftsQuery.length === 0)
263
+ return [];
264
+ const { sql, parameters } = candidateSql(query, true);
265
+ const rows = this.database.connection
266
+ .prepare(sql)
267
+ .all(ftsQuery, query.workspace, ...parameters, query.limit);
268
+ return rows.map((row) => ({
269
+ chunkId: row.chunk_id,
270
+ text: row.text,
271
+ lexicalRank: row.lexical_rank,
272
+ citation: mapCitation(row),
273
+ }));
274
+ }
275
+ semanticCandidates(model, vector, query) {
276
+ const buckets = embeddingBuckets(vector);
277
+ if (buckets.length === 0)
278
+ return [];
279
+ const providerClause = providerFilter(query.providers);
280
+ const timeClause = timeFilter(query);
281
+ const placeholders = buckets.map(() => "?").join(", ");
282
+ const sql = `
283
+ SELECT
284
+ c.chunk_id, c.text, c.embedding_json,
285
+ s.portal_session_id, s.vendor_session_id, s.provider, s.workspace,
286
+ e.source_id, e.entry_id, c.occurred_at,
287
+ count(*) AS bucket_hits
288
+ FROM embedding_buckets b
289
+ JOIN chunks c ON c.chunk_id = b.chunk_id
290
+ JOIN sessions s ON s.portal_session_id = c.session_id
291
+ JOIN transcript_entries e ON e.entry_id = c.first_entry_id
292
+ WHERE b.model = ? AND b.bucket IN (${placeholders})
293
+ AND s.workspace = ?
294
+ ${providerClause.sql}
295
+ ${timeClause.sql}
296
+ GROUP BY c.chunk_id
297
+ ORDER BY bucket_hits DESC
298
+ LIMIT ?
299
+ `;
300
+ const rows = this.database.connection
301
+ .prepare(sql)
302
+ .all(model, ...buckets, query.workspace, ...providerClause.parameters, ...timeClause.parameters, query.limit);
303
+ return rows
304
+ .map((row) => ({
305
+ chunkId: row.chunk_id,
306
+ text: row.text,
307
+ similarity: cosineSimilarity(vector, JSON.parse(row.embedding_json)),
308
+ citation: mapCitation(row),
309
+ }))
310
+ .filter(({ similarity }) => similarity > 0.01)
311
+ .sort((left, right) => right.similarity - left.similarity || left.chunkId.localeCompare(right.chunkId));
312
+ }
313
+ explainLexicalQuery(ftsQuery, query) {
314
+ const { sql, parameters } = candidateSql(query, true);
315
+ const rows = this.database.connection
316
+ .prepare(`EXPLAIN QUERY PLAN ${sql}`)
317
+ .all(ftsQuery, query.workspace, ...parameters, query.limit);
318
+ return rows.map(({ detail }) => detail);
319
+ }
320
+ workspaceFreshness(workspace) {
321
+ const row = this.database.connection
322
+ .prepare(`
323
+ SELECT max(e.updated_at) AS freshness
324
+ FROM transcript_entries e
325
+ JOIN sessions s ON s.portal_session_id = e.session_id
326
+ WHERE s.workspace = ?
327
+ `)
328
+ .get(workspace);
329
+ return fromEpoch(row?.freshness ?? null);
330
+ }
331
+ upsertCursor(input) {
332
+ const updatedAt = Date.now();
333
+ this.database.connection
334
+ .prepare(`
335
+ INSERT INTO ingestion_cursors (
336
+ provider, source_scope, cursor, watermark, last_success, last_error, updated_at
337
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
338
+ ON CONFLICT(provider, source_scope) DO UPDATE SET
339
+ cursor = excluded.cursor,
340
+ watermark = excluded.watermark,
341
+ last_success = excluded.last_success,
342
+ last_error = excluded.last_error,
343
+ updated_at = excluded.updated_at
344
+ `)
345
+ .run(input.provider, input.sourceScope, input.cursor, input.watermark, toEpoch(input.lastSuccess), input.lastError, updatedAt);
346
+ const cursor = this.getCursor(input.provider, input.sourceScope);
347
+ if (cursor === null)
348
+ throw new Error("failed to upsert ingestion cursor");
349
+ return cursor;
350
+ }
351
+ getCursor(provider, sourceScope) {
352
+ const row = this.database.connection
353
+ .prepare("SELECT * FROM ingestion_cursors WHERE provider = ? AND source_scope = ?")
354
+ .get(provider, sourceScope);
355
+ if (row === undefined)
356
+ return null;
357
+ return {
358
+ provider: row.provider,
359
+ sourceScope: row.source_scope,
360
+ cursor: row.cursor,
361
+ watermark: row.watermark,
362
+ lastSuccess: fromEpoch(row.last_success),
363
+ lastError: row.last_error,
364
+ updatedAt: new Date(row.updated_at),
365
+ };
366
+ }
367
+ countTranscriptEntries(sessionId) {
368
+ const row = this.database.connection
369
+ .prepare("SELECT count(*) AS count FROM transcript_entries WHERE session_id = ?")
370
+ .get(sessionId);
371
+ return row.count;
372
+ }
373
+ enqueueMessage(input) {
374
+ const sender = this.getSession(input.senderSessionId);
375
+ const target = this.getSession(input.targetSessionId);
376
+ if (sender === null || target === null)
377
+ throw new Error("message sender and target must exist");
378
+ if (sender.workspace === null || sender.workspace !== target.workspace) {
379
+ throw new Error("cross-workspace messages are not allowed");
380
+ }
381
+ if (input.idempotencyKey.length > 200)
382
+ throw new Error("message idempotency key is too long");
383
+ if (input.replyTo !== undefined && input.replyTo !== null) {
384
+ this.validateReply(input.replyTo, sender.workspace, input.senderSessionId, input.targetSessionId, 4);
385
+ }
386
+ const prior = this.getMessageByIdempotency(input.senderSessionId, input.idempotencyKey);
387
+ if (prior !== null) {
388
+ if (prior.targetSessionId !== input.targetSessionId ||
389
+ prior.body !== input.body ||
390
+ prior.replyTo !== (input.replyTo ?? null)) {
391
+ throw new Error("message idempotency key was reused with a different payload");
392
+ }
393
+ return prior;
394
+ }
395
+ const messageId = stableId("msg", input.senderSessionId, input.idempotencyKey);
396
+ this.database.connection
397
+ .prepare(`
398
+ INSERT INTO messages (
399
+ message_id, sender_session_id, target_session_id, body, state,
400
+ created_at, reply_to, idempotency_key
401
+ ) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)
402
+ ON CONFLICT(sender_session_id, idempotency_key) DO NOTHING
403
+ `)
404
+ .run(messageId, input.senderSessionId, input.targetSessionId, input.body, Date.now(), input.replyTo ?? null, input.idempotencyKey);
405
+ const stored = this.getMessage(messageId);
406
+ if (stored === null)
407
+ throw new Error("failed to enqueue message");
408
+ return stored;
409
+ }
410
+ createMessageIdempotencyKey() {
411
+ return randomUUID();
412
+ }
413
+ countMessagesSince(senderSessionId, since) {
414
+ const row = this.database.connection
415
+ .prepare("SELECT count(*) AS count FROM messages WHERE sender_session_id = ? AND created_at >= ?")
416
+ .get(senderSessionId, since.getTime());
417
+ return row.count;
418
+ }
419
+ hasRunningDelivery(senderSessionId, targetSessionId) {
420
+ const row = this.database.connection.prepare(`
421
+ SELECT 1 AS running FROM messages m
422
+ JOIN message_receipts r ON r.message_id = m.message_id
423
+ WHERE m.sender_session_id = ? AND m.target_session_id = ?
424
+ AND m.state = 'delivering' AND r.status = 'running'
425
+ LIMIT 1
426
+ `).get(senderSessionId, targetSessionId);
427
+ return row?.running === 1;
428
+ }
429
+ getMessage(messageId) {
430
+ const row = this.database.connection
431
+ .prepare("SELECT * FROM messages WHERE message_id = ?")
432
+ .get(messageId);
433
+ return row === undefined ? null : mapMessage(row);
434
+ }
435
+ getMessageByIdempotency(senderSessionId, idempotencyKey) {
436
+ const row = this.database.connection
437
+ .prepare("SELECT * FROM messages WHERE sender_session_id = ? AND idempotency_key = ?")
438
+ .get(senderSessionId, idempotencyKey);
439
+ return row === undefined ? null : mapMessage(row);
440
+ }
441
+ updateMessageState(messageId, state, options = {}) {
442
+ const change = this.database.connection
443
+ .prepare("UPDATE messages SET state = ?, delivered_at = ?, error = ? WHERE message_id = ?")
444
+ .run(state, toEpoch(options.deliveredAt ?? null), options.error ?? null, messageId);
445
+ if (Number(change.changes) !== 1)
446
+ throw new Error(`unknown message: ${messageId}`);
447
+ const message = this.getMessage(messageId);
448
+ if (message === null)
449
+ throw new Error(`failed to load message: ${messageId}`);
450
+ return message;
451
+ }
452
+ listInbox(targetSessionId, state) {
453
+ const rows = (state === undefined
454
+ ? this.database.connection
455
+ .prepare("SELECT * FROM messages WHERE target_session_id = ? ORDER BY created_at, message_id")
456
+ .all(targetSessionId)
457
+ : this.database.connection
458
+ .prepare("SELECT * FROM messages WHERE target_session_id = ? AND state = ? ORDER BY created_at, message_id")
459
+ .all(targetSessionId, state));
460
+ return rows.map(mapMessage);
461
+ }
462
+ receiveQueuedMessages(targetSessionId, acknowledgements = [], claimTtlMs = 60_000) {
463
+ if (acknowledgements.length > 100)
464
+ throw new Error("at most 100 inbox messages can be acknowledged at once");
465
+ const claimedAt = Date.now();
466
+ const staleBefore = claimedAt - claimTtlMs;
467
+ return this.database.transaction(() => {
468
+ const acknowledge = this.database.connection.prepare(`
469
+ UPDATE messages
470
+ SET state = 'delivered', delivered_at = ?, delivery_claimed_at = NULL, error = NULL
471
+ WHERE message_id = ? AND target_session_id = ? AND state = 'delivering'
472
+ `);
473
+ for (const messageId of new Set(acknowledgements)) {
474
+ acknowledge.run(claimedAt, messageId, targetSessionId);
475
+ }
476
+ const rows = this.database.connection
477
+ .prepare(`
478
+ SELECT * FROM messages
479
+ WHERE target_session_id = ? AND (
480
+ state = 'queued' OR
481
+ (state = 'delivering' AND delivery_claimed_at IS NOT NULL AND delivery_claimed_at <= ?)
482
+ )
483
+ ORDER BY created_at, message_id
484
+ `)
485
+ .all(targetSessionId, staleBefore);
486
+ const claim = this.database.connection.prepare(`
487
+ UPDATE messages
488
+ SET state = 'delivering', delivery_claimed_at = ?, delivery_attempts = delivery_attempts + 1, error = NULL
489
+ WHERE message_id = ? AND target_session_id = ? AND (
490
+ state = 'queued' OR
491
+ (state = 'delivering' AND delivery_claimed_at IS NOT NULL AND delivery_claimed_at <= ?)
492
+ )
493
+ `);
494
+ const received = [];
495
+ for (const row of rows) {
496
+ const changed = claim.run(claimedAt, row.message_id, targetSessionId, staleBefore);
497
+ if (Number(changed.changes) === 1) {
498
+ received.push(mapMessage({ ...row, state: "delivering", delivered_at: null }));
499
+ }
500
+ }
501
+ return received;
502
+ });
503
+ }
504
+ touchRunnerLease(sessionId, ttlMs = 45_000) {
505
+ if (this.getSession(sessionId) === null)
506
+ throw new Error(`unknown Portal session: ${sessionId}`);
507
+ const now = Date.now();
508
+ const expiresAt = new Date(now + ttlMs);
509
+ this.database.connection.prepare(`
510
+ INSERT INTO runner_leases (session_id, last_seen, expires_at) VALUES (?, ?, ?)
511
+ ON CONFLICT(session_id) DO UPDATE SET last_seen = excluded.last_seen, expires_at = excluded.expires_at
512
+ `).run(sessionId, now, expiresAt.getTime());
513
+ return expiresAt;
514
+ }
515
+ hasActiveRunnerLease(sessionId, now = new Date()) {
516
+ const row = this.database.connection
517
+ .prepare("SELECT 1 AS active FROM runner_leases WHERE session_id = ? AND expires_at > ?")
518
+ .get(sessionId, now.getTime());
519
+ return row?.active === 1;
520
+ }
521
+ clearRunnerLease(sessionId) {
522
+ this.database.connection.prepare("DELETE FROM runner_leases WHERE session_id = ?").run(sessionId);
523
+ }
524
+ claimNextRunnerMessage(targetSessionId, claimTtlMs = 60_000) {
525
+ const now = Date.now();
526
+ const staleBefore = now - claimTtlMs;
527
+ return this.database.transaction(() => {
528
+ const row = this.database.connection.prepare(`
529
+ SELECT * FROM messages
530
+ WHERE target_session_id = ? AND (
531
+ state = 'queued' OR (state = 'delivering' AND delivery_claimed_at <= ?
532
+ AND NOT EXISTS (SELECT 1 FROM message_receipts r WHERE r.message_id = messages.message_id AND r.status = 'running'))
533
+ )
534
+ ORDER BY created_at, message_id LIMIT 1
535
+ `).get(targetSessionId, staleBefore);
536
+ if (row === undefined)
537
+ return null;
538
+ const changed = this.database.connection.prepare(`
539
+ UPDATE messages SET state = 'delivering', delivery_claimed_at = ?,
540
+ delivery_attempts = delivery_attempts + 1, error = NULL
541
+ WHERE message_id = ? AND target_session_id = ? AND (
542
+ state = 'queued' OR (state = 'delivering' AND delivery_claimed_at <= ?)
543
+ )
544
+ `).run(now, row.message_id, targetSessionId, staleBefore);
545
+ if (Number(changed.changes) !== 1)
546
+ return null;
547
+ this.database.connection.prepare(`
548
+ INSERT INTO message_receipts(message_id, status) VALUES (?, 'claimed')
549
+ ON CONFLICT(message_id) DO UPDATE SET status = 'claimed', error = NULL
550
+ `).run(row.message_id);
551
+ return mapMessage({ ...row, state: "delivering", delivered_at: null });
552
+ });
553
+ }
554
+ markRunnerMessageReceived(messageId, targetSessionId, turnId) {
555
+ const now = Date.now();
556
+ const changed = this.database.connection.prepare(`
557
+ UPDATE message_receipts SET target_turn_id = ?, received_at = ?, status = 'running', error = NULL
558
+ WHERE message_id = ? AND EXISTS (
559
+ SELECT 1 FROM messages WHERE message_id = ? AND target_session_id = ? AND state = 'delivering'
560
+ )
561
+ `).run(turnId, now, messageId, messageId, targetSessionId);
562
+ if (Number(changed.changes) !== 1)
563
+ throw new Error(`message is not claimed by this runner: ${messageId}`);
564
+ return this.requireMessageReceipt(messageId);
565
+ }
566
+ markRunnerMessageStarting(messageId, targetSessionId) {
567
+ const changed = this.database.connection.prepare(`
568
+ UPDATE message_receipts SET status = 'running', error = NULL
569
+ WHERE message_id = ? AND status = 'claimed' AND EXISTS (
570
+ SELECT 1 FROM messages WHERE message_id = ? AND target_session_id = ? AND state = 'delivering'
571
+ )
572
+ `).run(messageId, messageId, targetSessionId);
573
+ if (Number(changed.changes) !== 1)
574
+ throw new Error(`message is not claimed by this runner: ${messageId}`);
575
+ return this.requireMessageReceipt(messageId);
576
+ }
577
+ completeRunnerMessage(messageId, targetSessionId, input) {
578
+ const now = Date.now();
579
+ return this.database.transaction(() => {
580
+ const changed = this.database.connection.prepare(`
581
+ UPDATE messages SET state = 'delivered', delivered_at = ?, delivery_claimed_at = NULL, error = NULL
582
+ WHERE message_id = ? AND target_session_id = ? AND state = 'delivering'
583
+ `).run(now, messageId, targetSessionId);
584
+ if (Number(changed.changes) !== 1)
585
+ throw new Error(`message is not running for this target: ${messageId}`);
586
+ this.database.connection.prepare(`
587
+ UPDATE message_receipts SET target_turn_id = ?, completed_at = ?, status = 'completed',
588
+ answer = ?, tool_results_json = ?, error = NULL WHERE message_id = ?
589
+ `).run(input.turnId, now, input.answer?.slice(0, 65_536) ?? null, JSON.stringify(input.toolResults).slice(0, 65_536), messageId);
590
+ return this.requireMessageReceipt(messageId);
591
+ });
592
+ }
593
+ failRunnerMessage(messageId, targetSessionId, error) {
594
+ const now = Date.now();
595
+ return this.database.transaction(() => {
596
+ const receipt = this.getMessageReceipt(messageId);
597
+ if (receipt === null)
598
+ throw new Error(`unknown message receipt: ${messageId}`);
599
+ const started = receipt.status === "running";
600
+ this.database.connection.prepare(`
601
+ UPDATE messages SET state = ?, delivery_claimed_at = NULL, error = ?
602
+ WHERE message_id = ? AND target_session_id = ? AND state = 'delivering'
603
+ `).run(started ? "failed" : "queued", error.slice(0, 4096), messageId, targetSessionId);
604
+ this.database.connection.prepare(`
605
+ UPDATE message_receipts SET completed_at = ?, status = 'failed', error = ? WHERE message_id = ?
606
+ `).run(now, error.slice(0, 4096), messageId);
607
+ return this.requireMessageReceipt(messageId);
608
+ });
609
+ }
610
+ getMessageReceipt(messageId) {
611
+ const row = this.database.connection.prepare("SELECT * FROM message_receipts WHERE message_id = ?")
612
+ .get(messageId);
613
+ return row === undefined ? null : mapMessageReceipt(row);
614
+ }
615
+ requireMessageReceipt(messageId) {
616
+ const receipt = this.getMessageReceipt(messageId);
617
+ if (receipt === null)
618
+ throw new Error(`unknown message receipt: ${messageId}`);
619
+ return receipt;
620
+ }
621
+ recordRemoteTaskEvent(input) {
622
+ const serverUrl = requireText(input.serverUrl, "remote server URL");
623
+ const taskId = requireText(input.taskId, "remote task ID");
624
+ const eventId = requireText(input.eventId, "remote event ID");
625
+ const envelopeId = requireText(input.envelopeId, "remote envelope ID");
626
+ const localRouteId = requireText(input.localRouteId, "local route ID");
627
+ const remoteRouteId = requireText(input.remoteRouteId, "remote route ID");
628
+ const senderLabel = requireText(input.senderLabel, "remote sender label");
629
+ const senderReference = requireText(input.senderReference, "remote sender reference");
630
+ const occurredAt = requireDate(input.occurredAt, "remote event occurredAt");
631
+ const expiresAt = requireDate(input.expiresAt, "remote task expiresAt");
632
+ const recordedAt = requireDate(input.recordedAt ?? new Date(), "remote event recordedAt");
633
+ if (expiresAt.getTime() <= occurredAt.getTime()) {
634
+ throw new Error("remote task expiry must be after the event timestamp");
635
+ }
636
+ validateRemoteKindState(input.kind, input.state);
637
+ const payloadJson = canonicalJson(input.payload);
638
+ const authenticated = input.authenticated ?? false;
639
+ if (this.getSession(input.localSessionId) === null) {
640
+ throw new Error(`unknown Portal session: ${input.localSessionId}`);
641
+ }
642
+ if (input.localMessageId !== undefined && input.localMessageId !== null) {
643
+ this.validateRemoteLocalMessage(input.localSessionId, input.localMessageId);
644
+ }
645
+ return this.database.transaction(() => {
646
+ const priorEvent = this.getRemoteTaskEvent(serverUrl, eventId);
647
+ if (priorEvent !== null) {
648
+ assertRemoteEventIdempotent(priorEvent, {
649
+ ...input,
650
+ serverUrl,
651
+ taskId,
652
+ eventId,
653
+ envelopeId,
654
+ localRouteId,
655
+ remoteRouteId,
656
+ senderLabel,
657
+ senderReference,
658
+ authenticated,
659
+ payload: JSON.parse(payloadJson),
660
+ occurredAt,
661
+ expiresAt,
662
+ });
663
+ return { task: this.requireRemoteTask(serverUrl, taskId), event: priorEvent, inserted: false };
664
+ }
665
+ const priorEnvelope = this.getRemoteTaskEventByEnvelope(serverUrl, envelopeId);
666
+ if (priorEnvelope !== null) {
667
+ throw new Error(`remote envelope ID conflict: ${envelopeId}`);
668
+ }
669
+ let task = this.getRemoteTask(serverUrl, taskId);
670
+ if (task === null) {
671
+ if (input.kind !== "offer" || input.state !== "offered") {
672
+ throw new Error("a remote task must begin with an offered event");
673
+ }
674
+ this.database.connection.prepare(`
675
+ INSERT INTO remote_tasks (
676
+ server_url, task_id, local_session_id, local_route_id, remote_route_id,
677
+ direction, state, created_at, updated_at, expires_at
678
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
679
+ `).run(serverUrl, taskId, input.localSessionId, localRouteId, remoteRouteId, input.direction, input.state, occurredAt.getTime(), occurredAt.getTime(), expiresAt.getTime());
680
+ }
681
+ else {
682
+ assertRemoteTaskIdentity(task, input.localSessionId, localRouteId, remoteRouteId, expiresAt);
683
+ validateRemoteTransition(task.state, input.state);
684
+ this.database.connection.prepare(`
685
+ UPDATE remote_tasks SET state = ?, updated_at = max(updated_at, ?)
686
+ WHERE server_url = ? AND task_id = ?
687
+ `).run(input.state, occurredAt.getTime(), serverUrl, taskId);
688
+ }
689
+ this.database.connection.prepare(`
690
+ INSERT INTO remote_task_events (
691
+ server_url, event_id, envelope_id, task_id, local_session_id,
692
+ local_route_id, remote_route_id, direction, kind, state, payload_json,
693
+ sender_label, sender_reference, authenticated, local_message_id,
694
+ occurred_at, expires_at, recorded_at, delivery_state
695
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
696
+ `).run(serverUrl, eventId, envelopeId, taskId, input.localSessionId, localRouteId, remoteRouteId, input.direction, input.kind, input.state, payloadJson, senderLabel, senderReference, Number(authenticated), input.localMessageId ?? null, occurredAt.getTime(), expiresAt.getTime(), recordedAt.getTime(), input.direction === "inbound" ? "queued" : "not_applicable");
697
+ task = this.requireRemoteTask(serverUrl, taskId);
698
+ return {
699
+ task,
700
+ event: this.requireRemoteTaskEvent(serverUrl, eventId),
701
+ inserted: true,
702
+ };
703
+ });
704
+ }
705
+ getRemoteTask(serverUrl, taskId) {
706
+ const row = this.database.connection.prepare(`
707
+ SELECT * FROM remote_tasks WHERE server_url = ? AND task_id = ?
708
+ `).get(serverUrl, taskId);
709
+ return row === undefined ? null : mapRemoteTask(row);
710
+ }
711
+ getRemoteTaskEvent(serverUrl, eventId) {
712
+ const row = this.database.connection.prepare(`
713
+ SELECT * FROM remote_task_events WHERE server_url = ? AND event_id = ?
714
+ `).get(serverUrl, eventId);
715
+ return row === undefined ? null : mapRemoteTaskEvent(row);
716
+ }
717
+ getRemoteTaskEventByEnvelope(serverUrl, envelopeId) {
718
+ const row = this.database.connection.prepare(`
719
+ SELECT * FROM remote_task_events WHERE server_url = ? AND envelope_id = ?
720
+ `).get(serverUrl, envelopeId);
721
+ return row === undefined ? null : mapRemoteTaskEvent(row);
722
+ }
723
+ getRemoteTaskEventByLocalMessage(localMessageId) {
724
+ const row = this.database.connection.prepare(`
725
+ SELECT * FROM remote_task_events WHERE local_message_id = ?
726
+ `).get(localMessageId);
727
+ return row === undefined ? null : mapRemoteTaskEvent(row);
728
+ }
729
+ listRemoteTaskEvents(serverUrl, taskId) {
730
+ const rows = this.database.connection.prepare(`
731
+ SELECT * FROM remote_task_events
732
+ WHERE server_url = ? AND task_id = ?
733
+ ORDER BY occurred_at, event_id
734
+ `).all(serverUrl, taskId);
735
+ return rows.map(mapRemoteTaskEvent);
736
+ }
737
+ linkRemoteTaskEventToLocalMessage(serverUrl, eventId, localMessageId) {
738
+ const event = this.requireRemoteTaskEvent(serverUrl, eventId);
739
+ if (event.localMessageId !== null) {
740
+ if (event.localMessageId !== localMessageId) {
741
+ throw new Error(`remote event is already linked to another local message: ${eventId}`);
742
+ }
743
+ return event;
744
+ }
745
+ this.validateRemoteLocalMessage(event.localSessionId, localMessageId);
746
+ try {
747
+ const changed = this.database.connection.prepare(`
748
+ UPDATE remote_task_events SET local_message_id = ?
749
+ WHERE server_url = ? AND event_id = ? AND local_message_id IS NULL
750
+ `).run(localMessageId, serverUrl, eventId);
751
+ if (Number(changed.changes) !== 1)
752
+ throw new Error(`failed to link remote event: ${eventId}`);
753
+ }
754
+ catch (error) {
755
+ if (String(error).includes("UNIQUE constraint failed")) {
756
+ throw new Error(`local message is already linked to another remote event: ${localMessageId}`);
757
+ }
758
+ throw error;
759
+ }
760
+ return this.requireRemoteTaskEvent(serverUrl, eventId);
761
+ }
762
+ claimNextRemoteTaskEvent(localSessionId, claimTtlMs = 60_000) {
763
+ const now = Date.now();
764
+ const staleBefore = now - claimTtlMs;
765
+ return this.database.transaction(() => {
766
+ this.database.connection.prepare(`
767
+ UPDATE remote_task_events SET delivery_state = 'failed', delivery_claimed_at = NULL,
768
+ completed_at = ?, error = 'remote event expired before delivery'
769
+ WHERE local_session_id = ? AND direction = 'inbound'
770
+ AND delivery_state IN ('queued', 'delivering') AND runner_started_at IS NULL
771
+ AND expires_at <= ?
772
+ `).run(now, localSessionId, now);
773
+ const row = this.database.connection.prepare(`
774
+ SELECT * FROM remote_task_events
775
+ WHERE local_session_id = ? AND direction = 'inbound' AND (
776
+ delivery_state = 'queued' OR
777
+ (delivery_state = 'delivering' AND delivery_claimed_at <= ? AND runner_started_at IS NULL)
778
+ ) AND expires_at > ?
779
+ ORDER BY occurred_at, event_id LIMIT 1
780
+ `).get(localSessionId, staleBefore, now);
781
+ if (row === undefined)
782
+ return null;
783
+ const changed = this.database.connection.prepare(`
784
+ UPDATE remote_task_events SET delivery_state = 'delivering',
785
+ delivery_claimed_at = ?, delivery_attempts = delivery_attempts + 1, error = NULL
786
+ WHERE server_url = ? AND event_id = ? AND (
787
+ delivery_state = 'queued' OR
788
+ (delivery_state = 'delivering' AND delivery_claimed_at <= ? AND runner_started_at IS NULL)
789
+ ) AND expires_at > ?
790
+ `).run(now, row.server_url, row.event_id, staleBefore, now);
791
+ if (Number(changed.changes) !== 1)
792
+ return null;
793
+ return this.requireRemoteTaskEvent(row.server_url, row.event_id);
794
+ });
795
+ }
796
+ markRemoteTaskEventStarting(serverUrl, eventId, localSessionId) {
797
+ const changed = this.database.connection.prepare(`
798
+ UPDATE remote_task_events SET runner_started_at = coalesce(runner_started_at, ?), error = NULL
799
+ WHERE server_url = ? AND event_id = ? AND local_session_id = ?
800
+ AND direction = 'inbound' AND delivery_state = 'delivering'
801
+ `).run(Date.now(), serverUrl, eventId, localSessionId);
802
+ if (Number(changed.changes) !== 1)
803
+ throw new Error(`remote event is not claimed by this runner: ${eventId}`);
804
+ return this.requireRemoteTaskEvent(serverUrl, eventId);
805
+ }
806
+ markRemoteTaskEventReceived(serverUrl, eventId, localSessionId, turnId) {
807
+ const now = Date.now();
808
+ const changed = this.database.connection.prepare(`
809
+ UPDATE remote_task_events SET runner_started_at = coalesce(runner_started_at, ?),
810
+ target_turn_id = ?, received_at = ?, error = NULL
811
+ WHERE server_url = ? AND event_id = ? AND local_session_id = ?
812
+ AND direction = 'inbound' AND delivery_state = 'delivering'
813
+ `).run(now, requireText(turnId, "target turn ID"), now, serverUrl, eventId, localSessionId);
814
+ if (Number(changed.changes) !== 1)
815
+ throw new Error(`remote event is not claimed by this runner: ${eventId}`);
816
+ return this.requireRemoteTaskEvent(serverUrl, eventId);
817
+ }
818
+ completeRemoteTaskEvent(serverUrl, eventId, localSessionId, input) {
819
+ const now = Date.now();
820
+ const toolResultsJson = canonicalJson(input.toolResults);
821
+ const changed = this.database.connection.prepare(`
822
+ UPDATE remote_task_events SET delivery_state = 'delivered', delivery_claimed_at = NULL,
823
+ target_turn_id = ?, completed_at = ?, answer = ?, tool_results_json = ?, error = NULL
824
+ WHERE server_url = ? AND event_id = ? AND local_session_id = ?
825
+ AND direction = 'inbound' AND delivery_state = 'delivering' AND runner_started_at IS NOT NULL
826
+ `).run(requireText(input.turnId, "target turn ID"), now, input.answer?.slice(0, 65_536) ?? null, toolResultsJson, serverUrl, eventId, localSessionId);
827
+ if (Number(changed.changes) !== 1)
828
+ throw new Error(`remote event is not running for this target: ${eventId}`);
829
+ return this.requireRemoteTaskEvent(serverUrl, eventId);
830
+ }
831
+ failRemoteTaskEvent(serverUrl, eventId, localSessionId, error) {
832
+ const event = this.requireRemoteTaskEvent(serverUrl, eventId);
833
+ if (event.localSessionId !== localSessionId || event.direction !== "inbound"
834
+ || event.deliveryState !== "delivering") {
835
+ throw new Error(`remote event is not claimed by this runner: ${eventId}`);
836
+ }
837
+ const started = event.runnerStartedAt !== null;
838
+ const now = Date.now();
839
+ this.database.connection.prepare(`
840
+ UPDATE remote_task_events SET delivery_state = ?, delivery_claimed_at = NULL,
841
+ completed_at = ?, error = ?
842
+ WHERE server_url = ? AND event_id = ?
843
+ `).run(started ? "failed" : "queued", started ? now : null, error.slice(0, 4096), serverUrl, eventId);
844
+ return this.requireRemoteTaskEvent(serverUrl, eventId);
845
+ }
846
+ requireRemoteTask(serverUrl, taskId) {
847
+ const task = this.getRemoteTask(serverUrl, taskId);
848
+ if (task === null)
849
+ throw new Error(`unknown remote task: ${taskId}`);
850
+ return task;
851
+ }
852
+ requireRemoteTaskEvent(serverUrl, eventId) {
853
+ const event = this.getRemoteTaskEvent(serverUrl, eventId);
854
+ if (event === null)
855
+ throw new Error(`unknown remote task event: ${eventId}`);
856
+ return event;
857
+ }
858
+ validateRemoteLocalMessage(localSessionId, localMessageId) {
859
+ const message = this.getMessage(localMessageId);
860
+ if (message === null)
861
+ throw new Error(`unknown message: ${localMessageId}`);
862
+ if (message.senderSessionId !== localSessionId && message.targetSessionId !== localSessionId) {
863
+ throw new Error("local message does not belong to the remote task session");
864
+ }
865
+ }
866
+ issueSessionCredential(sessionId) {
867
+ if (this.getSession(sessionId) === null)
868
+ throw new Error(`unknown Portal session: ${sessionId}`);
869
+ const credentialId = randomUUID();
870
+ const secret = randomBytes(32).toString("base64url");
871
+ this.database.connection
872
+ .prepare(`
873
+ INSERT INTO session_credentials (
874
+ credential_id, session_id, secret_hash, created_at, expires_at
875
+ ) VALUES (?, ?, ?, ?, ?)
876
+ `)
877
+ .run(credentialId, sessionId, digest(secret), Date.now(), Date.now() + 86_400_000);
878
+ return `pc_${credentialId}_${secret}`;
879
+ }
880
+ authenticateSession(sessionId, token) {
881
+ const parsed = parseCredential(token);
882
+ if (parsed === null)
883
+ throw new Error("invalid Portal session credential");
884
+ const row = this.database.connection
885
+ .prepare(`
886
+ SELECT session_id, secret_hash FROM session_credentials
887
+ WHERE credential_id = ? AND revoked_at IS NULL
888
+ AND (expires_at IS NULL OR expires_at > ?)
889
+ `)
890
+ .get(parsed.credentialId, Date.now());
891
+ const candidate = digest(parsed.secret);
892
+ if (row === undefined ||
893
+ row.session_id !== sessionId ||
894
+ !safeEqual(candidate, row.secret_hash)) {
895
+ throw new Error("invalid Portal session credential");
896
+ }
897
+ const session = this.getSession(sessionId);
898
+ if (session === null)
899
+ throw new Error("credential references an unknown Portal session");
900
+ return session;
901
+ }
902
+ revokeSessionCredential(token) {
903
+ const parsed = parseCredential(token);
904
+ if (parsed === null)
905
+ return;
906
+ this.database.connection
907
+ .prepare("UPDATE session_credentials SET revoked_at = ? WHERE credential_id = ?")
908
+ .run(Date.now(), parsed.credentialId);
909
+ }
910
+ touchConnectionLease(sessionId, ttlMs = 120_000) {
911
+ if (!Number.isInteger(ttlMs) || ttlMs < 10_000)
912
+ throw new RangeError("connection lease TTL is too small");
913
+ if (this.getSession(sessionId) === null)
914
+ throw new Error(`unknown Portal session: ${sessionId}`);
915
+ const now = Date.now();
916
+ const expiresAt = new Date(now + ttlMs);
917
+ this.database.connection
918
+ .prepare(`
919
+ INSERT INTO connection_leases (session_id, last_seen, expires_at)
920
+ VALUES (?, ?, ?)
921
+ ON CONFLICT(session_id) DO UPDATE SET
922
+ last_seen = excluded.last_seen,
923
+ expires_at = excluded.expires_at
924
+ `)
925
+ .run(sessionId, now, expiresAt.getTime());
926
+ return expiresAt;
927
+ }
928
+ hasActiveConnectionLease(sessionId, now = new Date()) {
929
+ const row = this.database.connection
930
+ .prepare("SELECT 1 AS active FROM connection_leases WHERE session_id = ? AND expires_at > ?")
931
+ .get(sessionId, now.getTime());
932
+ return row?.active === 1;
933
+ }
934
+ clearConnectionLease(sessionId) {
935
+ this.database.connection.prepare("DELETE FROM connection_leases WHERE session_id = ?").run(sessionId);
936
+ }
937
+ validateReply(messageId, workspace, senderSessionId, targetSessionId, maxDepth) {
938
+ let current = messageId;
939
+ const visited = new Set();
940
+ for (let depth = 0; current !== null; depth += 1) {
941
+ if (depth >= maxDepth)
942
+ throw new Error(`message reply depth exceeds ${maxDepth}`);
943
+ if (visited.has(current))
944
+ throw new Error("message reply chain contains a cycle");
945
+ visited.add(current);
946
+ const row = this.database.connection
947
+ .prepare(`
948
+ SELECT m.reply_to, m.sender_session_id, m.target_session_id,
949
+ sender.workspace AS sender_workspace, target.workspace AS target_workspace
950
+ FROM messages m
951
+ JOIN sessions sender ON sender.portal_session_id = m.sender_session_id
952
+ JOIN sessions target ON target.portal_session_id = m.target_session_id
953
+ WHERE m.message_id = ?
954
+ `)
955
+ .get(current);
956
+ if (row === undefined)
957
+ throw new Error(`unknown reply message: ${current}`);
958
+ if (row.sender_workspace !== workspace || row.target_workspace !== workspace) {
959
+ throw new Error("reply message is outside the sender workspace");
960
+ }
961
+ if (depth === 0 && (row.target_session_id !== senderSessionId || row.sender_session_id !== targetSessionId)) {
962
+ throw new Error("reply participants must reverse the parent message participants");
963
+ }
964
+ current = row.reply_to;
965
+ }
966
+ }
967
+ }
968
+ function mapSession(row) {
969
+ return {
970
+ portalSessionId: row.portal_session_id,
971
+ provider: row.provider,
972
+ installation: row.installation,
973
+ accountFingerprint: row.account_fingerprint,
974
+ vendorSessionId: row.vendor_session_id,
975
+ workspace: row.workspace,
976
+ title: row.title,
977
+ managedName: row.managed_name,
978
+ observedAt: new Date(row.observed_at),
979
+ updatedAt: fromEpoch(row.updated_at),
980
+ state: row.state,
981
+ stateConfidence: row.state_confidence,
982
+ indexed: row.indexed === 1,
983
+ connected: row.connected === 1,
984
+ messageable: row.messageable === 1,
985
+ };
986
+ }
987
+ function mapChunk(row) {
988
+ return {
989
+ chunkId: row.chunk_id,
990
+ sessionId: row.session_id,
991
+ text: row.text,
992
+ embeddingModel: row.embedding_model,
993
+ embedding: row.embedding_json === null ? null : JSON.parse(row.embedding_json),
994
+ };
995
+ }
996
+ function mapSessionProfile(row) {
997
+ return {
998
+ sessionId: row.session_id,
999
+ displayName: row.display_name,
1000
+ description: row.description,
1001
+ topics: JSON.parse(row.topics_json),
1002
+ source: row.source,
1003
+ transcriptWatermark: row.transcript_watermark,
1004
+ updatedAt: new Date(row.updated_at),
1005
+ };
1006
+ }
1007
+ function mapCitation(row) {
1008
+ return {
1009
+ portalSessionId: row.portal_session_id,
1010
+ vendorSessionId: row.vendor_session_id,
1011
+ provider: row.provider,
1012
+ workspace: row.workspace,
1013
+ sourceId: row.source_id,
1014
+ entryId: row.entry_id,
1015
+ occurredAt: fromEpoch(row.occurred_at),
1016
+ uri: `portal://session/${encodeURIComponent(row.portal_session_id)}/entry/${encodeURIComponent(row.entry_id)}`,
1017
+ };
1018
+ }
1019
+ function mapMessage(row) {
1020
+ return {
1021
+ messageId: row.message_id,
1022
+ senderSessionId: row.sender_session_id,
1023
+ targetSessionId: row.target_session_id,
1024
+ body: row.body,
1025
+ state: row.state,
1026
+ createdAt: new Date(row.created_at),
1027
+ deliveredAt: fromEpoch(row.delivered_at),
1028
+ replyTo: row.reply_to,
1029
+ idempotencyKey: row.idempotency_key,
1030
+ error: row.error,
1031
+ };
1032
+ }
1033
+ function mapMessageReceipt(row) {
1034
+ return {
1035
+ messageId: row.message_id,
1036
+ targetTurnId: row.target_turn_id,
1037
+ receivedAt: fromEpoch(row.received_at),
1038
+ completedAt: fromEpoch(row.completed_at),
1039
+ status: row.status,
1040
+ answer: row.answer,
1041
+ toolResults: row.tool_results_json === null ? [] : JSON.parse(row.tool_results_json),
1042
+ error: row.error,
1043
+ };
1044
+ }
1045
+ function mapRemoteTask(row) {
1046
+ return {
1047
+ serverUrl: row.server_url,
1048
+ taskId: row.task_id,
1049
+ localSessionId: row.local_session_id,
1050
+ localRouteId: row.local_route_id,
1051
+ remoteRouteId: row.remote_route_id,
1052
+ direction: row.direction,
1053
+ state: row.state,
1054
+ createdAt: new Date(row.created_at),
1055
+ updatedAt: new Date(row.updated_at),
1056
+ expiresAt: new Date(row.expires_at),
1057
+ };
1058
+ }
1059
+ function mapRemoteTaskEvent(row) {
1060
+ return {
1061
+ serverUrl: row.server_url,
1062
+ taskId: row.task_id,
1063
+ eventId: row.event_id,
1064
+ envelopeId: row.envelope_id,
1065
+ localSessionId: row.local_session_id,
1066
+ localRouteId: row.local_route_id,
1067
+ remoteRouteId: row.remote_route_id,
1068
+ direction: row.direction,
1069
+ kind: row.kind,
1070
+ state: row.state,
1071
+ payload: JSON.parse(row.payload_json),
1072
+ senderLabel: row.sender_label,
1073
+ senderReference: row.sender_reference,
1074
+ authenticated: row.authenticated === 1,
1075
+ localMessageId: row.local_message_id,
1076
+ occurredAt: new Date(row.occurred_at),
1077
+ expiresAt: new Date(row.expires_at),
1078
+ recordedAt: new Date(row.recorded_at),
1079
+ deliveryState: row.delivery_state,
1080
+ deliveryClaimedAt: fromEpoch(row.delivery_claimed_at),
1081
+ deliveryAttempts: row.delivery_attempts,
1082
+ runnerStartedAt: fromEpoch(row.runner_started_at),
1083
+ targetTurnId: row.target_turn_id,
1084
+ receivedAt: fromEpoch(row.received_at),
1085
+ completedAt: fromEpoch(row.completed_at),
1086
+ answer: row.answer,
1087
+ toolResults: row.tool_results_json === null ? [] : JSON.parse(row.tool_results_json),
1088
+ error: row.error,
1089
+ };
1090
+ }
1091
+ const REMOTE_EVENT_STATES = {
1092
+ offer: "offered",
1093
+ accepted: "accepted",
1094
+ declined: "declined",
1095
+ running: "running",
1096
+ progress: "running",
1097
+ question: "running",
1098
+ answer: "running",
1099
+ completed: "completed",
1100
+ failed: "failed",
1101
+ };
1102
+ const REMOTE_TRANSITIONS = {
1103
+ offered: new Set(["offered", "accepted", "declined", "failed"]),
1104
+ accepted: new Set(["accepted", "running", "completed", "failed"]),
1105
+ declined: new Set(),
1106
+ running: new Set(["running", "completed", "failed"]),
1107
+ completed: new Set(),
1108
+ failed: new Set(),
1109
+ };
1110
+ function validateRemoteKindState(kind, state) {
1111
+ if (REMOTE_EVENT_STATES[kind] !== state) {
1112
+ throw new Error(`remote event kind ${kind} cannot have task state ${state}`);
1113
+ }
1114
+ }
1115
+ function validateRemoteTransition(from, to) {
1116
+ if (!REMOTE_TRANSITIONS[from].has(to)) {
1117
+ throw new Error(`invalid remote task state transition: ${from} -> ${to}`);
1118
+ }
1119
+ }
1120
+ function assertRemoteTaskIdentity(task, localSessionId, localRouteId, remoteRouteId, expiresAt) {
1121
+ if (task.localSessionId !== localSessionId
1122
+ || task.localRouteId !== localRouteId
1123
+ || task.remoteRouteId !== remoteRouteId
1124
+ || task.expiresAt.getTime() !== expiresAt.getTime()) {
1125
+ throw new Error(`remote task identity conflict: ${task.taskId}`);
1126
+ }
1127
+ }
1128
+ function assertRemoteEventIdempotent(existing, input) {
1129
+ const matches = existing.taskId === input.taskId
1130
+ && existing.envelopeId === input.envelopeId
1131
+ && existing.localSessionId === input.localSessionId
1132
+ && existing.localRouteId === input.localRouteId
1133
+ && existing.remoteRouteId === input.remoteRouteId
1134
+ && existing.direction === input.direction
1135
+ && existing.kind === input.kind
1136
+ && existing.state === input.state
1137
+ && canonicalJson(existing.payload) === canonicalJson(input.payload)
1138
+ && existing.senderLabel === input.senderLabel
1139
+ && existing.senderReference === input.senderReference
1140
+ && existing.authenticated === input.authenticated
1141
+ && existing.occurredAt.getTime() === input.occurredAt.getTime()
1142
+ && existing.expiresAt.getTime() === input.expiresAt.getTime();
1143
+ if (!matches)
1144
+ throw new Error(`remote event ID conflict: ${existing.eventId}`);
1145
+ }
1146
+ function requireText(value, label) {
1147
+ const trimmed = value.trim();
1148
+ if (trimmed.length === 0)
1149
+ throw new TypeError(`${label} cannot be empty`);
1150
+ return trimmed;
1151
+ }
1152
+ function requireDate(value, label) {
1153
+ if (!Number.isFinite(value.getTime()))
1154
+ throw new TypeError(`${label} is invalid`);
1155
+ return value;
1156
+ }
1157
+ function canonicalJson(value) {
1158
+ const seen = new Set();
1159
+ const normalize = (candidate) => {
1160
+ if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean")
1161
+ return candidate;
1162
+ if (typeof candidate === "number") {
1163
+ if (!Number.isFinite(candidate))
1164
+ throw new TypeError("remote event payload contains a non-finite number");
1165
+ return candidate;
1166
+ }
1167
+ if (Array.isArray(candidate))
1168
+ return candidate.map(normalize);
1169
+ if (typeof candidate === "object") {
1170
+ if (seen.has(candidate))
1171
+ throw new TypeError("remote event payload contains a cycle");
1172
+ seen.add(candidate);
1173
+ const result = {};
1174
+ for (const key of Object.keys(candidate).sort()) {
1175
+ const normalized = normalize(candidate[key]);
1176
+ if (normalized !== undefined)
1177
+ result[key] = normalized;
1178
+ }
1179
+ seen.delete(candidate);
1180
+ return result;
1181
+ }
1182
+ if (candidate === undefined)
1183
+ throw new TypeError("remote event payload cannot be undefined");
1184
+ throw new TypeError(`remote event payload contains unsupported ${typeof candidate}`);
1185
+ };
1186
+ return JSON.stringify(normalize(value));
1187
+ }
1188
+ function stableId(prefix, ...parts) {
1189
+ return `${prefix}_${digest(...parts).slice(0, 32)}`;
1190
+ }
1191
+ function digest(...parts) {
1192
+ const hash = createHash("sha256");
1193
+ for (const part of parts) {
1194
+ hash.update(String(Buffer.byteLength(part)));
1195
+ hash.update(":");
1196
+ hash.update(part);
1197
+ hash.update(";");
1198
+ }
1199
+ return hash.digest("hex");
1200
+ }
1201
+ function parseCredential(token) {
1202
+ const match = /^pc_([0-9a-f-]{36})_([A-Za-z0-9_-]+)$/.exec(token);
1203
+ return match?.[1] && match[2] ? { credentialId: match[1], secret: match[2] } : null;
1204
+ }
1205
+ function safeEqual(left, right) {
1206
+ const leftBuffer = Buffer.from(left);
1207
+ const rightBuffer = Buffer.from(right);
1208
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
1209
+ }
1210
+ function toEpoch(date) {
1211
+ return date?.getTime() ?? null;
1212
+ }
1213
+ function fromEpoch(value) {
1214
+ return value === null ? null : new Date(value);
1215
+ }
1216
+ function splitIntoChunks(text, maxCharacters = 900) {
1217
+ const normalized = text.replace(/\r\n?/g, "\n").trim();
1218
+ if (normalized.length === 0)
1219
+ return [];
1220
+ const chunks = [];
1221
+ let remaining = normalized;
1222
+ while (remaining.length > maxCharacters) {
1223
+ const window = remaining.slice(0, maxCharacters + 1);
1224
+ const paragraphBoundary = window.lastIndexOf("\n\n");
1225
+ const sentenceBoundary = Math.max(window.lastIndexOf(". "), window.lastIndexOf("? "), window.lastIndexOf("! "));
1226
+ const wordBoundary = window.lastIndexOf(" ");
1227
+ const boundary = Math.max(paragraphBoundary >= Math.floor(maxCharacters * 0.45) ? paragraphBoundary + 2 : -1, sentenceBoundary >= Math.floor(maxCharacters * 0.55) ? sentenceBoundary + 1 : -1, wordBoundary >= Math.floor(maxCharacters * 0.7) ? wordBoundary : -1, maxCharacters);
1228
+ chunks.push(remaining.slice(0, boundary).trim());
1229
+ remaining = remaining.slice(boundary).trim();
1230
+ }
1231
+ if (remaining.length > 0)
1232
+ chunks.push(remaining);
1233
+ return chunks;
1234
+ }
1235
+ export function compileFtsQuery(query) {
1236
+ const tokens = query
1237
+ .normalize("NFKD")
1238
+ .replace(/\p{Mark}/gu, "")
1239
+ .toLocaleLowerCase("en-US")
1240
+ .match(/[\p{Letter}\p{Number}]+/gu);
1241
+ if (tokens === null || tokens.length === 0)
1242
+ return "";
1243
+ return [...new Set(tokens)].map((token) => `"${token.replaceAll('"', '""')}"`).join(" OR ");
1244
+ }
1245
+ function candidateSql(query, lexical) {
1246
+ const providerClause = providerFilter(query.providers);
1247
+ const timeClause = timeFilter(query);
1248
+ const source = lexical
1249
+ ? "chunks_fts JOIN chunks c ON c.rowid = chunks_fts.rowid"
1250
+ : "chunks c";
1251
+ return {
1252
+ sql: `
1253
+ SELECT
1254
+ c.chunk_id, c.text,
1255
+ s.portal_session_id, s.vendor_session_id, s.provider, s.workspace,
1256
+ e.source_id, e.entry_id, c.occurred_at
1257
+ ${lexical ? ", bm25(chunks_fts) AS lexical_rank" : ""}
1258
+ FROM ${source}
1259
+ JOIN sessions s ON s.portal_session_id = c.session_id
1260
+ JOIN transcript_entries e ON e.entry_id = c.first_entry_id
1261
+ WHERE ${lexical ? "chunks_fts MATCH ? AND" : ""} s.workspace = ?
1262
+ ${providerClause.sql}
1263
+ ${timeClause.sql}
1264
+ ${lexical ? "ORDER BY lexical_rank, c.chunk_id" : "ORDER BY c.chunk_id"}
1265
+ LIMIT ?
1266
+ `,
1267
+ parameters: [...providerClause.parameters, ...timeClause.parameters],
1268
+ };
1269
+ }
1270
+ function providerFilter(providers) {
1271
+ if (providers === undefined || providers.length === 0)
1272
+ return { sql: "", parameters: [] };
1273
+ return {
1274
+ sql: `AND s.provider IN (${providers.map(() => "?").join(", ")})`,
1275
+ parameters: [...providers],
1276
+ };
1277
+ }
1278
+ function timeFilter(query) {
1279
+ const clauses = [];
1280
+ const parameters = [];
1281
+ if (query.occurredAfter !== undefined) {
1282
+ clauses.push("AND c.occurred_at >= ?");
1283
+ parameters.push(query.occurredAfter.getTime());
1284
+ }
1285
+ if (query.occurredBefore !== undefined) {
1286
+ clauses.push("AND c.occurred_at <= ?");
1287
+ parameters.push(query.occurredBefore.getTime());
1288
+ }
1289
+ return { sql: clauses.join("\n"), parameters };
1290
+ }
1291
+ //# sourceMappingURL=repository.js.map