@ouro.bot/cli 0.1.0-alpha.812 → 0.1.0-alpha.814

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.814",
6
+ "changes": [
7
+ "Preserve native session history and delivery references while keeping model context and active outputs bounded.",
8
+ "Keep precommitted ingress validation strict when loading effective projections."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.813",
13
+ "changes": [
14
+ "Unrelated logs no longer block Sanctuary containment checks; identity and authenticated audit safeguards remain intact."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.1.0-alpha.812",
6
19
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.812",
2
+ "runtimeVersion": "0.1.0-alpha.814",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.812</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.814</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -1336,14 +1336,16 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1336
1336
  contextWindowTokens: (0, config_1.getContextConfig)().maxTokens,
1337
1337
  };
1338
1338
  const canonicalBudget = (0, prompt_budget_1.applyPromptBudget)({ ...budgetOptions, messages });
1339
- if (canonicalBudget.status !== "within_budget") {
1340
- messages.splice(0, messages.length, ...canonicalBudget.messages);
1341
- }
1342
- // Preserve canonical evidence object identity while freezing the array
1343
- // and the one attempt's private control/scratch across operational retries.
1344
- const attemptMessages = rejectedAttempt.length > 0
1345
- ? [...(0, prompt_budget_1.applyPromptBudget)({ ...budgetOptions, messages: [...messages, ...rejectedAttempt, ...nextAttemptControls] }).messages]
1346
- : [...messages];
1339
+ const currentUserMessage = budgetOptions.requiredPromptEvidence?.currentUserMessage
1340
+ ?? canonicalBudget.messages.findLast((message) => message.role === "user");
1341
+ // Keep retry controls attached to the genuine request, not to canonical history.
1342
+ let attemptMessages = rejectedAttempt.length > 0
1343
+ ? [...(0, prompt_budget_1.applyPromptBudget)({
1344
+ ...budgetOptions,
1345
+ requiredPromptEvidence: budgetOptions.requiredPromptEvidence ?? (currentUserMessage ? { currentUserMessage } : undefined),
1346
+ messages: [...canonicalBudget.messages, ...rejectedAttempt, ...nextAttemptControls],
1347
+ }).messages]
1348
+ : [...canonicalBudget.messages];
1347
1349
  if (rejectedAttempt.length > 0 || canonicalBudget.status !== "within_budget")
1348
1350
  providerRuntime.resetTurnState(attemptMessages);
1349
1351
  const turnCallbackBufferRef = { current: null };
@@ -1384,20 +1386,22 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1384
1386
  throw error;
1385
1387
  if (isContextOverflow(error) && !overflowRetried) {
1386
1388
  overflowRetried = true;
1387
- const overflowMessages = messages.map((message) => message.role === "assistant" ? { ...message } : message);
1389
+ const protectedStart = currentUserMessage ? Math.max(0, attemptMessages.indexOf(currentUserMessage)) : 0;
1390
+ const currentAttempt = attemptMessages.slice(protectedStart);
1391
+ const overflowMessages = attemptMessages.slice(0, protectedStart).map((message) => message.role === "assistant" ? { ...message } : message);
1388
1392
  stripLastToolCalls(overflowMessages);
1393
+ overflowMessages.push(...currentAttempt);
1389
1394
  const { maxTokens, contextMargin } = (0, config_1.getContextConfig)();
1390
1395
  const trimmed = (0, context_1.trimMessages)(overflowMessages, maxTokens, contextMargin, maxTokens * 2);
1391
1396
  const requiredEvidence = options?.requiredPromptEvidence;
1392
1397
  const requiredMessages = new Set([
1393
1398
  ...(requiredEvidence?.verifiedPredecessorMessage ? [requiredEvidence.verifiedPredecessorMessage] : []),
1394
- ...(requiredEvidence?.currentUserMessage ? [requiredEvidence.currentUserMessage] : []),
1399
+ ...currentAttempt,
1395
1400
  ]);
1396
1401
  const trimmedMessages = new Set(trimmed);
1397
- const overflowRetryMessages = requiredMessages.size === 0
1402
+ attemptMessages = requiredMessages.size === 0
1398
1403
  ? trimmed
1399
1404
  : overflowMessages.filter((message) => trimmedMessages.has(message) || requiredMessages.has(message));
1400
- messages.splice(0, messages.length, ...overflowRetryMessages);
1401
1405
  providerRuntime.resetTurnState(attemptMessages);
1402
1406
  callbacks.onError(new Error("context trimmed, retrying..."), "transient");
1403
1407
  return callProviderTurn();
@@ -1372,59 +1372,62 @@ async function readDefaultSanctuaryScenarioFacts(label, scenarioHandleDigest, de
1372
1372
  throw new Error("Telegram approval evidence MAC is invalid");
1373
1373
  }
1374
1374
  }
1375
- const expectedSubject = (0, telegram_1.opaqueTelegramSubject)(identityKey, credentials.botToken, credentials.authorizedUserId, credentials.authorizedChatId);
1376
- const observedSubjects = auditEntries.flatMap((entry) => typeof entry.meta.subject === "string" ? [entry.meta.subject] : []);
1377
- const approvalSubjects = approvalRecords.map((projection) => projection.approval).filter((record) => record.transport === "telegram").map((record) => record.requesterId);
1378
- const rawValues = [...new Set([credentials.botToken, credentials.authorizedUserId, credentials.authorizedChatId])];
1379
- const persistedIdentitySurfaces = readBoundedIdentitySurfaces(agentRoot);
1380
- const surfacePairs = Array.from({ length: Math.floor(persistedIdentitySurfaces.length / 2) }, (_, index) => ({
1381
- relativePath: persistedIdentitySurfaces[index * 2],
1382
- raw: persistedIdentitySurfaces[index * 2 + 1],
1383
- }));
1384
- const canonicalSessionPath = path.join("state", "sessions", `telegram-user:${expectedSubject}`, "telegram", `telegram_${expectedSubject}.json`);
1385
- const canonicalSessionCount = surfacePairs.filter(({ relativePath, raw }) => {
1386
- if (relativePath !== canonicalSessionPath)
1387
- return false;
1388
- object(JSON.parse(raw), "canonical Telegram session");
1389
- return true;
1390
- }).length;
1391
- const sessionSurfaceDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(surfacePairs.filter(({ relativePath }) => relativePath === path.join("state", "senses", "telegram", "identity-subjects.json")
1392
- || relativePath.startsWith(`state${path.sep}sessions${path.sep}`)
1393
- || relativePath.startsWith(`state${path.sep}pending${path.sep}`)
1394
- || relativePath.startsWith(`state${path.sep}pending-returns${path.sep}`)))).digest("hex");
1395
- const friendSurfaceDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(surfacePairs.filter(({ relativePath }) => relativePath.startsWith(`friends${path.sep}`)))).digest("hex");
1396
- const canonicalFriendCount = surfacePairs.reduce((count, { relativePath, raw }) => {
1397
- if (!relativePath.startsWith(`friends${path.sep}`) || !relativePath.endsWith(".json"))
1398
- return count;
1399
- const record = object(JSON.parse(raw), "Telegram Friend identity");
1400
- if (!Array.isArray(record.externalIds))
1401
- return count;
1402
- return count + record.externalIds.filter((value) => {
1403
- const identity = object(value, "Telegram Friend external identity");
1404
- return identity.provider === "telegram-user" && identity.externalId === expectedSubject;
1375
+ // Unknown admission needs the same surfaces for its zero-work digest.
1376
+ if (sanctuary_acceptance_harness_1.SANCTUARY_SCENARIO_SOURCES[label].includes("identity-surface-audit") || label === "unit-16d-2-unknown-admission") {
1377
+ const expectedSubject = (0, telegram_1.opaqueTelegramSubject)(identityKey, credentials.botToken, credentials.authorizedUserId, credentials.authorizedChatId);
1378
+ const observedSubjects = auditEntries.flatMap((entry) => typeof entry.meta.subject === "string" ? [entry.meta.subject] : []);
1379
+ const approvalSubjects = approvalRecords.map((projection) => projection.approval).filter((record) => record.transport === "telegram").map((record) => record.requesterId);
1380
+ const rawValues = [...new Set([credentials.botToken, credentials.authorizedUserId, credentials.authorizedChatId])];
1381
+ const persistedIdentitySurfaces = readBoundedIdentitySurfaces(agentRoot);
1382
+ const surfacePairs = Array.from({ length: Math.floor(persistedIdentitySurfaces.length / 2) }, (_, index) => ({
1383
+ relativePath: persistedIdentitySurfaces[index * 2],
1384
+ raw: persistedIdentitySurfaces[index * 2 + 1],
1385
+ }));
1386
+ const canonicalSessionPath = path.join("state", "sessions", `telegram-user:${expectedSubject}`, "telegram", `telegram_${expectedSubject}.json`);
1387
+ const canonicalSessionCount = surfacePairs.filter(({ relativePath, raw }) => {
1388
+ if (relativePath !== canonicalSessionPath)
1389
+ return false;
1390
+ object(JSON.parse(raw), "canonical Telegram session");
1391
+ return true;
1405
1392
  }).length;
1406
- }, 0);
1407
- const surfaceRecords = [...persistedIdentitySurfaces, auditRaw ?? "", auditHeadRaw ?? "", JSON.stringify(approvalRecords)];
1408
- const surfaceSubjects = surfaceRecords.flatMap((raw) => raw.match(/tg_[A-Za-z0-9_-]{43}/gu) ?? []);
1409
- const structuredRawId = /"(?:authorizedUserId|authorizedChatId|transportUserId|transportChatId|userId|chatId|updateId|messageId)"\s*:\s*"?\d{1,20}"?/gu;
1410
- const rawLeakCount = surfaceRecords.reduce((count, raw) => count + rawValues.filter((value) => raw.includes(value)).length + (raw.match(structuredRawId)?.length ?? 0), 0);
1411
- const mismatchCount = [...surfaceSubjects, ...observedSubjects, ...approvalSubjects].filter((subject) => subject !== expectedSubject).length;
1412
- identity = {
1413
- keyPresent: true,
1414
- subjectOpaque: /^tg_[A-Za-z0-9_-]{43}$/u.test(expectedSubject)
1415
- && mismatchCount === 0,
1416
- rawIdentityAbsent: rawLeakCount === 0,
1417
- liveSubjectObserved: observedSubjects.includes(expectedSubject) && telegramTurns.some((turn) => turn.status === "success"),
1418
- inspectedRecordCount: surfaceRecords.length,
1419
- opaqueSubjectCount: [...surfaceSubjects, ...observedSubjects, ...approvalSubjects].filter((subject) => subject === expectedSubject).length,
1420
- mismatchCount,
1421
- rawLeakCount,
1422
- canonicalSessionCount,
1423
- canonicalFriendCount,
1424
- sessionSurfaceDigest,
1425
- friendSurfaceDigest,
1426
- surfaceDigest: (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(surfaceRecords)).digest("hex"),
1427
- };
1393
+ const sessionSurfaceDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(surfacePairs.filter(({ relativePath }) => relativePath === path.join("state", "senses", "telegram", "identity-subjects.json")
1394
+ || relativePath.startsWith(`state${path.sep}sessions${path.sep}`)
1395
+ || relativePath.startsWith(`state${path.sep}pending${path.sep}`)
1396
+ || relativePath.startsWith(`state${path.sep}pending-returns${path.sep}`)))).digest("hex");
1397
+ const friendSurfaceDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(surfacePairs.filter(({ relativePath }) => relativePath.startsWith(`friends${path.sep}`)))).digest("hex");
1398
+ const canonicalFriendCount = surfacePairs.reduce((count, { relativePath, raw }) => {
1399
+ if (!relativePath.startsWith(`friends${path.sep}`) || !relativePath.endsWith(".json"))
1400
+ return count;
1401
+ const record = object(JSON.parse(raw), "Telegram Friend identity");
1402
+ if (!Array.isArray(record.externalIds))
1403
+ return count;
1404
+ return count + record.externalIds.filter((value) => {
1405
+ const identity = object(value, "Telegram Friend external identity");
1406
+ return identity.provider === "telegram-user" && identity.externalId === expectedSubject;
1407
+ }).length;
1408
+ }, 0);
1409
+ const surfaceRecords = [...persistedIdentitySurfaces, auditRaw ?? "", auditHeadRaw ?? "", JSON.stringify(approvalRecords)];
1410
+ const surfaceSubjects = surfaceRecords.flatMap((raw) => raw.match(/tg_[A-Za-z0-9_-]{43}/gu) ?? []);
1411
+ const structuredRawId = /"(?:authorizedUserId|authorizedChatId|transportUserId|transportChatId|userId|chatId|updateId|messageId)"\s*:\s*"?\d{1,20}"?/gu;
1412
+ const rawLeakCount = surfaceRecords.reduce((count, raw) => count + rawValues.filter((value) => raw.includes(value)).length + (raw.match(structuredRawId)?.length ?? 0), 0);
1413
+ const mismatchCount = [...surfaceSubjects, ...observedSubjects, ...approvalSubjects].filter((subject) => subject !== expectedSubject).length;
1414
+ identity = {
1415
+ keyPresent: true,
1416
+ subjectOpaque: /^tg_[A-Za-z0-9_-]{43}$/u.test(expectedSubject)
1417
+ && mismatchCount === 0,
1418
+ rawIdentityAbsent: rawLeakCount === 0,
1419
+ liveSubjectObserved: observedSubjects.includes(expectedSubject) && telegramTurns.some((turn) => turn.status === "success"),
1420
+ inspectedRecordCount: surfaceRecords.length,
1421
+ opaqueSubjectCount: [...surfaceSubjects, ...observedSubjects, ...approvalSubjects].filter((subject) => subject === expectedSubject).length,
1422
+ mismatchCount,
1423
+ rawLeakCount,
1424
+ canonicalSessionCount,
1425
+ canonicalFriendCount,
1426
+ sessionSurfaceDigest,
1427
+ friendSurfaceDigest,
1428
+ surfaceDigest: (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(surfaceRecords)).digest("hex"),
1429
+ };
1430
+ }
1428
1431
  }
