dsh-thinking-guard 0.1.19 → 0.1.21

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 (73) hide show
  1. package/README.md +80 -80
  2. package/cordis.patch.yml +5 -0
  3. package/lib/client.d.ts +20 -2
  4. package/lib/client.d.ts.map +1 -1
  5. package/lib/client.js +6724 -7923
  6. package/lib/guard-core.js +72 -1
  7. package/lib/guard-core.js.map +1 -1
  8. package/lib/guard-loop-core.js +36 -5
  9. package/lib/guard-loop-core.js.map +1 -1
  10. package/lib/guard.js +20 -4
  11. package/lib/guard.js.map +1 -1
  12. package/lib/host-face.js +1 -0
  13. package/lib/host-face.js.map +1 -1
  14. package/lib/index.js +11 -1
  15. package/lib/index.js.map +1 -1
  16. package/lib/policy-state.js +29 -1
  17. package/lib/policy-state.js.map +1 -1
  18. package/lib/policy.js +24 -2
  19. package/lib/policy.js.map +1 -1
  20. package/lib/server/call-index-remote.js +110 -0
  21. package/lib/server/call-index-remote.js.map +1 -0
  22. package/lib/server/remote.js +13 -0
  23. package/lib/server/remote.js.map +1 -1
  24. package/lib/types/guard-core.d.ts +69 -1
  25. package/lib/types/guard-core.d.ts.map +1 -1
  26. package/lib/types/guard-loop-core.d.ts +15 -0
  27. package/lib/types/guard-loop-core.d.ts.map +1 -1
  28. package/lib/types/guard.d.ts +7 -1
  29. package/lib/types/guard.d.ts.map +1 -1
  30. package/lib/types/host-face.d.ts.map +1 -1
  31. package/lib/types/index.d.ts +5 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/policy-state.d.ts +6 -0
  34. package/lib/types/policy-state.d.ts.map +1 -1
  35. package/lib/types/policy.d.ts +22 -3
  36. package/lib/types/policy.d.ts.map +1 -1
  37. package/lib/types/server/call-index-remote.d.ts +17 -0
  38. package/lib/types/server/call-index-remote.d.ts.map +1 -0
  39. package/lib/types/server/remote.d.ts +9 -0
  40. package/lib/types/server/remote.d.ts.map +1 -1
  41. package/lib/types/usage/call-index-core.d.ts +152 -0
  42. package/lib/types/usage/call-index-core.d.ts.map +1 -0
  43. package/lib/types/usage/call-index-host-face.d.ts +44 -0
  44. package/lib/types/usage/call-index-host-face.d.ts.map +1 -0
  45. package/lib/types/usage/call-index-host.d.ts +7 -0
  46. package/lib/types/usage/call-index-host.d.ts.map +1 -0
  47. package/lib/types/usage/call-index-query.d.ts +97 -0
  48. package/lib/types/usage/call-index-query.d.ts.map +1 -0
  49. package/lib/types/usage/call-index-runtime.d.ts +46 -0
  50. package/lib/types/usage/call-index-runtime.d.ts.map +1 -0
  51. package/lib/types/usage/call-index-scope.d.ts +59 -0
  52. package/lib/types/usage/call-index-scope.d.ts.map +1 -0
  53. package/lib/types/usage/call-index-wire.d.ts +286 -0
  54. package/lib/types/usage/call-index-wire.d.ts.map +1 -0
  55. package/lib/types/usage/session-index.d.ts +149 -0
  56. package/lib/types/usage/session-index.d.ts.map +1 -0
  57. package/lib/usage/call-index-core.js +761 -0
  58. package/lib/usage/call-index-core.js.map +1 -0
  59. package/lib/usage/call-index-host-face.js +77 -0
  60. package/lib/usage/call-index-host-face.js.map +1 -0
  61. package/lib/usage/call-index-host.js +13 -0
  62. package/lib/usage/call-index-host.js.map +1 -0
  63. package/lib/usage/call-index-query.js +669 -0
  64. package/lib/usage/call-index-query.js.map +1 -0
  65. package/lib/usage/call-index-runtime.js +376 -0
  66. package/lib/usage/call-index-runtime.js.map +1 -0
  67. package/lib/usage/call-index-scope.js +79 -0
  68. package/lib/usage/call-index-scope.js.map +1 -0
  69. package/lib/usage/call-index-wire.js +490 -0
  70. package/lib/usage/call-index-wire.js.map +1 -0
  71. package/lib/usage/session-index.js +600 -0
  72. package/lib/usage/session-index.js.map +1 -0
  73. package/package.json +144 -144
