sentinelayer-cli 0.30.6 → 0.30.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.30.6",
3
+ "version": "0.30.8",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -5867,10 +5867,11 @@ export function registerSessionCommand(program) {
5867
5867
  ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
5868
5868
  : [];
5869
5869
  const includeControlEvents = Boolean(options.includeControlEvents);
5870
+ const dedupedEvents = dedupeSessionEvents(events);
5870
5871
  const transcriptBaseEvents = includeControlEvents
5871
- ? events
5872
- : filterSessionMaterialEvents(events);
5873
- const hiddenControlEventCount = events.length - transcriptBaseEvents.length;
5872
+ ? dedupedEvents
5873
+ : filterSessionMaterialEvents(dedupedEvents);
5874
+ const hiddenControlEventCount = dedupedEvents.length - transcriptBaseEvents.length;
5874
5875
  const exportEvents = mergeSessionActionEvents(transcriptBaseEvents, actionEvents);
5875
5876
  const stats = computeTranscriptStats({
5876
5877
  sessionMeta: sessionPayload,
@@ -6021,10 +6022,11 @@ export function registerSessionCommand(program) {
6021
6022
  ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
6022
6023
  : [];
6023
6024
  const includeControlEvents = Boolean(options.includeControlEvents);
6025
+ const dedupedEvents = dedupeSessionEvents(events);
6024
6026
  const transcriptBaseEvents = includeControlEvents
6025
- ? events
6026
- : filterSessionMaterialEvents(events);
6027
- const hiddenControlEventCount = events.length - transcriptBaseEvents.length;
6027
+ ? dedupedEvents
6028
+ : filterSessionMaterialEvents(dedupedEvents);
6029
+ const hiddenControlEventCount = dedupedEvents.length - transcriptBaseEvents.length;
6028
6030
  const transcriptEvents = mergeSessionActionEvents(transcriptBaseEvents, actionEvents);
6029
6031
 
6030
6032
  // Pull GitHub/Google avatar + display name from the active auth
@@ -49,7 +49,12 @@ const INVESTOR_DD_PERSONAS = Object.freeze([...FULL_DEPTH_PERSONAS]);
49
49
  * Walk the target repo and return a list of relative POSIX file paths.
50
50
  * Skips common noise directories.
51
51
  */
52
- async function walkRepoFiles(rootPath) {
52
+ function isPathInsideOrSame(candidate, root) {
53
+ const rel = path.relative(root, candidate);
54
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
55
+ }
56
+
57
+ async function walkRepoFiles(rootPath, { excludeRoots = [] } = {}) {
53
58
  const SKIP_DIRS = new Set([
54
59
  "node_modules",
55
60
  ".git",
@@ -84,7 +89,11 @@ async function walkRepoFiles(rootPath) {
84
89
  ]);
85
90
 
86
91
  const results = [];
92
+ const excludedRoots = excludeRoots.map((item) => path.resolve(item));
87
93
  async function walk(absDir, relDir) {
94
+ if (excludedRoots.some((excludedRoot) => isPathInsideOrSame(absDir, excludedRoot))) {
95
+ return;
96
+ }
88
97
  let entries;
89
98
  try {
90
99
  entries = await fsp.readdir(absDir, { withFileTypes: true });
@@ -99,6 +108,7 @@ async function walkRepoFiles(rootPath) {
99
108
  const absPath = path.join(absDir, entry.name);
100
109
  if (entry.isDirectory()) {
101
110
  if (SKIP_DIRS.has(entry.name)) continue;
111
+ if (excludedRoots.some((excludedRoot) => isPathInsideOrSame(absPath, excludedRoot))) continue;
102
112
  await walk(absPath, relPath);
103
113
  } else if (entry.isFile()) {
104
114
  const ext = path.extname(entry.name).toLowerCase();
@@ -111,6 +121,45 @@ async function walkRepoFiles(rootPath) {
111
121
  return results;
112
122
  }
113
123
 
124
+ function countSourceLoc(text) {
125
+ return String(text || "")
126
+ .split(/\r\n|\n|\r/)
127
+ .filter((line) => line.trim().length > 0).length;
128
+ }
129
+
130
+ async function collectFileMetrics(rootPath, files) {
131
+ const metrics = {};
132
+ for (const file of files) {
133
+ const rel = String(file || "").trim();
134
+ if (!rel) continue;
135
+ const abs = path.resolve(rootPath, rel);
136
+ try {
137
+ const stat = await fsp.stat(abs);
138
+ if (!stat.isFile()) continue;
139
+ let loc = 0;
140
+ let truncated = false;
141
+ if (stat.size <= 2_000_000) {
142
+ const text = await fsp.readFile(abs, "utf-8");
143
+ loc = countSourceLoc(text);
144
+ } else {
145
+ truncated = true;
146
+ }
147
+ metrics[rel] = {
148
+ bytes: stat.size,
149
+ loc,
150
+ truncated,
151
+ };
152
+ } catch {
153
+ metrics[rel] = {
154
+ bytes: 0,
155
+ loc: 0,
156
+ missing: true,
157
+ };
158
+ }
159
+ }
160
+ return metrics;
161
+ }
162
+
114
163
  async function writeJson(filePath, obj) {
115
164
  await fsp.mkdir(path.dirname(filePath), { recursive: true });
116
165
  await fsp.writeFile(filePath, JSON.stringify(obj, null, 2), "utf-8");
@@ -317,11 +366,18 @@ export async function runInvestorDd({
317
366
  const startTime = Date.now();
318
367
  emit({ type: "investor_dd_start", rootPath, personas });
319
368
 
320
- const files = await walkRepoFiles(rootPath);
369
+ const files = await walkRepoFiles(rootPath, { excludeRoots: [runRoot] });
321
370
  emit({ type: "investor_dd_files_discovered", totalFiles: files.length });
322
371
 
323
372
  const routing = routeFilesToPersonas({ files, personas });
324
373
  const routingSummary = summarizeRouting(routing);
374
+ const fileMetrics = await collectFileMetrics(rootPath, files);
375
+ await writeJson(path.join(artifactBase, "file-metrics.json"), {
376
+ runId,
377
+ rootPath,
378
+ totalFiles: files.length,
379
+ metrics: fileMetrics,
380
+ });
325
381
  await writeJson(path.join(artifactBase, "plan.json"), {
326
382
  runId,
327
383
  rootPath,
@@ -520,6 +576,7 @@ export async function runInvestorDd({
520
576
  notification,
521
577
  artifactFiles: artifactFilesBeforeManifest,
522
578
  budgetState,
579
+ fileMetrics,
523
580
  });
524
581
  summary.ddProgress = {
525
582
  version: ddProgress.version,
@@ -531,6 +588,7 @@ export async function runInvestorDd({
531
588
  blockingGapCount: ddProgress.summary.blockingGapCount,
532
589
  artifact: "progress.json",
533
590
  };
591
+ summary.usageTelemetry = ddProgress.usageTelemetry;
534
592
  runResult.progress = ddProgress;
535
593
  await writeJson(path.join(artifactBase, "progress.json"), ddProgress);
536
594
  await writeJson(path.join(artifactBase, "summary.json"), summary);
@@ -180,6 +180,7 @@ export async function runPersonaAcrossFiles({
180
180
  onEvent = () => {},
181
181
  } = {}) {
182
182
  if (!Array.isArray(files)) throw new TypeError("runPersonaAcrossFiles requires files array");
183
+ const startedAtMs = Date.now();
183
184
  const safeBudget = budget || createBudgetState();
184
185
  const perFile = [];
185
186
  const allFindings = [];
@@ -228,6 +229,7 @@ export async function runPersonaAcrossFiles({
228
229
  visited,
229
230
  skipped,
230
231
  terminationReason,
232
+ durationMs: Math.max(0, Date.now() - startedAtMs),
231
233
  };
232
234
  }
233
235
 
@@ -268,6 +270,7 @@ export async function runPersonaAcrossFiles({
268
270
  visited,
269
271
  skipped,
270
272
  terminationReason,
273
+ durationMs: Math.max(0, Date.now() - startedAtMs),
271
274
  };
272
275
  }
273
276
 
@@ -29,13 +29,26 @@ function compactBudgetState(budgetState) {
29
29
  };
30
30
  }
31
31
 
32
+ function nonNegativeNumber(value, fallback = 0) {
33
+ const parsed = Number(value);
34
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed;
35
+ return fallback;
36
+ }
37
+
38
+ function roundedMoney(value) {
39
+ const parsed = Number(value);
40
+ if (!Number.isFinite(parsed)) return null;
41
+ return Math.round(parsed * 1_000_000) / 1_000_000;
42
+ }
43
+
32
44
  function usageLedgerKey(entry) {
33
45
  return [
34
46
  entry?.ledgerEntry?.ledgerEntryId,
35
- entry?.action,
47
+ entry?.action || entry?.ledgerEntry?.action,
48
+ entry?.agentId || entry?.ledgerEntry?.agentId,
36
49
  entry?.ledgerEntry?.idempotencyKey,
37
- entry?.inputTokens,
38
- entry?.outputTokens,
50
+ entry?.inputTokens ?? entry?.ledgerEntry?.inputTokens,
51
+ entry?.outputTokens ?? entry?.ledgerEntry?.outputTokens,
39
52
  ].map((value) => String(value || "")).join(":");
40
53
  }
41
54
 
@@ -56,6 +69,205 @@ function collectSessionUsageLedgerEntries({ budgetState = null, devTestBotPhase
56
69
  return entries;
57
70
  }
58
71
 
72
+ function ledgerValue(entry, key, fallback = 0) {
73
+ if (entry && Object.prototype.hasOwnProperty.call(entry, key)) {
74
+ return nonNegativeNumber(entry[key], fallback);
75
+ }
76
+ if (entry?.ledgerEntry && Object.prototype.hasOwnProperty.call(entry.ledgerEntry, key)) {
77
+ return nonNegativeNumber(entry.ledgerEntry[key], fallback);
78
+ }
79
+ return fallback;
80
+ }
81
+
82
+ function ledgerString(entry, key) {
83
+ return String(entry?.[key] || entry?.ledgerEntry?.[key] || "").trim();
84
+ }
85
+
86
+ function personaFromAgentId(agentId) {
87
+ const normalized = String(agentId || "").trim();
88
+ if (!normalized.startsWith("investor-dd-")) return "";
89
+ return normalized.slice("investor-dd-".length);
90
+ }
91
+
92
+ function sumFileMetrics(files, fileMetrics = {}) {
93
+ const seen = new Set();
94
+ let filesWithMetrics = 0;
95
+ let locScanned = 0;
96
+ let bytesScanned = 0;
97
+ let truncatedFiles = 0;
98
+ let missingFiles = 0;
99
+ for (const file of files || []) {
100
+ const rel = String(file || "").trim();
101
+ if (!rel || seen.has(rel)) continue;
102
+ seen.add(rel);
103
+ const metrics = fileMetrics[rel];
104
+ if (!metrics) continue;
105
+ filesWithMetrics += 1;
106
+ locScanned += nonNegativeNumber(metrics.loc, 0);
107
+ bytesScanned += nonNegativeNumber(metrics.bytes, 0);
108
+ if (metrics.truncated) truncatedFiles += 1;
109
+ if (metrics.missing) missingFiles += 1;
110
+ }
111
+ return {
112
+ filesWithMetrics,
113
+ locScanned,
114
+ bytesScanned,
115
+ truncatedFiles,
116
+ missingFiles,
117
+ };
118
+ }
119
+
120
+ function countToolInvocations(record = {}) {
121
+ return (Array.isArray(record.perFile) ? record.perFile : []).reduce(
122
+ (count, item) => count + (Array.isArray(item.toolInvocations) ? item.toolInvocations.length : 0),
123
+ 0,
124
+ );
125
+ }
126
+
127
+ function createUsageRecord({ personaId = "", agentId = "" } = {}) {
128
+ const id = String(personaId || "").trim();
129
+ const agent = String(agentId || (id ? `investor-dd-${id}` : "")).trim();
130
+ return {
131
+ personaId: id || null,
132
+ agentId: agent,
133
+ routedFiles: 0,
134
+ visitedFiles: 0,
135
+ skippedFiles: 0,
136
+ filesWithMetrics: 0,
137
+ locScanned: 0,
138
+ bytesScanned: 0,
139
+ truncatedFiles: 0,
140
+ missingFiles: 0,
141
+ durationMs: 0,
142
+ toolCalls: 0,
143
+ findingCount: 0,
144
+ inputTokens: 0,
145
+ outputTokens: 0,
146
+ totalTokens: 0,
147
+ providerCostUsd: 0,
148
+ customerCostUsd: null,
149
+ marginUsd: null,
150
+ ledgerEntries: 0,
151
+ actions: [],
152
+ };
153
+ }
154
+
155
+ function buildUsageTelemetry({
156
+ activePersonas = [],
157
+ routing = {},
158
+ byPersona = {},
159
+ fileMetrics = {},
160
+ sessionUsageLedgerEntries = [],
161
+ } = {}) {
162
+ const records = new Map();
163
+ const ensure = ({ personaId = "", agentId = "" } = {}) => {
164
+ const key = personaId || agentId;
165
+ if (!records.has(key)) {
166
+ records.set(key, createUsageRecord({ personaId, agentId }));
167
+ }
168
+ return records.get(key);
169
+ };
170
+
171
+ for (const personaId of activePersonas) {
172
+ const record = ensure({ personaId });
173
+ const routed = Array.isArray(routing?.[personaId]) ? routing[personaId] : [];
174
+ const personaRecord = byPersona?.[personaId] || {};
175
+ const visited = Array.isArray(personaRecord.visited) ? personaRecord.visited : [];
176
+ const skipped = Array.isArray(personaRecord.skipped) ? personaRecord.skipped : [];
177
+ const filesForMetrics = visited.length > 0 ? visited : routed;
178
+ const metrics = sumFileMetrics(filesForMetrics, fileMetrics);
179
+ record.routedFiles = routed.length;
180
+ record.visitedFiles = visited.length;
181
+ record.skippedFiles = skipped.length;
182
+ record.filesWithMetrics = metrics.filesWithMetrics;
183
+ record.locScanned = metrics.locScanned;
184
+ record.bytesScanned = metrics.bytesScanned;
185
+ record.truncatedFiles = metrics.truncatedFiles;
186
+ record.missingFiles = metrics.missingFiles;
187
+ record.durationMs = Math.max(0, Math.floor(nonNegativeNumber(personaRecord.durationMs, 0)));
188
+ record.toolCalls = countToolInvocations(personaRecord);
189
+ record.findingCount = Array.isArray(personaRecord.findings) ? personaRecord.findings.length : 0;
190
+ }
191
+
192
+ for (const entry of sessionUsageLedgerEntries) {
193
+ const agentId = ledgerString(entry, "agentId");
194
+ const personaId = personaFromAgentId(agentId);
195
+ const record = ensure({ personaId, agentId });
196
+ const inputTokens = ledgerValue(entry, "inputTokens", 0);
197
+ const outputTokens = ledgerValue(entry, "outputTokens", 0);
198
+ const totalTokens = ledgerValue(entry, "totalTokens", inputTokens + outputTokens);
199
+ const providerCost = roundedMoney(
200
+ entry?.ledgerEntry?.providerCostUsd ?? entry?.ledgerEntry?.costUsd ?? entry?.providerCostUsd ?? entry?.costUsd,
201
+ );
202
+ const customerCost = roundedMoney(entry?.ledgerEntry?.customerCostUsd ?? entry?.customerCostUsd);
203
+ record.inputTokens += inputTokens;
204
+ record.outputTokens += outputTokens;
205
+ record.totalTokens += totalTokens;
206
+ if (providerCost != null) {
207
+ record.providerCostUsd = roundedMoney(record.providerCostUsd + providerCost);
208
+ }
209
+ if (customerCost != null) {
210
+ record.customerCostUsd = roundedMoney((record.customerCostUsd || 0) + customerCost);
211
+ }
212
+ if (record.customerCostUsd != null && record.providerCostUsd != null) {
213
+ record.marginUsd = roundedMoney(record.customerCostUsd - record.providerCostUsd);
214
+ }
215
+ record.ledgerEntries += 1;
216
+ const action = ledgerString(entry, "action");
217
+ if (action && !record.actions.includes(action)) record.actions.push(action);
218
+ }
219
+
220
+ const perAgent = Array.from(records.values()).map((record) => ({
221
+ ...record,
222
+ providerCostUsd: roundedMoney(record.providerCostUsd) || 0,
223
+ }));
224
+ const hasCustomerCost = perAgent.some((record) => record.customerCostUsd != null);
225
+ const totals = perAgent.reduce(
226
+ (acc, record) => {
227
+ acc.routedFiles += record.routedFiles;
228
+ acc.visitedFiles += record.visitedFiles;
229
+ acc.filesWithMetrics += record.filesWithMetrics;
230
+ acc.locScanned += record.locScanned;
231
+ acc.bytesScanned += record.bytesScanned;
232
+ acc.durationMs += record.durationMs;
233
+ acc.toolCalls += record.toolCalls;
234
+ acc.findingCount += record.findingCount;
235
+ acc.inputTokens += record.inputTokens;
236
+ acc.outputTokens += record.outputTokens;
237
+ acc.totalTokens += record.totalTokens;
238
+ acc.providerCostUsd += record.providerCostUsd || 0;
239
+ acc.customerCostUsd += record.customerCostUsd || 0;
240
+ acc.ledgerEntries += record.ledgerEntries;
241
+ return acc;
242
+ },
243
+ {
244
+ routedFiles: 0,
245
+ visitedFiles: 0,
246
+ filesWithMetrics: 0,
247
+ locScanned: 0,
248
+ bytesScanned: 0,
249
+ durationMs: 0,
250
+ toolCalls: 0,
251
+ findingCount: 0,
252
+ inputTokens: 0,
253
+ outputTokens: 0,
254
+ totalTokens: 0,
255
+ providerCostUsd: 0,
256
+ customerCostUsd: 0,
257
+ ledgerEntries: 0,
258
+ },
259
+ );
260
+ totals.providerCostUsd = roundedMoney(totals.providerCostUsd) || 0;
261
+ totals.customerCostUsd = hasCustomerCost ? roundedMoney(totals.customerCostUsd) : null;
262
+ totals.marginUsd = totals.customerCostUsd != null ? roundedMoney(totals.customerCostUsd - totals.providerCostUsd) : null;
263
+
264
+ return {
265
+ schema: "investor_dd_usage_telemetry_v1",
266
+ totals,
267
+ perAgent,
268
+ };
269
+ }
270
+
59
271
  function byId(capabilities, id) {
60
272
  return capabilities.find((capability) => capability.id === id);
61
273
  }
@@ -110,6 +322,7 @@ export function buildInvestorDdProgress({
110
322
  artifactFiles = [],
111
323
  budgetState = null,
112
324
  usageLedgerEntries = [],
325
+ fileMetrics = {},
113
326
  } = {}) {
114
327
  const activePersonas = uniqueStrings(personas);
115
328
  const missingPersonas = INVESTOR_DD_EXPECTED_PERSONAS.filter(
@@ -169,13 +382,28 @@ export function buildInvestorDdProgress({
169
382
  devTestBotPhase,
170
383
  usageLedgerEntries,
171
384
  });
385
+ const usageTelemetry = buildUsageTelemetry({
386
+ activePersonas,
387
+ routing,
388
+ byPersona,
389
+ fileMetrics,
390
+ sessionUsageLedgerEntries,
391
+ });
172
392
  const hasSessionUsageLedger = sessionUsageLedgerEntries.length > 0;
393
+ const hasPerAgentTelemetry = usageTelemetry.perAgent.length > 0;
394
+ const hasLocTelemetry = usageTelemetry.totals.locScanned > 0 || usageTelemetry.totals.filesWithMetrics > 0;
395
+ const hasRuntimeTelemetry = usageTelemetry.totals.durationMs > 0;
396
+ const hasCustomerPricing = usageTelemetry.totals.customerCostUsd != null;
173
397
  addCapability(capabilities, {
174
398
  id: "usage_margin_telemetry",
175
399
  label: "Billing-grade per-agent usage, token, time, LOC, and margin telemetry",
176
- status: compactBudget || hasSessionUsageLedger ? "partial" : dryRun ? "deferred" : "not_configured",
177
- evidence: compactBudget || hasSessionUsageLedger
400
+ status: compactBudget || hasSessionUsageLedger || hasPerAgentTelemetry ? "partial" : dryRun ? "deferred" : "not_configured",
401
+ evidence: compactBudget || hasSessionUsageLedger || hasPerAgentTelemetry
178
402
  ? [
403
+ `agentUsageTelemetry=${hasPerAgentTelemetry}`,
404
+ `perAgentUsageRecords=${usageTelemetry.perAgent.length}`,
405
+ `locScanned=${usageTelemetry.totals.locScanned}`,
406
+ `durationMs=${usageTelemetry.totals.durationMs}`,
179
407
  ...(compactBudget
180
408
  ? [
181
409
  `localBudgetSpentUsd=${compactBudget.spentUsd}`,
@@ -192,11 +420,12 @@ export function buildInvestorDdProgress({
192
420
  ? ["dryRun=true"]
193
421
  : [],
194
422
  gaps: [
195
- hasSessionUsageLedger
196
- ? "only optional DD planner calls are wired to billing-grade session_usage"
197
- : "budgetState is a local run governor, not the billing-grade session_usage ledger",
198
- "summary does not include per-agent token totals",
199
- "summary does not include per-agent runtime, LOC scanned, customer price, or margin",
423
+ ...(hasSessionUsageLedger
424
+ ? ["only optional DD planner calls are wired to billing-grade session_usage"]
425
+ : ["billing-grade token/customer-cost telemetry is only available when session_usage ledger entries exist"]),
426
+ ...(hasRuntimeTelemetry || dryRun ? [] : ["per-agent runtime is unavailable because no persona execution records were produced"]),
427
+ ...(hasLocTelemetry ? [] : ["per-agent LOC is unavailable because file metrics were not collected"]),
428
+ ...(hasCustomerPricing ? [] : ["customerCostUsd and marginUsd are unavailable until customer pricing is supplied by the session_usage ledger"]),
200
429
  ],
201
430
  });
202
431
 
@@ -277,8 +506,8 @@ export function buildInvestorDdProgress({
277
506
  });
278
507
 
279
508
  const artifactRequired = dryRun
280
- ? ["plan.json", "stream.ndjson", "summary.json", "report.md", "report.html"]
281
- : ["plan.json", "stream.ndjson", "summary.json", "report.md", "report.html", "findings.json"];
509
+ ? ["file-metrics.json", "plan.json", "stream.ndjson", "summary.json", "report.md", "report.html"]
510
+ : ["file-metrics.json", "plan.json", "stream.ndjson", "summary.json", "report.md", "report.html", "findings.json"];
282
511
  const missingArtifacts = artifactRequired.filter((file) => !artifactFiles.includes(file));
283
512
  addCapability(capabilities, {
284
513
  id: "artifact_bundle",
@@ -315,6 +544,7 @@ export function buildInvestorDdProgress({
315
544
  plannedPersonaCount: INVESTOR_DD_EXPECTED_PERSONAS.length,
316
545
  missingPersonas,
317
546
  capabilities,
547
+ usageTelemetry,
318
548
  routingSummary: {
319
549
  routedPersonas: Object.keys(routing || {}).length,
320
550
  routedFiles: Object.values(routing || {}).reduce(
@@ -330,7 +560,7 @@ export function buildInvestorDdProgress({
330
560
  ? ["add the missing frontend/Jules persona to the default Investor-DD roster"]
331
561
  : ["run the full 13-persona roster before claiming sellable DD coverage"]),
332
562
  "wire Senti session id and live usage counters into Investor-DD runs",
333
- "add per-agent token/time/LOC/customer-price/margin telemetry",
563
+ "wire billing-grade token/customer-price/margin telemetry for every Investor-DD persona",
334
564
  "require live reconciliation and report-email proof for sellable DD closeout",
335
565
  ],
336
566
  };
@@ -52,9 +52,61 @@ function stableStringify(value) {
52
52
  return JSON.stringify(stableJsonValue(value));
53
53
  }
54
54
 
55
+ function messageEventCanUseRelaxedContentKey(eventKind) {
56
+ return [
57
+ "agent_response",
58
+ "human_relay",
59
+ "session_message",
60
+ "session_say",
61
+ ].includes(keyString(eventKind));
62
+ }
63
+
64
+ function eventDurabilityScore(event = {}) {
65
+ let score = 0;
66
+ if (readSessionEventSequence(event) > 0) score += 2;
67
+ if (sessionEventHasDurableCursor(event)) score += 1;
68
+ return score;
69
+ }
70
+
71
+ function mergeObjectPreferringPrimary(primaryValue, secondaryValue) {
72
+ if (
73
+ primaryValue &&
74
+ typeof primaryValue === "object" &&
75
+ !Array.isArray(primaryValue) &&
76
+ secondaryValue &&
77
+ typeof secondaryValue === "object" &&
78
+ !Array.isArray(secondaryValue)
79
+ ) {
80
+ return { ...secondaryValue, ...primaryValue };
81
+ }
82
+ return primaryValue === undefined ? secondaryValue : primaryValue;
83
+ }
84
+
85
+ function mergeDuplicateEvent(existingEvent = {}, candidateEvent = {}) {
86
+ const existingScore = eventDurabilityScore(existingEvent);
87
+ const candidateScore = eventDurabilityScore(candidateEvent);
88
+ const candidateIsPrimary = candidateScore > existingScore || (
89
+ candidateScore === existingScore &&
90
+ sessionEventUpgradesExisting(existingEvent, candidateEvent)
91
+ ) || (
92
+ candidateScore === existingScore &&
93
+ !sessionEventUpgradesExisting(candidateEvent, existingEvent)
94
+ );
95
+ const primaryEvent = candidateIsPrimary ? candidateEvent : existingEvent;
96
+ const secondaryEvent = candidateIsPrimary ? existingEvent : candidateEvent;
97
+
98
+ return {
99
+ ...secondaryEvent,
100
+ ...primaryEvent,
101
+ agent: mergeObjectPreferringPrimary(primaryEvent.agent, secondaryEvent.agent),
102
+ payload: mergeObjectPreferringPrimary(primaryEvent.payload, secondaryEvent.payload),
103
+ };
104
+ }
105
+
55
106
  export function sessionEventIdentityKeys(event = {}) {
56
107
  if (!event || typeof event !== "object") return [];
57
108
  const keys = [];
109
+ const eventKind = keyString(event.event || event.type);
58
110
  const id = keyString(event.id);
59
111
  if (id) {
60
112
  keys.push(`id:${id}`);
@@ -109,7 +161,7 @@ export function sessionEventIdentityKeys(event = {}) {
109
161
  if (message) {
110
162
  try {
111
163
  keys.push(`content:${stableStringify({
112
- event: keyString(event.event || event.type),
164
+ event: eventKind,
113
165
  agent: keyString(event.agent?.id || event.agentId || payload.agentId || payload.authorId),
114
166
  payload: {
115
167
  channel: keyString(payload.channel),
@@ -123,6 +175,22 @@ export function sessionEventIdentityKeys(event = {}) {
123
175
  } catch {
124
176
  // Best-effort duplicate suppression only.
125
177
  }
178
+ if (messageEventCanUseRelaxedContentKey(eventKind)) {
179
+ try {
180
+ keys.push(`content-relaxed:${stableStringify({
181
+ event: eventKind,
182
+ agent: keyString(event.agent?.id || event.agentId || payload.agentId || payload.authorId),
183
+ payload: {
184
+ channel: keyString(payload.channel),
185
+ message,
186
+ source: keyString(payload.source),
187
+ },
188
+ ts: timestampKey(event.ts, event.timestamp, event.at),
189
+ })}`);
190
+ } catch {
191
+ // Best-effort duplicate suppression only.
192
+ }
193
+ }
126
194
  }
127
195
  return keys;
128
196
  }
@@ -151,7 +219,7 @@ export function dedupeSessionEvents(events = []) {
151
219
  const existingIndex = existingIndexes.length > 0 ? Math.min(...existingIndexes) : -1;
152
220
 
153
221
  if (existingIndex >= 0) {
154
- deduped[existingIndex] = event;
222
+ deduped[existingIndex] = mergeDuplicateEvent(deduped[existingIndex], event);
155
223
  for (const key of keys) {
156
224
  indexByKey.set(key, existingIndex);
157
225
  }