1429
1432
  const container = options.skipContainerSnapshot === true
1430
1433
  ? null
@@ -239,6 +239,13 @@ function removeApprovalSuspensionCheckpoint(envelope, approvalId) {
239
239
  });
240
240
  return { ...envelope, approvalSuspensions };
241
241
  }
242
+ // Native origins survive replay normalization, but never enter serialized provider or session data.
243
+ const messageSourceEventIds = Symbol("messageSourceEventIds");
244
+ function withMessageSourceEventIds(message, eventIds) {
245
+ if (eventIds)
246
+ Object.defineProperty(message, messageSourceEventIds, { value: eventIds });
247
+ return message;
248
+ }
242
249
  exports.EVENT_CONTENT_MAX_CHARS = 256 * 1024;
243
250
  function truncateLargeEventContent(content, maxChars) {
244
251
  if (typeof content !== "string") {
@@ -383,6 +390,7 @@ function normalizeMessage(message) {
383
390
  const record = message;
384
391
  const role = normalizeRole(record.role);
385
392
  const normalizedContent = sanitizeConversationContent(role, normalizeContent(record.content));
393
+ const sourceEventIds = message[messageSourceEventIds];
386
394
  if (role === "assistant") {
387
395
  return {
388
396
  role,
@@ -391,6 +399,7 @@ function normalizeMessage(message) {
391
399
  toolCallId: null,
392
400
  toolCalls: normalizeToolCalls(record.tool_calls),
393
401
  hadToolCallsField: Array.isArray(record.tool_calls),
402
+ sourceEventIds,
394
403
  };
395
404
  }
396
405
  if (role === "tool") {
@@ -401,6 +410,7 @@ function normalizeMessage(message) {
401
410
  toolCallId: typeof record.tool_call_id === "string" ? record.tool_call_id : null,
402
411
  toolCalls: [],
403
412
  hadToolCallsField: false,
413
+ sourceEventIds,
404
414
  };
405
415
  }
406
416
  return {
@@ -410,6 +420,7 @@ function normalizeMessage(message) {
410
420
  toolCallId: null,
411
421
  toolCalls: [],
412
422
  hadToolCallsField: false,
423
+ sourceEventIds,
413
424
  };
414
425
  }
415
426
  function contentText(content) {
@@ -442,33 +453,38 @@ function toProviderMessage(message) {
442
453
  },
443
454
  }));
444
455
  }
445
- return assistant;
456
+ return withMessageSourceEventIds(assistant, message.sourceEventIds);
446
457
  }