@@ -0,0 +1,669 @@
1
+ /**
2
+ * Behavioural query layer for the N22 call index (D-026, N23 host/Remote).
3
+ *
4
+ * `SessionCallIndex` (session-index.ts) is the scanner: it owns enumeration,
5
+ * live observation and freshness. This file is the read-model in front of it:
6
+ * it captures ONE immutable projection per query snapshot, serves summary and
7
+ * stable pagination from that frozen copy, and only re-reads `stamp()` (cheap)
8
+ * on later pages. It never re-reads the whole snapshot mid-pagination.
9
+ *
10
+ * Trust model: a `snapshot()` row is Host data, not a wire object. Every field
11
+ * is copied individually through an allowlist (never spread), so raw tool
12
+ * arguments, result content and error text cannot leak into a reply. `detail`
13
+ * additionally re-checks `isSessionVisible` and the authorization stamp after
14
+ * the async lookups, so a stale or escalated id cannot be read.
15
+ *
16
+ * Pagination uses opaque random UUID cursors bound to (view, kind, filter);
17
+ * there is no client-supplied offset or base64 "signature". Repeating a cursor
18
+ * yields the same page. A cursor for the wrong kind or a filter mismatch is
19
+ * rejected rather than silently jumping.
20
+ *
21
+ * @module dsh-thinking-guard/usage/call-index-query
22
+ */
23
+ import { Buffer } from 'node:buffer';
24
+ import { randomUUID } from 'node:crypto';
25
+ import { CALL_INDEX_DEFAULT_LIMIT, CALL_INDEX_DEFAULT_MAX_BYTES, CALL_INDEX_DEFAULT_MAX_VIEWS, CALL_INDEX_DEFAULT_TTL_MS, CALL_INDEX_MAX_DAY_BUCKETS, CALL_INDEX_MAX_REPLY_BYTES, CALL_INDEX_OUTCOMES, CALL_INDEX_SOURCE_KINDS, callIndexDetailRequestSchema, callIndexError, callIndexListRequestSchema, callIndexSummaryRequestSchema, callIndexWireRowSchema, callIndexWireSessionSchema, callIndexReply, } from "./call-index-wire.js";
26
+ const OUTCOME_SET = new Set(CALL_INDEX_OUTCOMES);
27
+ const SOURCE_SET = new Set(CALL_INDEX_SOURCE_KINDS);
28
+ function isRecord(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+ /** Read one field without letting a hostile getter throw into the query layer. */
32
+ function read(value, key) {
33
+ if (typeof value !== 'object' || value === null)
34
+ return undefined;
35
+ try {
36
+ return value[key];
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ const isInt = (value) => typeof value === 'number' && Number.isSafeInteger(value);
43
+ const isNonNegInt = (value) => isInt(value) && value >= 0;
44
+ const isNum = (value) => typeof value === 'number' && Number.isFinite(value);
45
+ const isStr = (value) => typeof value === 'string';
46
+ function requestObject(value) {
47
+ return value === undefined ? {} : value;
48
+ }
49
+ function projectRow(raw) {
50
+ const sessionId = read(raw, 'sessionId');
51
+ const callSeq = read(raw, 'callSeq');
52
+ const callId = read(raw, 'callId');
53
+ const toolName = read(raw, 'toolName');
54
+ const startedAt = read(raw, 'startedAt');
55
+ const outcome = read(raw, 'outcome');
56
+ const inherited = read(raw, 'inherited');
57
+ const sourceKind = read(raw, 'sourceKind');
58
+ const layer = read(raw, 'layer');
59
+ if (!isStr(sessionId) || !isNonNegInt(callSeq) || !isStr(callId) || !isStr(toolName)
60
+ || !isNum(startedAt) || !isStr(outcome) || !OUTCOME_SET.has(outcome)
61
+ || typeof inherited !== 'boolean' || !isStr(sourceKind) || !SOURCE_SET.has(sourceKind) || !isStr(layer))
62
+ return undefined;
63
+ const row = {
64
+ sessionId, callSeq, callId, toolName, startedAt,
65
+ outcome: outcome, inherited, sourceKind: sourceKind, layer,
66
+ };
67
+ const turn = read(raw, 'turn');
68
+ if (isInt(turn))
69
+ row.turn = turn;
70
+ const step = read(raw, 'step');
71
+ if (isInt(step))
72
+ row.step = step;
73
+ const endedAt = read(raw, 'endedAt');
74
+ if (isNum(endedAt))
75
+ row.endedAt = endedAt;
76
+ const resultSeq = read(raw, 'resultSeq');
77
+ if (isInt(resultSeq))
78
+ row.resultSeq = resultSeq;
79
+ const durationMs = read(raw, 'durationMs');
80
+ if (isNum(durationMs))
81
+ row.durationMs = durationMs;
82
+ const parsed = callIndexWireRowSchema.safeParse(row);
83
+ return parsed.success ? parsed.data : undefined;
84
+ }
85
+ function projectSession(raw) {
86
+ const sessionId = read(raw, 'sessionId');
87
+ const revision = read(raw, 'revision');
88
+ const cut = read(raw, 'cut');
89
+ const coverage = read(raw, 'coverage');
90
+ const stale = read(raw, 'stale');
91
+ const reasons = read(raw, 'reasons');
92
+ if (!isStr(sessionId) || !isStr(revision) || !isNonNegInt(cut)
93
+ || (coverage !== 'complete' && coverage !== 'partial') || typeof stale !== 'boolean'
94
+ || !Array.isArray(reasons) || !reasons.every(isStr))
95
+ return undefined;
96
+ const meta = { sessionId, revision, cut, coverage, stale, reasons: reasons };
97
+ const cwd = read(raw, 'cwd');
98
+ if (isStr(cwd))
99
+ meta.cwd = cwd;
100
+ const parsed = callIndexWireSessionSchema.safeParse(meta);
101
+ return parsed.success ? parsed.data : undefined;
102
+ }
103
+ function summarize(rows) {
104
+ const summary = { total: 0, success: 0, error: 0, cancelled: 0, unresolved: 0, unknown: 0, running: 0, inherited: 0 };
105
+ for (const row of rows) {
106
+ summary.total += 1;
107
+ summary[row.outcome] += 1;
108
+ if (row.inherited)
109
+ summary.inherited += 1;
110
+ }
111
+ return summary;
112
+ }
113
+ /** `YYYY-MM-DD` for an instant, shifted by the caller's local-day offset. */
114
+ function localDayLabel(epochMs, offsetMinutes) {
115
+ const shifted = epochMs + offsetMinutes * 60_000;
116
+ const date = new Date(shifted);
117
+ const time = date.getTime();
118
+ if (!Number.isFinite(time))
119
+ return undefined;
120
+ const year = date.getUTCFullYear();
121
+ const month = date.getUTCMonth() + 1;
122
+ const day = date.getUTCDate();
123
+ const pad = (value) => (value < 10 ? `0${value}` : `${value}`);
124
+ return `${year}-${pad(month)}-${pad(day)}`;
125
+ }
126
+ /**
127
+ * Overview aggregates over the filtered set, computed from the SAME frozen
128
+ * snapshot and filter as `summary`, so the two can never disagree.
129
+ *
130
+ * `bySource` always emits all five keys. `unknown` is a real answer here — the
131
+ * Host cannot prove a plugin owner (R-009) — so a caller must render "not
132
+ * provable", not "unused". Returns `undefined` when the requested span would
133
+ * exceed {@link CALL_INDEX_MAX_DAY_BUCKETS}, which the caller answers with
134
+ * `reply-too-large` rather than a silently truncated trend.
135
+ */
136
+ function aggregateRows(rows, dayOffsetMinutes) {
137
+ const bySource = { mcp: 0, 'plugin-tool': 0, native: 0, skill: 0, unknown: 0 };
138
+ const tools = new Set();
139
+ const days = new Map();
140
+ for (const row of rows) {
141
+ bySource[row.sourceKind] += 1;
142
+ tools.add(row.toolName);
143
+ const day = localDayLabel(row.startedAt, dayOffsetMinutes);
144
+ if (day === undefined)
145
+ continue;
146
+ let bucket = days.get(day);
147
+ if (bucket === undefined) {
148
+ if (days.size >= CALL_INDEX_MAX_DAY_BUCKETS)
149
+ return undefined;
150
+ bucket = { day, calls: 0, success: 0, error: 0, cancelled: 0, unresolved: 0, unknown: 0, running: 0 };
151
+ days.set(day, bucket);
152
+ }
153
+ bucket.calls += 1;
154
+ bucket[row.outcome] += 1;
155
+ }
156
+ const aggregates = { distinctTools: tools.size, bySource, dayOffsetMinutes };
157
+ if (days.size > 0)
158
+ aggregates.byDay = [...days.values()].sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
159
+ return aggregates;
160
+ }
161
+ function projectSnapshot(raw) {
162
+ if (!isRecord(raw))
163
+ return { kind: 'invalid' };
164
+ const scope = read(raw, 'scope');
165
+ const status = read(scope, 'status');
166
+ if (status === 'disabled')
167
+ return { kind: 'scope-disabled' };
168
+ if (status === 'unavailable')
169
+ return { kind: 'persistence-unavailable' };
170
+ if (status !== 'ready')
171
+ return { kind: 'invalid' };
172
+ const generation = read(raw, 'generation');
173
+ if (!isNonNegInt(generation))
174
+ return { kind: 'invalid' };
175
+ const coverageRaw = read(raw, 'coverage');
176
+ const coverage = coverageRaw === 'complete' || coverageRaw === 'partial' || coverageRaw === 'unknown' ? coverageRaw : 'unknown';
177
+ const sessionsRaw = read(raw, 'sessions');
178
+ const rowsRaw = read(raw, 'rows');
179
+ const sessions = [];
180
+ if (!Array.isArray(sessionsRaw) || !Array.isArray(rowsRaw))
181
+ return { kind: 'invalid' };
182
+ if (Array.isArray(sessionsRaw)) {
183
+ for (const entry of sessionsRaw) {
184
+ const meta = projectSession(entry);
185
+ if (!meta)
186
+ return { kind: 'invalid' };
187
+ sessions.push(meta);
188
+ }
189
+ }
190
+ const rows = [];
191
+ if (Array.isArray(rowsRaw)) {
192
+ for (const entry of rowsRaw) {
193
+ const row = projectRow(entry);
194
+ if (!row)
195
+ return { kind: 'invalid' };
196
+ rows.push(row);
197
+ }
198
+ }
199
+ const cwdBySession = new Map();
200
+ for (const session of sessions)
201
+ cwdBySession.set(session.sessionId, session.cwd);
202
+ const semantic = read(raw, 'semanticCoverage');
203
+ const semanticCoverage = { source: read(semantic, 'source') === 'verified' ? 'verified' : 'unknown', ptc: read(semantic, 'ptc') === 'verified' ? 'verified' : 'unknown', skillExecution: read(semantic, 'skillExecution') === 'verified' ? 'verified' : 'unavailable', denied: read(semantic, 'denied') === 'verified' ? 'verified' : 'unavailable' };
204
+ const data = { snapshotId: randomUUID(), generation, coverage, sessions, rows, totals: summarize(rows), semanticCoverage, cwdBySession };
205
+ const asOf = read(raw, 'asOf');
206
+ if (isNum(asOf))
207
+ data.asOf = asOf;
208
+ return { kind: 'ok', data };
209
+ }
210
+ function normalizeQuery(value) {
211
+ const record = isRecord(value) ? value : {};
212
+ const query = { includeInherited: record.includeInherited === true };
213
+ if (isStr(record.sessionId))
214
+ query.sessionId = record.sessionId;
215
+ if (isStr(record.toolName) && record.toolName.length > 0)
216
+ query.toolName = record.toolName.toLowerCase();
217
+ if (isStr(record.cwd))
218
+ query.cwd = record.cwd;
219
+ if (isStr(record.outcome))
220
+ query.outcome = record.outcome;
221
+ if (isStr(record.sourceKind))
222
+ query.sourceKind = record.sourceKind;
223
+ if (isNum(record.fromMs))
224
+ query.fromMs = record.fromMs;
225
+ if (isNum(record.toMs))
226
+ query.toMs = record.toMs;
227
+ return query;
228
+ }
229
+ function filterKey(query) {
230
+ return JSON.stringify({
231
+ sessionId: query.sessionId ?? null,
232
+ toolName: query.toolName ?? null,
233
+ cwd: query.cwd ?? null,
234
+ outcome: query.outcome ?? null,
235
+ sourceKind: query.sourceKind ?? null,
236
+ fromMs: query.fromMs ?? null,
237
+ toMs: query.toMs ?? null,
238
+ includeInherited: query.includeInherited,
239
+ });
240
+ }
241
+ function matches(row, query, view) {
242
+ if (!query.includeInherited && row.inherited)
243
+ return false;
244
+ if (query.sessionId !== undefined && row.sessionId !== query.sessionId)
245
+ return false;
246
+ if (query.toolName !== undefined && query.toolName.length > 0
247
+ && !row.toolName.toLowerCase().includes(query.toolName.toLowerCase()))
248
+ return false;
249
+ if (query.outcome !== undefined && row.outcome !== query.outcome)
250
+ return false;
251
+ if (query.sourceKind !== undefined && row.sourceKind !== query.sourceKind)
252
+ return false;
253
+ if (query.cwd !== undefined && view.cwdBySession.get(row.sessionId) !== query.cwd)
254
+ return false;
255
+ if (query.fromMs !== undefined && row.startedAt < query.fromMs)
256
+ return false;
257
+ if (query.toMs !== undefined && row.startedAt >= query.toMs)
258
+ return false;
259
+ return true;
260
+ }
261
+ /** Stable desc-by-start sort, then sessionId, then callSeq. */
262
+ function sortRows(rows) {
263
+ return [...rows].sort((a, b) => b.startedAt - a.startedAt
264
+ || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0)
265
+ || a.callSeq - b.callSeq);
266
+ }
267
+ /**
268
+ * Immutable, bounded query views behind the `callIndex` namespace.
269
+ *
270
+ * One instance is owned by the Host runtime. Views are frozen captures of a
271
+ * single `snapshot()`; cursors point at offsets inside one view.
272
+ */
273
+ export class CallIndexQueries {
274
+ source;
275
+ now;
276
+ ttlMs;
277
+ maxViews;
278
+ maxBytes;
279
+ maxReplyBytes;
280
+ views = new Map();
281
+ cursors = new Map();
282
+ cursorKeys = new Map();
283
+ totalBytes = 0;
284
+ constructor(source, options = {}) {
285
+ this.source = source;
286
+ this.now = options.now ?? Date.now;
287
+ this.ttlMs = options.ttlMs ?? CALL_INDEX_DEFAULT_TTL_MS;
288
+ this.maxViews = options.maxViews ?? CALL_INDEX_DEFAULT_MAX_VIEWS;
289
+ this.maxBytes = options.maxBytes ?? CALL_INDEX_DEFAULT_MAX_BYTES;
290
+ this.maxReplyBytes = Math.min(options.maxReplyBytes ?? CALL_INDEX_MAX_REPLY_BYTES, CALL_INDEX_MAX_REPLY_BYTES);
291
+ for (const value of [this.ttlMs, this.maxViews, this.maxBytes, this.maxReplyBytes])
292
+ if (!Number.isSafeInteger(value) || value < 1)
293
+ throw Error('invalid call-index query limit');
294
+ }
295
+ /**
296
+ * Wire envelope shared by every page.
297
+ *
298
+ * `totalExact` means "this number is the whole truth for the filter". The two
299
+ * kinds of provenance filter are NOT symmetric, and the asymmetry decides the
300
+ * flag:
301
+ *
302
+ * - `sourceKind: 'mcp' | 'native' | 'plugin-tool' | 'skill'` — the predicate
303
+ * means "the real source IS mcp", but the stored label only means "the
304
+ * catalog resolved it to mcp". A call from a since-removed plugin is
305
+ * labelled `unknown` although it really was MCP, so this count is a LOWER
306
+ * BOUND and must not claim exact.
307
+ * - `sourceKind: 'unknown'` — the predicate is the label itself. Every row
308
+ * carries exactly one label (`buildRow` always writes it, and
309
+ * `projectRow` drops a row with an invalid one), so "rows matching this
310
+ * filter" equals "rows labelled unknown" by definition: nothing can be
311
+ * missed and nothing counted twice. That exactness is definitional, not
312
+ * evidential, which is why it survives.
313
+ *
314
+ * What `unknown` must NOT be read as is "calls whose real source is unknown":
315
+ * it also holds calls whose source is known but whose tool left the catalog.
316
+ * It is a superset of the truly-unresolved, so the UI has to say
317
+ * "provenance unresolved", never "未知来源的真实总数".
318
+ */
319
+ meta(view, filter) {
320
+ const provenanceFilterExact = filter.sourceKind === undefined || filter.sourceKind === 'unknown';
321
+ return { corpus: 'persisted-plus-observed-live', snapshotId: view.snapshotId, generation: view.generation, coverage: view.coverage,
322
+ totalExact: view.coverage === 'complete' && provenanceFilterExact,
323
+ semanticCoverage: { ...view.semanticCoverage } };
324
+ }
325
+ bounded(reply) {
326
+ try {
327
+ const projected = callIndexReply(reply);
328
+ return Buffer.byteLength(JSON.stringify(projected), 'utf8') <= this.maxReplyBytes ? projected : callIndexError('reply-too-large');
329
+ }
330
+ catch {
331
+ return callIndexError('internal-error');
332
+ }
333
+ }
334
+ /** Drop every cached view and cursor (explicit rescan / disable / dispose). */
335
+ invalidate() {
336
+ this.views.clear();
337
+ this.cursors.clear();
338
+ this.cursorKeys.clear();
339
+ this.totalBytes = 0;
340
+ }
341
+ estimateBytes(data) {
342
+ return Buffer.byteLength(JSON.stringify({ generation: data.generation, coverage: data.coverage, sessions: data.sessions, rows: data.rows }), 'utf8');
343
+ }
344
+ dropView(id) {
345
+ const view = this.views.get(id);
346
+ if (view === undefined)
347
+ return;
348
+ this.views.delete(id);
349
+ this.totalBytes = Math.max(0, this.totalBytes - view.bytes);
350
+ for (const [cursorId, cursor] of this.cursors) {
351
+ if (cursor.viewId !== id)
352
+ continue;
353
+ this.cursors.delete(cursorId);
354
+ this.cursorKeys.delete(this.cursorKey(id, cursor.kind, cursor.filterKey, cursor.offset));
355
+ }
356
+ }
357
+ evictExpired() {
358
+ const now = this.now();
359
+ for (const [id, view] of this.views)
360
+ if (now - view.capturedAt > this.ttlMs)
361
+ this.dropView(id);
362
+ }
363
+ evictOldest() {
364
+ let oldest;
365
+ for (const view of this.views.values())
366
+ if (oldest === undefined || view.capturedAt < oldest.capturedAt)
367
+ oldest = view;
368
+ if (oldest !== undefined)
369
+ this.dropView(oldest.snapshotId);
370
+ }
371
+ capture(filter) {
372
+ let projection;
373
+ let before;
374
+ try {
375
+ before = this.source.stamp();
376
+ projection = projectSnapshot(this.source.snapshot());
377
+ }
378
+ catch {
379
+ return { kind: 'error', reason: 'internal-error' };
380
+ }
381
+ if (projection.kind === 'scope-disabled')
382
+ return { kind: 'error', reason: 'scope-disabled' };
383
+ if (projection.kind === 'persistence-unavailable')
384
+ return { kind: 'error', reason: 'persistence-unavailable' };
385
+ if (projection.kind === 'invalid')
386
+ return { kind: 'error', reason: 'internal-error' };
387
+ const bytes = this.estimateBytes(projection.data) + 2 * Buffer.byteLength(JSON.stringify(filter), 'utf8') + 256;
388
+ if (bytes > this.maxBytes)
389
+ return { kind: 'error', reason: 'query-capacity' };
390
+ let stamp;
391
+ try {
392
+ stamp = this.source.stamp();
393
+ }
394
+ catch {
395
+ return { kind: 'error', reason: 'internal-error' };
396
+ }
397
+ if (before !== stamp)
398
+ return { kind: 'error', reason: 'cursor-stale' };
399
+ this.evictExpired();
400
+ while ((this.views.size >= this.maxViews || this.totalBytes + bytes > this.maxBytes) && this.views.size > 0)
401
+ this.evictOldest();
402
+ const view = { ...projection.data, stamp, filter, filterKey: filterKey(filter), capturedAt: this.now(), bytes };
403
+ this.views.set(view.snapshotId, view);
404
+ this.totalBytes += bytes;
405
+ return { kind: 'ok', view };
406
+ }
407
+ resolveView(snapshotId, filter) {
408
+ this.evictExpired();
409
+ if (snapshotId === undefined)
410
+ return this.capture(filter ?? normalizeQuery(undefined));
411
+ const view = this.views.get(snapshotId);
412
+ if (view === undefined)
413
+ return { kind: 'error', reason: 'cursor-stale' };
414
+ if (filter !== undefined && filterKey(filter) !== view.filterKey)
415
+ return { kind: 'error', reason: 'invalid-request' };
416
+ let stamp;
417
+ try {
418
+ stamp = this.source.stamp();
419
+ }
420
+ catch {
421
+ return { kind: 'error', reason: 'internal-error' };
422
+ }
423
+ if (stamp !== view.stamp) {
424
+ this.dropView(snapshotId);
425
+ return { kind: 'error', reason: 'cursor-stale' };
426
+ }
427
+ return { kind: 'ok', view };
428
+ }
429
+ hasUpdates(view) {
430
+ if (this.source.generation === undefined)
431
+ return false;
432
+ try {
433
+ const generation = this.source.generation();
434
+ return isInt(generation) && generation !== view.generation;
435
+ }
436
+ catch {
437
+ return false;
438
+ }
439
+ }
440
+ cursorKey(viewId, kind, _key, offset) {
441
+ // The filter is already bound to the view; avoid copying a long filter per cursor.
442
+ return `${viewId}\u0000${kind}\u0000${offset}`;
443
+ }
444
+ /** Reuse the cursor id for an identical (view, kind, filter, offset) position. */
445
+ newCursor(view, kind, filter, offset) {
446
+ const key = this.cursorKey(view.snapshotId, kind, filterKey(filter), offset);
447
+ const existing = this.cursorKeys.get(key);
448
+ if (existing !== undefined && this.cursors.has(existing))
449
+ return existing;
450
+ while (this.cursors.size >= 256) {
451
+ const oldest = this.cursors.keys().next().value;
452
+ if (oldest === undefined)
453
+ break;
454
+ const entry = this.cursors.get(oldest);
455
+ this.cursors.delete(oldest);
456
+ this.cursorKeys.delete(this.cursorKey(entry.viewId, entry.kind, entry.filterKey, entry.offset));
457
+ }
458
+ const id = randomUUID();
459
+ this.cursors.set(id, { viewId: view.snapshotId, kind, filter, filterKey: view.filterKey, offset });
460
+ this.cursorKeys.set(key, id);
461
+ return id;
462
+ }
463
+ fitCount(count, encodeBytes) {
464
+ if (count <= 0)
465
+ return 0;
466
+ if (encodeBytes(count) <= this.maxReplyBytes)
467
+ return count;
468
+ let low = 1;
469
+ let high = count;
470
+ let best = 0;
471
+ while (low <= high) {
472
+ const mid = (low + high) >> 1;
473
+ if (encodeBytes(mid) <= this.maxReplyBytes) {
474
+ best = mid;
475
+ low = mid + 1;
476
+ }
477
+ else {
478
+ high = mid - 1;
479
+ }
480
+ }
481
+ return best;
482
+ }
483
+ listContext(request, kind) {
484
+ this.evictExpired();
485
+ let cursorEntry;
486
+ if (request.cursor !== undefined) {
487
+ cursorEntry = this.cursors.get(request.cursor);
488
+ if (cursorEntry === undefined || cursorEntry.kind !== kind)
489
+ return { ok: false, reply: callIndexError('cursor-stale') };
490
+ if (request.snapshotId !== undefined && request.snapshotId !== cursorEntry.viewId) {
491
+ return { ok: false, reply: callIndexError('invalid-request') };
492
+ }
493
+ }
494
+ const resolved = this.resolveView(request.snapshotId ?? cursorEntry?.viewId, request.query === undefined ? undefined : normalizeQuery(request.query));
495
+ if (resolved.kind === 'error')
496
+ return { ok: false, reply: callIndexError(resolved.reason) };
497
+ const view = resolved.view;
498
+ if (cursorEntry !== undefined && cursorEntry.viewId !== view.snapshotId) {
499
+ return { ok: false, reply: callIndexError('cursor-stale') };
500
+ }
501
+ if (cursorEntry !== undefined) {
502
+ if (request.query !== undefined && filterKey(normalizeQuery(request.query)) !== cursorEntry.filterKey) {
503
+ return { ok: false, reply: callIndexError('invalid-request') };
504
+ }
505
+ return { ok: true, view, filter: cursorEntry.filter, offset: cursorEntry.offset };
506
+ }
507
+ return { ok: true, view, filter: view.filter, offset: 0 };
508
+ }
509
+ sessionsFor(view, filter) {
510
+ const callLevel = filter.toolName !== undefined || filter.outcome !== undefined
511
+ || filter.sourceKind !== undefined || filter.fromMs !== undefined || filter.toMs !== undefined;
512
+ const out = [];
513
+ for (const session of view.sessions) {
514
+ if (filter.sessionId !== undefined && session.sessionId !== filter.sessionId)
515
+ continue;
516
+ if (filter.cwd !== undefined && session.cwd !== filter.cwd)
517
+ continue;
518
+ if (callLevel && !view.rows.some(row => row.sessionId === session.sessionId && matches(row, filter, view)))
519
+ continue;
520
+ out.push(session);
521
+ }
522
+ return out;
523
+ }
524
+ /** Summarise the whole filtered set (not the current page). */
525
+ getSummary(request) {
526
+ const parsed = callIndexSummaryRequestSchema.safeParse(requestObject(request));
527
+ if (!parsed.success)
528
+ return callIndexError('invalid-request');
529
+ const resolved = this.resolveView(parsed.data.snapshotId, parsed.data.query === undefined ? undefined : normalizeQuery(parsed.data.query));
530
+ if (resolved.kind === 'error')
531
+ return callIndexError(resolved.reason);
532
+ const view = resolved.view;
533
+ const filter = view.filter;
534
+ const filtered = view.rows.filter(row => matches(row, filter, view));
535
+ const dayOffsetMinutes = parsed.data.dayOffsetMinutes ?? 0;
536
+ const aggregates = aggregateRows(filtered, dayOffsetMinutes);
537
+ if (aggregates === undefined)
538
+ return callIndexError('reply-too-large');
539
+ const data = {
540
+ ...this.meta(view, filter),
541
+ summary: summarize(filtered),
542
+ total: filtered.length,
543
+ hasUpdates: this.hasUpdates(view),
544
+ aggregates,
545
+ };
546
+ if (view.asOf !== undefined)
547
+ data.asOf = view.asOf;
548
+ return this.bounded({ schemaVersion: 1, ok: true, kind: 'summary', data });
549
+ }
550
+ /** One page of Session metadata; zero-call Sessions are included when unfiltered. */
551
+ listSessions(request) {
552
+ const parsed = callIndexListRequestSchema.safeParse(requestObject(request));
553
+ if (!parsed.success)
554
+ return callIndexError('invalid-request');
555
+ const context = this.listContext(parsed.data, 'sessions');
556
+ if (!context.ok)
557
+ return context.reply;
558
+ const { view, filter, offset } = context;
559
+ const ordered = this.sessionsFor(view, filter).sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
560
+ if (offset > ordered.length)
561
+ return callIndexError('cursor-stale');
562
+ const limit = parsed.data.limit ?? CALL_INDEX_DEFAULT_LIMIT;
563
+ const summary = summarize(view.rows.filter(row => matches(row, filter, view)));
564
+ const placeholder = 'x'.repeat(36);
565
+ const make = (count, nextCursor) => {
566
+ const data = {
567
+ ...this.meta(view, filter),
568
+ summary,
569
+ total: ordered.length,
570
+ items: ordered.slice(offset, offset + count),
571
+ hasUpdates: this.hasUpdates(view),
572
+ };
573
+ if (view.asOf !== undefined)
574
+ data.asOf = view.asOf;
575
+ if (nextCursor !== undefined)
576
+ data.nextCursor = nextCursor;
577
+ return { schemaVersion: 1, ok: true, kind: 'sessions', data };
578
+ };
579
+ const room = Math.min(limit, ordered.length - offset);
580
+ const count = this.fitCount(room, (n) => Buffer.byteLength(JSON.stringify(make(n, offset + n < ordered.length ? placeholder : undefined)), 'utf8'));
581
+ if (room > 0 && count === 0)
582
+ return callIndexError('reply-too-large');
583
+ const nextCursor = offset + count < ordered.length ? this.newCursor(view, 'sessions', filter, offset + count) : undefined;
584
+ return this.bounded(make(count, nextCursor));
585
+ }
586
+ /** One page of call rows in stable `(startedAt DESC, sessionId, callSeq)` order. */
587
+ listCalls(request) {
588
+ const parsed = callIndexListRequestSchema.safeParse(requestObject(request));
589
+ if (!parsed.success)
590
+ return callIndexError('invalid-request');
591
+ const context = this.listContext(parsed.data, 'calls');
592
+ if (!context.ok)
593
+ return context.reply;
594
+ const { view, filter, offset } = context;
595
+ const filtered = sortRows(view.rows.filter(row => matches(row, filter, view)));
596
+ if (offset > filtered.length)
597
+ return callIndexError('cursor-stale');
598
+ const limit = parsed.data.limit ?? CALL_INDEX_DEFAULT_LIMIT;
599
+ const summary = summarize(filtered);
600
+ const placeholder = 'x'.repeat(36);
601
+ const make = (count, nextCursor) => {
602
+ const data = {
603
+ ...this.meta(view, filter),
604
+ summary,
605
+ total: filtered.length,
606
+ items: filtered.slice(offset, offset + count),
607
+ hasUpdates: this.hasUpdates(view),
608
+ };
609
+ if (view.asOf !== undefined)
610
+ data.asOf = view.asOf;
611
+ if (nextCursor !== undefined)
612
+ data.nextCursor = nextCursor;
613
+ return { schemaVersion: 1, ok: true, kind: 'calls', data };
614
+ };
615
+ const room = Math.min(limit, filtered.length - offset);
616
+ const count = this.fitCount(room, (n) => Buffer.byteLength(JSON.stringify(make(n, offset + n < filtered.length ? placeholder : undefined)), 'utf8'));
617
+ if (room > 0 && count === 0)
618
+ return callIndexError('reply-too-large');
619
+ const nextCursor = offset + count < filtered.length ? this.newCursor(view, 'calls', filter, offset + count) : undefined;
620
+ return this.bounded(make(count, nextCursor));
621
+ }
622
+ /**
623
+ * Safely read one call's metadata. The row must be in the frozen view, then
624
+ * the Session must still be visible, then the authorization stamp must still
625
+ * match — a detail read never trusts a browser-supplied id by itself.
626
+ */
627
+ async getCallDetail(request) {
628
+ const parsed = callIndexDetailRequestSchema.safeParse(requestObject(request));
629
+ if (!parsed.success)
630
+ return callIndexError('invalid-request');
631
+ const resolved = this.resolveView(parsed.data.snapshotId);
632
+ if (resolved.kind === 'error')
633
+ return callIndexError(resolved.reason);
634
+ const view = resolved.view;
635
+ const row = view.rows.find(item => item.sessionId === parsed.data.sessionId && item.callSeq === parsed.data.callSeq && matches(item, view.filter, view));
636
+ if (row === undefined)
637
+ return callIndexError('not-visible');
638
+ let visible = false;
639
+ try {
640
+ visible = await this.source.isSessionVisible(parsed.data.sessionId);
641
+ }
642
+ catch {
643
+ visible = false;
644
+ }
645
+ if (!visible)
646
+ return callIndexError('not-visible');
647
+ let stamp;
648
+ try {
649
+ stamp = this.source.stamp();
650
+ }
651
+ catch {
652
+ return callIndexError('internal-error');
653
+ }
654
+ if (stamp !== view.stamp || this.views.get(view.snapshotId) !== view)
655
+ return callIndexError('cursor-stale');
656
+ if (this.now() - view.capturedAt > this.ttlMs) {
657
+ this.dropView(view.snapshotId);
658
+ return callIndexError('cursor-stale');
659
+ }
660
+ const data = {
661
+ ...this.meta(view, view.filter),
662
+ detail: row,
663
+ };
664
+ if (view.asOf !== undefined)
665
+ data.asOf = view.asOf;
666
+ return this.bounded({ schemaVersion: 1, ok: true, kind: 'detail', data });
667
+ }
668
+ }
669
+ //# sourceMappingURL=call-index-query.js.map