447
458
  if (message.role === "tool") {
448
- return {
459
+ return withMessageSourceEventIds({
449
460
  role: "tool",
450
461
  content: typeof message.content === "string" ? message.content : contentText(message.content),
451
462
  tool_call_id: message.toolCallId ?? "",
452
- };
463
+ }, message.sourceEventIds);
453
464
  }
454
465
  if (message.role === "system") {
455
- return {
466
+ return withMessageSourceEventIds({
456
467
  role: "system",
457
468
  content: typeof message.content === "string" ? message.content : contentText(message.content),
458
469
  ...(message.name ? { name: message.name } : {}),
459
- };
470
+ }, message.sourceEventIds);
460
471
  }
461
- return {
472
+ return withMessageSourceEventIds({
462
473
  role: "user",
463
474
  content: (typeof message.content === "string" || Array.isArray(message.content) ? message.content : ""),
464
475
  ...(message.name ? { name: message.name } : {}),
465
- };
476
+ }, message.sourceEventIds);
477
+ }
478
+ function messageSourcesAgree(left, right) {
479
+ const leftIds = left[messageSourceEventIds];
480
+ const rightIds = right[messageSourceEventIds];
481
+ return !leftIds || !rightIds || leftIds.length === rightIds.length && leftIds.every((id, index) => id === rightIds[index]);
466
482
  }
467
- function messageFingerprint(message) {
483
+ function messageFingerprint(message, captureContent = false) {
468
484
  const normalized = normalizeMessage(message);
469
485
  return JSON.stringify({
470
486
  role: normalized.role,
471
- content: normalized.content,
487
+ content: captureContent ? truncateLargeEventContent(normalized.content, exports.EVENT_CONTENT_MAX_CHARS).content : normalized.content,
472
488
  name: normalized.name,
473
489
  tool_call_id: normalized.toolCallId,
474
490
  tool_calls: normalized.toolCalls,
@@ -582,6 +598,8 @@ function repairSessionMessages(messages) {
582
598
  if (msg.role === "assistant" && result.length > 0) {
583
599
  const prev = result[result.length - 1];
584
600
  if (prev.role === "assistant" && prev.toolCalls.length === 0) {
601
+ if (prev.sourceEventIds)
602
+ prev.sourceEventIds = [...new Set([...prev.sourceEventIds, ...(msg.sourceEventIds ?? [])])];
585
603
  const prevContent = contentText(prev.content);
586
604
  const curContent = contentText(msg.content);
587
605
  // Drop the second of two consecutive assistants when the content is
@@ -805,7 +823,7 @@ function repairInlineReasoningOnReplay(messages, inlineReasoningStrippedCallIds)
805
823
  repaired++;
806
824
  for (const tc of a.tool_calls)
807
825
  inlineReasoningStrippedCallIds.add(tc.id);
808
- return { ...a, content: stripped.length > 0 ? stripped : null };
826
+ return withMessageSourceEventIds({ ...a, content: stripped.length > 0 ? stripped : null }, msg[messageSourceEventIds]);
809
827
  });
810
828
  if (repaired > 0) {
811
829
  (0, runtime_1.emitNervesEvent)({
@@ -949,27 +967,30 @@ function buildEventFromMessage(message, sequence, recordedAt, captureKind, sourc
949
967
  };
950
968
  }
951
969
  function projectedSessionEventIds(envelope) {
952
- return envelope.projection.eventIds.length > 0
953
- ? envelope.projection.eventIds
954
- : envelope.events.map((event) => event.id);
970
+ return providerProjectionEvents(envelope).map((event) => event.id);
955
971
  }
956
972
  function providerProjectionEvents(envelope) {
957
- const eventIds = projectedSessionEventIds(envelope);
958
- const byId = new Map(selectEffectiveSessionEvents(envelope.events).map((event) => [event.id, event]));
959
- return eventIds
973
+ const events = selectEffectiveSessionEvents(envelope.events);
974
+ if (envelope.projection.eventIds.length === 0)
975
+ return envelope.projection.trimmed ? [] : events;
976
+ const byId = new Map(events.map((event) => [event.id, event]));
977
+ return envelope.projection.eventIds
960
978
  .map((id) => byId.get(id))
961
979
  .filter((event) => Boolean(event));
962
980
  }
963
- function projectProviderMessages(envelope) {
964
- return providerProjectionEvents(envelope)
965
- .map((event) => toProviderMessage({
981
+ function providerMessageFromEvent(event) {
982
+ return toProviderMessage({
966
983
  role: event.role,
967
984
  content: event.content,
968
985
  name: event.name,
969
986
  toolCallId: event.toolCallId,
970
987
  toolCalls: event.toolCalls,
971
988
  hadToolCallsField: event.toolCalls.length > 0,
972
- }));
989
+ sourceEventIds: [event.id],
990
+ });
991
+ }
992
+ function projectProviderMessages(envelope) {
993
+ return providerProjectionEvents(envelope).map(providerMessageFromEvent);
973
994
  }
974
995
  /**
975
996
  * Annotate user and assistant messages with a relative time offset tag.
@@ -1262,7 +1283,7 @@ function parseSessionEnvelope(raw, options = {}) {
1262
1283
  // still resolves predictably.
1263
1284
  const events = dedupeEventsByIdLastWins(rawEvents);
1264
1285
  const projection = record.projection;
1265
- return {
1286
+ const envelope = {
1266
1287
  version: 2,
1267
1288
  events,
1268
1289
  projection: {
@@ -1273,11 +1294,12 @@ function parseSessionEnvelope(raw, options = {}) {
1273
1294
  inputTokens: typeof projection.inputTokens === "number" ? projection.inputTokens : null,
1274
1295
  projectedAt: typeof projection.projectedAt === "string" ? projection.projectedAt : null,
1275
1296
  },
1276
- structuredOutputs: (0, structured_output_1.extractStructuredOutputsFromEvents)(selectEffectiveSessionEvents(events), { emitTelemetry: false }),
1277
1297
  lastUsage: normalizeUsage(record.lastUsage),
1278
1298
  state: normalizeContinuityState(record.state),
1279
1299
  approvalSuspensions: normalizeApprovalSuspensions(record.approvalSuspensions),
1280
1300
  };
1301
+ envelope.structuredOutputs = (0, structured_output_1.extractStructuredOutputsFromEvents)(providerProjectionEvents(envelope), { emitTelemetry: false });
1302
+ return envelope;
1281
1303
  }
1282
1304
  function loadSessionEnvelopeFile(filePath) {
1283
1305
  try {
@@ -1310,12 +1332,14 @@ function filterNonSystem(messages) {
1310
1332
  * System messages (whose content changes every turn due to live world-state)
1311
1333
  * are excluded so that prefix matching is not defeated by system prompt updates.
1312
1334
  */
1313
- function findCommonPrefixLength(a, b) {
1335
+ function findCommonPrefixLength(a, b, captureContent = false) {
1314
1336
  const aNonSys = filterNonSystem(a);
1315
1337
  const bNonSys = filterNonSystem(b);
1316
1338
  const max = Math.min(aNonSys.length, bNonSys.length);
1317
1339
  for (let i = 0; i < max; i++) {
1318
- if (messageFingerprint(aNonSys[i]) !== messageFingerprint(bNonSys[i]))
1340
+ if (!messageSourcesAgree(aNonSys[i], bNonSys[i]))
1341
+ return i;
1342
+ if (messageFingerprint(aNonSys[i], captureContent) !== messageFingerprint(bNonSys[i], captureContent))
1319
1343
  return i;
1320
1344
  }
1321
1345
  return max;
@@ -1324,101 +1348,139 @@ function selectProjectedEventIds(currentMessages, currentEventIds, trimmedMessag
1324
1348
  if (trimmedMessages.length === 0)
1325
1349
  return [];
1326
1350
  const result = [];
1351
+ const currentIdentities = new Set(currentMessages);
1327
1352
  let needle = 0;
1328
1353
  let trimmedFingerprint = null;
1329
1354
  for (let i = 0; i < currentMessages.length && needle < trimmedMessages.length; i++) {
1330
1355
  const currentMessage = currentMessages[i];
1331
1356
  const trimmedMessage = trimmedMessages[needle];
1332
1357
  if (currentMessage !== trimmedMessage) {
1358
+ if (currentIdentities.has(trimmedMessage))
1359
+ continue;
1360
+ if (!messageSourcesAgree(currentMessage, trimmedMessage))
1361
+ continue;
1333
1362
  trimmedFingerprint ??= messageFingerprint(trimmedMessage);
1334
1363
  if (messageFingerprint(currentMessage) !== trimmedFingerprint)
1335
1364
  continue;
1336
1365
  }
1337
- result.push(currentEventIds[i]);
1366
+ result.push(...currentEventIds[i]);
1338
1367
  needle++;
1339
1368
  trimmedFingerprint = null;
1340
1369
  }
1341
1370
  return result;
1342
1371
  }
1372
+ function retainedSourceEventIds(message, existing, used) {
1373
+ const ids = message[messageSourceEventIds];
1374
+ if (!ids?.length || ids.some((id) => !existing.has(id) || used.has(id)))
1375
+ return null;
1376
+ const originals = ids.map((id) => providerMessageFromEvent(existing.get(id)));
1377
+ const fingerprint = messageFingerprint(message, true);
1378
+ const matches = (candidate) => (candidate[messageSourceEventIds] !== undefined
1379
+ && messageSourcesAgree(candidate, message)
1380
+ && messageFingerprint(candidate, true) === fingerprint);
1381
+ return originals.some(matches) || sanitizeProviderMessages(originals).some(matches) ? ids : null;
1382
+ }
1343
1383
  function buildCanonicalSessionEnvelope(options) {
1344
1384
  const existing = options.existing;
1345
1385
  // Callers pass pre-sanitized messages + pre-captured ingress times.
1346
1386
  const currentIngressTimes = options.currentIngressTimes ?? options.currentMessages.map(getIngressTime);
1347
1387
  const currentIngressRelations = options.currentIngressRelations ?? options.currentMessages.map(getIngressRelations);
1348
- const previousMessages = options.previousMessages;
1388
+ let previousMessages = options.previousMessages;
1349
1389
  const currentMessages = options.currentMessages;
1350
1390
  const trimmedMessages = options.trimmedMessages;
1351
- const previousProjectionIds = existing ? providerProjectionEvents(existing).map((event) => event.id) : [];
1391
+ let previousProjectionGroups = existing ? providerProjectionEvents(existing).map((event) => [event.id]) : [];
1352
1392
  // Compare only non-system messages to find the common prefix.
1353
1393
  // System messages change every turn (live world-state in system prompt)
1354
1394
  // and must not defeat prefix matching of the actual conversation.
1355
- const nonSystemPrefix = findCommonPrefixLength(previousMessages, currentMessages);
1395
+ let nonSystemPrefix = findCommonPrefixLength(previousMessages, currentMessages, true);
1396
+ // A repaired provider message can represent several original native records.
1397
+ const repairedPrevious = sanitizeProviderMessages(previousMessages.map((message, index) => withMessageSourceEventIds({ ...message }, previousProjectionGroups[index])));
1398
+ const repairedPrefix = findCommonPrefixLength(repairedPrevious, currentMessages);
1399
+ if (repairedPrefix > nonSystemPrefix) {
1400
+ previousMessages = repairedPrevious;
1401
+ previousProjectionGroups = repairedPrevious.map((message) => message[messageSourceEventIds] ?? []);
1402
+ nonSystemPrefix = repairedPrefix;
1403
+ }
1356
1404
  // Build a lookup of non-system previous projection IDs.
1357
1405
  const prevNonSystemIds = [];
1358
1406
  for (let i = 0; i < previousMessages.length; i++) {
1359
1407
  if (messageRole(previousMessages[i]) !== "system") {
1360
- prevNonSystemIds.push(previousProjectionIds[i]);
1408
+ prevNonSystemIds.push(previousProjectionGroups[i]);
1361
1409
  }
1362
1410
  }
1363
1411
  // Walk currentMessages and build currentEventIds + new events.
1364
1412
  // Non-system messages within the prefix reuse old event IDs.
1365
- // System messages and post-prefix messages get new events.
1413
+ // Other messages reuse a matching native origin or create a new event.
1366
1414
  const events = [...(existing?.events ?? [])];
1415
+ const sourceEvents = new Map(selectEffectiveSessionEvents(events).map((event) => [event.id, event]));
1416
+ const excludedSourceIds = new Set(events.filter((event) => !sourceEvents.has(event.id)).map((event) => event.id));
1417
+ const usedEventIds = new Set();
1367
1418
  const currentEventIds = [];
1368
1419
  let nonSystemSeen = 0;
1369
1420
  for (let i = 0; i < currentMessages.length; i++) {
1421
+ const sourceIds = currentMessages[i][messageSourceEventIds];
1422
+ if (sourceIds?.some((id) => excludedSourceIds.has(id))) {
1423
+ throw new Error("cannot persist messages derived from redacted session events");
1424
+ }
1370
1425
  const role = messageRole(currentMessages[i]);
1371
1426
  const isSystem = role === "system";
1372
1427
  const inPrefix = !isSystem && nonSystemSeen < nonSystemPrefix;
1373
- if (inPrefix) {
1428
+ if (inPrefix && prevNonSystemIds[nonSystemSeen].every((id) => !usedEventIds.has(id))) {
1374
1429
  // Reuse existing event ID for this matched non-system message
1375
1430
  currentEventIds.push(prevNonSystemIds[nonSystemSeen]);
1376
1431
  nonSystemSeen++;
1377
1432
  }
1378
1433
  else if (isSystem && i < previousMessages.length
1379
1434
  && messageRole(previousMessages[i]) === "system"
1380
- && messageFingerprint(currentMessages[i]) === messageFingerprint(previousMessages[i])) {
1435
+ && messageSourcesAgree(currentMessages[i], previousMessages[i])
1436
+ && previousProjectionGroups[i].every((id) => !usedEventIds.has(id))
1437
+ && messageFingerprint(currentMessages[i], true) === messageFingerprint(previousMessages[i], true)) {
1381
1438
  // System message at same position with identical content -- reuse event ID
1382
- currentEventIds.push(previousProjectionIds[i]);
1439
+ currentEventIds.push(previousProjectionGroups[i]);
1383
1440
  }
1384
1441
  else {
1385
1442
  if (!isSystem)
1386
1443
  nonSystemSeen++;
1444
+ const retainedIds = retainedSourceEventIds(currentMessages[i], sourceEvents, usedEventIds);
1445
+ if (retainedIds) {
1446
+ currentEventIds.push(retainedIds);
1447
+ for (const id of retainedIds)
1448
+ usedEventIds.add(id);
1449
+ continue;
1450
+ }
1387
1451
  // Create a new event. Use nextEventSequence(events) instead of
1388
1452
  // `events.length + 1` so that any gap (from pruning, archive replay,
1389
1453
  // or self-heal dedup) cannot collide with an existing id.
1390
1454
  const event = buildEventFromMessage(currentMessages[i], nextEventSequence(events), options.recordedAt, "live", null, null, currentIngressTimes[i], currentIngressRelations[i]);
1391
1455
  events.push(event);
1392
- currentEventIds.push(event.id);
1456
+ currentEventIds.push([event.id]);
1393
1457
  }
1458
+ for (const id of currentEventIds.at(-1))
1459
+ usedEventIds.add(id);
1394
1460
  }
1395
1461
  const projectionEventIds = selectProjectedEventIds(currentMessages, currentEventIds, trimmedMessages);
1396
- // Prune events: only keep events whose IDs are in the projection.
1397
- // Events not in projection are returned as evicted for archiving.
1462
+ // Projection omissions are not authority to delete native history.
1398
1463
  const projectionIdSet = new Set(projectionEventIds);
1399
- const effectiveIds = new Set(selectEffectiveSessionEvents(events).map((event) => event.id));
1400
- const keep = (event) => projectionIdSet.has(event.id) || !effectiveIds.has(event.id);
1401
- const prunedEvents = events.filter(keep);
1402
- const evictedEvents = events.filter((event) => !keep(event));
1403
- return {
1404
- envelope: {
1405
- version: 2,
1406
- events: prunedEvents,
1407
- projection: {
1408
- eventIds: projectionEventIds.filter((id) => effectiveIds.has(id)),
1409
- trimmed: projectionEventIds.length < currentEventIds.length,
1410
- maxTokens: options.projectionBasis.maxTokens,
1411
- contextMargin: options.projectionBasis.contextMargin,
1412
- inputTokens: options.projectionBasis.inputTokens,
1413
- projectedAt: options.recordedAt,
1414
- },
1415
- structuredOutputs: (0, structured_output_1.extractStructuredOutputsFromEvents)(selectEffectiveSessionEvents(prunedEvents)),
1416
- lastUsage: normalizeUsage(options.lastUsage),
1417
- state: normalizeContinuityState(options.state),
1418
- approvalSuspensions: structuredClone(options.existing?.approvalSuspensions ?? []),
1464
+ const effectiveEvents = selectEffectiveSessionEvents(events);
1465
+ const effectiveIds = new Set(effectiveEvents.map((event) => event.id));
1466
+ const evictedEvents = effectiveEvents.filter((event) => !projectionIdSet.has(event.id));
1467
+ const envelope = {
1468
+ version: 2,
1469
+ events,
1470
+ projection: {
1471
+ eventIds: projectionEventIds.filter((id) => effectiveIds.has(id)),
1472
+ trimmed: evictedEvents.length > 0,
1473
+ maxTokens: options.projectionBasis.maxTokens,
1474
+ contextMargin: options.projectionBasis.contextMargin,
1475
+ inputTokens: options.projectionBasis.inputTokens,
1476
+ projectedAt: options.recordedAt,
1419
1477
  },
1420
- evictedEvents,
1478
+ lastUsage: normalizeUsage(options.lastUsage),
1479
+ state: normalizeContinuityState(options.state),
1480
+ approvalSuspensions: structuredClone(options.existing?.approvalSuspensions ?? []),
1421
1481
  };
1482
+ envelope.structuredOutputs = (0, structured_output_1.extractStructuredOutputsFromEvents)(providerProjectionEvents(envelope));
1483
+ return { envelope, evictedEvents };
1422
1484
  }
1423
1485
  function appendSyntheticAssistantEvent(envelope, content, recordedAt) {
1424
1486
  // Use nextEventSequence(events) instead of `events.length + 1` so any gap
@@ -1426,15 +1488,17 @@ function appendSyntheticAssistantEvent(envelope, content, recordedAt) {
1426
1488
  // an existing event id. Same fix pattern as line 1046.
1427
1489
  const sequence = nextEventSequence(envelope.events);
1428
1490
  const event = buildEventFromMessage({ role: "assistant", content }, sequence, recordedAt, "synthetic", null, null);
1429
- return {
1491
+ const priorIds = projectedSessionEventIds(envelope);
1492
+ const updated = {
1430
1493
  ...envelope,
1431
1494
  events: [...envelope.events, event],
1432
- structuredOutputs: (0, structured_output_1.extractStructuredOutputsFromEvents)(selectEffectiveSessionEvents([...envelope.events, event])),
1433
1495
  projection: {
1434
1496
  ...envelope.projection,
1435
- eventIds: [...envelope.projection.eventIds, event.id],
1497
+ eventIds: [...priorIds, event.id],
1436
1498
  projectedAt: recordedAt,
1437
- trimmed: false,
1499
+ trimmed: new Set(priorIds).size < selectEffectiveSessionEvents(envelope.events).length,
1438
1500
  },
1439
1501
  };
1502
+ updated.structuredOutputs = (0, structured_output_1.extractStructuredOutputsFromEvents)(providerProjectionEvents(updated));
1503
+ return updated;
1440
1504
  }