larkin 0.3.3 → 0.4.2

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 (32) hide show
  1. package/README.md +72 -33
  2. package/assets/readme/larkin-hero.svg +111 -0
  3. package/dist/agent/agent-state-store.cjs +321 -22
  4. package/dist/agent/agent-state-store.mjs +320 -22
  5. package/dist/agent/agent-transport.cjs +6 -4
  6. package/dist/agent/agent-transport.mjs +6 -4
  7. package/dist/agent/host-reminder-orchestrator.cjs +9 -3
  8. package/dist/agent/host-reminder-orchestrator.mjs +9 -3
  9. package/dist/agent/inbox-projection.cjs +114 -23
  10. package/dist/agent/inbox-projection.mjs +112 -23
  11. package/dist/app/cli.mjs +3 -1
  12. package/dist/app/local-control.mjs +108 -2
  13. package/dist/app/runtime-process.mjs +4 -0
  14. package/dist/app/session-cli.mjs +55 -20
  15. package/dist/feishu/document-comment.cjs +2 -1
  16. package/dist/feishu/document-comment.mjs +2 -1
  17. package/dist/feishu/host-business-state.cjs +7 -2
  18. package/dist/feishu/host-business-state.mjs +7 -2
  19. package/dist/feishu/host-shell.cjs +138 -32
  20. package/dist/feishu/host-shell.mjs +138 -32
  21. package/dist/runtime/pi-compaction-recovery.cjs +623 -0
  22. package/dist/runtime/pi-compaction-recovery.mjs +602 -0
  23. package/dist/runtime/pi-provider-config.cjs +2 -2
  24. package/dist/runtime/pi-provider-config.mjs +2 -2
  25. package/dist/runtime/pi-rpc-client.cjs +39 -22
  26. package/dist/runtime/pi-rpc-client.mjs +39 -22
  27. package/dist/runtime/provider-error-classifier.cjs +39 -0
  28. package/dist/runtime/provider-error-classifier.mjs +34 -0
  29. package/dist/runtime/runtime-adapters.mjs +82 -19
  30. package/dist/runtime/runtime-host.cjs +663 -12
  31. package/dist/runtime/runtime-host.mjs +661 -11
  32. package/package.json +1 -1
@@ -4,7 +4,8 @@ import * as path from "node:path";
4
4
  import { TargetRootLayout } from "../platform/root-layout.mjs";
5
5
  import { acquireProcessLock, inspectProcess } from "../platform/process-state.mjs";
6
6
  import { isWindows } from "../platform/secure-metadata.mjs";
7
- import { targetKeyOfInboxEnvelope } from "./inbox-projection.mjs";
7
+ import { isCanonicalInboxTarget, targetKeyOfInboxEnvelope } from "./inbox-projection.mjs";
8
+ import { buildStrictProviderErrorInput, classifyStrictProviderError } from "../runtime/provider-error-classifier.mjs";
8
9
  const JSON_KEYS = new Set([
9
10
  "agentState", "status", "map", "replyctx", "botIdentity", "senderProfiles", "readReceipts", "pendingReact", "runtimeDeliveries", "inboxState", "freshnessState", "documentComments", "reminders", "interactions",
10
11
  ]);
@@ -103,7 +104,9 @@ function normalizeInboxState(value) {
103
104
  continue;
104
105
  const row = candidate;
105
106
  if (typeof row.target === "string" && row.target && validSequence(row.seq))
106
- state.messages[messageId] = { target: row.target, seq: row.seq };
107
+ state.messages[messageId] = {
108
+ target: row.target, seq: row.seq, ...(typeof row.kind === "string" && row.kind ? { kind: row.kind } : {}),
109
+ };
107
110
  }
108
111
  }
109
112
  if (raw.drafts && typeof raw.drafts === "object" && !Array.isArray(raw.drafts)) {
@@ -158,6 +161,14 @@ function validInboxLockOwner(value) {
158
161
  && typeof owner.processStartToken === "string" && !!owner.processStartToken
159
162
  && typeof owner.nonce === "string" && /^[0-9a-f-]{36}$/i.test(owner.nonce);
160
163
  }
164
+ export class ContextOverflowRecoveryError extends Error {
165
+ code;
166
+ constructor(code, message) {
167
+ super(message);
168
+ this.code = code;
169
+ this.name = "ContextOverflowRecoveryError";
170
+ }
171
+ }
161
172
  export class AgentStateStore {
162
173
  paths;
163
174
  boundary;
@@ -424,13 +435,14 @@ export class AgentStateStore {
424
435
  if (!value || typeof value !== "object" || Array.isArray(value))
425
436
  throw new Error("Inbox envelope must be an object");
426
437
  const input = value;
427
- const target = typeof input.target === "string" && input.target ? input.target : targetKeyOfInboxEnvelope(input);
438
+ const target = targetKeyOfInboxEnvelope(input);
428
439
  const current = state.targets[target] ?? { latest_received_seq: 0, model_seen_seq: 0 };
429
440
  const requested = validSequence(input.target_seq) ? input.target_seq : 0;
430
441
  const targetSeq = requested > current.latest_received_seq ? requested : current.latest_received_seq + 1;
431
442
  state.targets[target] = { ...current, latest_received_seq: targetSeq };
432
443
  if (typeof input.message_id === "string" && input.message_id) {
433
- state.messages[input.message_id] = { target, seq: targetSeq };
444
+ state.messages[input.message_id] = { target, seq: targetSeq,
445
+ ...(typeof input.kind === "string" && input.kind ? { kind: input.kind } : {}) };
434
446
  const messageIds = Object.keys(state.messages);
435
447
  for (const stale of messageIds.slice(0, Math.max(0, messageIds.length - 2_048)))
436
448
  delete state.messages[stale];
@@ -438,8 +450,9 @@ export class AgentStateStore {
438
450
  return { ...input, envelope_version: 2, target, target_seq: targetSeq };
439
451
  }
440
452
  reconcileInboxRows(rows, state) {
441
- return rows.map((row) => {
442
- const target = typeof row.target === "string" && row.target ? row.target : targetKeyOfInboxEnvelope(row);
453
+ const targets = rows.map((row) => targetKeyOfInboxEnvelope(row));
454
+ return rows.map((row, index) => {
455
+ const target = targets[index];
443
456
  const existing = typeof row.message_id === "string" ? state.messages[row.message_id] : undefined;
444
457
  const current = state.targets[target] ?? { latest_received_seq: 0, model_seen_seq: 0 };
445
458
  const targetSeq = validSequence(row.target_seq) ? row.target_seq
@@ -447,7 +460,8 @@ export class AgentStateStore {
447
460
  current.latest_received_seq = Math.max(current.latest_received_seq, targetSeq);
448
461
  state.targets[target] = current;
449
462
  if (typeof row.message_id === "string" && row.message_id)
450
- state.messages[row.message_id] = { target, seq: targetSeq };
463
+ state.messages[row.message_id] = { target, seq: targetSeq,
464
+ ...(typeof row.kind === "string" && row.kind ? { kind: row.kind } : {}) };
451
465
  return { ...row, envelope_version: 2, target, target_seq: targetSeq };
452
466
  });
453
467
  }
@@ -573,23 +587,302 @@ export class AgentStateStore {
573
587
  else
574
588
  append();
575
589
  }
576
- /** Append a canonical Inbox envelope once by stable message_id under the shared cross-process lock. */
577
- appendInboxOnce(value) {
590
+ /**
591
+ * Append one canonical Inbox envelope and return the exact normalized object
592
+ * serialized to disk. Stable message_id dedupe and locator coherence share the
593
+ * same lock as poll, so HostShell can deliver that very object without a
594
+ * persistence/delivery split.
595
+ */
596
+ appendCanonicalInboxOnce(value) {
578
597
  if (!value || typeof value !== "object" || Array.isArray(value))
579
598
  throw new Error("Inbox envelope must be an object");
580
- const messageId = value.message_id;
599
+ const input = value;
600
+ const messageId = input.message_id;
581
601
  if (typeof messageId !== "string" || !messageId)
582
602
  throw new Error("Inbox envelope requires message_id");
603
+ const incomingTarget = targetKeyOfInboxEnvelope(input);
583
604
  const file = this.file("inbox");
584
605
  return this.withInboxLock(file, () => {
585
- if (this.inboxState().messages[messageId])
586
- return false;
587
- if (this.readNdjson("inbox").some((row) => row.message_id === messageId))
588
- return false;
589
- this.appendInboxUnlocked(value);
590
- return true;
606
+ const state = this.inboxState();
607
+ const matching = this.readNdjson("inbox").filter((row) => row.message_id === messageId);
608
+ if (matching.length > 1)
609
+ throw new Error("Inbox duplicate message_id has multiple pending canonical rows");
610
+ if (matching.length === 1) {
611
+ const existing = matching[0];
612
+ if (targetKeyOfInboxEnvelope(existing) !== incomingTarget) {
613
+ throw new Error("Inbox duplicate message_id conflicts with its canonical target");
614
+ }
615
+ if (existing.target_seq !== undefined && !validSequence(existing.target_seq)) {
616
+ throw new Error("Inbox duplicate message_id has a malformed canonical sequence");
617
+ }
618
+ const known = state.messages[messageId];
619
+ if (known) {
620
+ if (known.target !== incomingTarget)
621
+ throw new Error("Inbox message state conflicts with its canonical target");
622
+ if (validSequence(existing.target_seq) && existing.target_seq !== known.seq) {
623
+ throw new Error("Inbox message state conflicts with its canonical sequence");
624
+ }
625
+ const targetState = state.targets[known.target];
626
+ if (targetState && targetState.model_seen_seq >= known.seq) {
627
+ throw new Error("Inbox pending row conflicts with consumed model-seen state");
628
+ }
629
+ }
630
+ return { status: "duplicate_pending", envelope: existing };
631
+ }
632
+ const known = state.messages[messageId];
633
+ if (known) {
634
+ if (known.target !== incomingTarget)
635
+ throw new Error("Inbox duplicate message_id conflicts with consumed canonical target");
636
+ const targetState = state.targets[known.target];
637
+ if (!targetState || targetState.model_seen_seq < known.seq) {
638
+ throw new Error("Inbox state references a missing unconsumed canonical row");
639
+ }
640
+ return { status: "duplicate_consumed", envelope: null };
641
+ }
642
+ return { status: "appended", envelope: this.appendInboxUnlocked(input) };
643
+ });
644
+ }
645
+ /** Append a canonical Inbox envelope once by stable message_id under the shared cross-process lock. */
646
+ appendInboxOnce(value) {
647
+ return this.appendCanonicalInboxOnce(value).status === "appended";
648
+ }
649
+ /**
650
+ * Resolve one Runtime delivery solely from canonical Inbox row/state. This is
651
+ * the single replay resolver used for startup migration and every retry; it
652
+ * never derives a DM/generic fallback from stale RuntimeInput text.
653
+ */
654
+ resolveInboxDeliverySource(messageId) {
655
+ if (!messageId)
656
+ return { status: "missing", code: "canonical_inbox_row_missing" };
657
+ return this.withInboxLock(this.file("inbox"), () => {
658
+ let rows;
659
+ try {
660
+ rows = this.readNdjson("inbox");
661
+ }
662
+ catch {
663
+ return { status: "invalid", code: "canonical_inbox_malformed" };
664
+ }
665
+ const matching = rows.filter((row) => row?.message_id === messageId);
666
+ if (matching.length > 1)
667
+ return { status: "invalid", code: "duplicate_message_id" };
668
+ const state = this.inboxState();
669
+ const known = state.messages[messageId];
670
+ if (matching.length === 1) {
671
+ const envelope = matching[0];
672
+ let target;
673
+ try {
674
+ target = targetKeyOfInboxEnvelope(envelope);
675
+ }
676
+ catch {
677
+ return { status: "invalid", code: "canonical_inbox_malformed" };
678
+ }
679
+ if (envelope.target_seq !== undefined && !validSequence(envelope.target_seq)) {
680
+ return { status: "invalid", code: "canonical_inbox_malformed" };
681
+ }
682
+ if (known) {
683
+ const targetState = state.targets[known.target];
684
+ if (known.target !== target || !validSequence(known.seq)
685
+ || (validSequence(envelope.target_seq) && envelope.target_seq !== known.seq)
686
+ || Boolean(targetState && targetState.model_seen_seq >= known.seq)) {
687
+ return { status: "invalid", code: "inbox_state_conflict" };
688
+ }
689
+ }
690
+ return { status: "pending", target, envelope };
691
+ }
692
+ if (!known)
693
+ return { status: "missing", code: "canonical_inbox_row_missing" };
694
+ if (!isCanonicalInboxTarget(known.target) || !validSequence(known.seq)) {
695
+ return { status: "invalid", code: "inbox_state_conflict" };
696
+ }
697
+ const targetState = state.targets[known.target];
698
+ if (targetState && targetState.model_seen_seq >= known.seq)
699
+ return { status: "consumed", target: known.target };
700
+ return { status: "missing", code: "canonical_inbox_row_missing" };
591
701
  });
592
702
  }
703
+ /**
704
+ * Re-arm only the durable deliveries proven to be the context-window incident.
705
+ * The Inbox bytes are intentionally untouched. An optional synchronous commit
706
+ * hook lets RuntimeHost swap its in-memory generation while this same lock is
707
+ * still held. If the hook fails, the ledger is restored before the lock is
708
+ * released. The callback also receives a guarded rollback for post-callback
709
+ * commit failures before retry scheduling begins.
710
+ */
711
+ rearmContextOverflow(onCommit, expected) {
712
+ let lockActive = true;
713
+ try {
714
+ return this.withInboxTransaction(() => {
715
+ let rows;
716
+ try {
717
+ rows = this.readNdjson("inbox");
718
+ }
719
+ catch {
720
+ throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox is malformed");
721
+ }
722
+ if (rows.length === 0)
723
+ throw new ContextOverflowRecoveryError("inbox_empty", "canonical Inbox backlog is empty");
724
+ const messageIds = new Set();
725
+ for (const row of rows) {
726
+ try {
727
+ targetKeyOfInboxEnvelope(row);
728
+ }
729
+ catch {
730
+ throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox contains a malformed row");
731
+ }
732
+ if (typeof row.message_id !== "string" || !row.message_id) {
733
+ throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox row has no message identity");
734
+ }
735
+ if (messageIds.has(row.message_id))
736
+ throw new ContextOverflowRecoveryError("duplicate_message_id", "canonical Inbox contains duplicate message identities");
737
+ messageIds.add(row.message_id);
738
+ }
739
+ const ledger = this.readJson("runtimeDeliveries", { version: 1, records: [] });
740
+ if (!Array.isArray(ledger.records))
741
+ throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "Runtime delivery ledger is malformed");
742
+ const ledgerRecords = ledger.records;
743
+ const matches = new Map();
744
+ const deliveryIds = new Set();
745
+ const inputIds = new Set();
746
+ for (const candidate of ledger.records) {
747
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
748
+ continue;
749
+ const record = candidate;
750
+ if (typeof record.messageId !== "string" || !messageIds.has(record.messageId))
751
+ continue;
752
+ if (typeof record.deliveryId !== "string" || !record.deliveryId)
753
+ throw new ContextOverflowRecoveryError("delivery_missing", "a matching Runtime delivery record has no delivery identity");
754
+ if (!record.input || typeof record.input !== "object" || Array.isArray(record.input)) {
755
+ throw new ContextOverflowRecoveryError("delivery_identity_invalid", "a matching Runtime delivery record has no Runtime input identity");
756
+ }
757
+ const input = record.input;
758
+ if (typeof input.inputId !== "string" || !input.inputId.trim()
759
+ || typeof input.deliveryId !== "string" || input.deliveryId !== record.deliveryId) {
760
+ throw new ContextOverflowRecoveryError("delivery_identity_invalid", "a matching Runtime delivery record has malformed input identity");
761
+ }
762
+ if (matches.has(record.messageId))
763
+ throw new ContextOverflowRecoveryError("delivery_duplicate", "a canonical Inbox row maps to multiple delivery records");
764
+ if (deliveryIds.has(record.deliveryId))
765
+ throw new ContextOverflowRecoveryError("delivery_duplicate", "matching Runtime delivery records share a delivery identity");
766
+ if (inputIds.has(input.inputId))
767
+ throw new ContextOverflowRecoveryError("duplicate_input_id", "matching Runtime delivery records share an input identity");
768
+ deliveryIds.add(record.deliveryId);
769
+ inputIds.add(input.inputId);
770
+ matches.set(record.messageId, record);
771
+ }
772
+ if (expected?.messageId && !messageIds.has(expected.messageId)) {
773
+ throw new ContextOverflowRecoveryError("delivery_missing", "the expected context-overflow message is not in canonical Inbox");
774
+ }
775
+ if (expected?.messageId) {
776
+ const expectedRecord = matches.get(expected.messageId);
777
+ if (!expectedRecord || (expected.deliveryId && expectedRecord.deliveryId !== expected.deliveryId)
778
+ || (expected.inputId && expectedRecord.input.inputId !== expected.inputId)) {
779
+ throw new ContextOverflowRecoveryError("delivery_identity_invalid", "the expected context-overflow delivery identity changed");
780
+ }
781
+ }
782
+ for (const messageId of messageIds) {
783
+ const record = matches.get(messageId);
784
+ if (!record)
785
+ throw new ContextOverflowRecoveryError("delivery_missing", "a canonical Inbox row has no Runtime delivery record");
786
+ if (record.status !== "error" || record.retryable === true) {
787
+ throw new ContextOverflowRecoveryError("delivery_not_terminal", "a canonical Inbox row is not backed by a non-retryable terminal Runtime error");
788
+ }
789
+ const reasonCategory = classifyStrictProviderError(buildStrictProviderErrorInput({
790
+ reason: record.reason, errorCategory: record.errorCategory,
791
+ }));
792
+ if (record.retryable !== false || reasonCategory !== "context_window"
793
+ || (record.errorCategory !== undefined && record.errorCategory !== "context_window")) {
794
+ throw new ContextOverflowRecoveryError("delivery_not_context_window", "a Runtime delivery error is not classified as context_window");
795
+ }
796
+ }
797
+ for (const candidate of ledgerRecords) {
798
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
799
+ continue;
800
+ const record = candidate;
801
+ if (typeof record.deliveryId === "string" && deliveryIds.has(record.deliveryId)
802
+ && (typeof record.messageId !== "string" || !messageIds.has(record.messageId))) {
803
+ throw new ContextOverflowRecoveryError("delivery_duplicate", "a matching delivery identity is also used by an unrelated Runtime delivery record");
804
+ }
805
+ }
806
+ const updatedAt = new Date().toISOString();
807
+ const records = ledgerRecords.map((candidate) => {
808
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
809
+ return candidate;
810
+ const record = candidate;
811
+ if (typeof record.messageId !== "string" || !messageIds.has(record.messageId))
812
+ return candidate;
813
+ return { ...record, status: "pending", retryable: true, updatedAt,
814
+ reason: "context-window recovery rearmed the retained Inbox delivery" };
815
+ });
816
+ const committedLedger = { ...ledger, records };
817
+ let restored = false;
818
+ const restoreUnlocked = () => {
819
+ if (restored)
820
+ return;
821
+ const current = this.readJson("runtimeDeliveries", { version: 1, records: [] });
822
+ if (!Array.isArray(current.records))
823
+ throw new Error("Runtime delivery rollback found a malformed ledger");
824
+ const originalMatching = ledgerRecords.filter((candidate) => {
825
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
826
+ return false;
827
+ const record = candidate;
828
+ return typeof record.messageId === "string" && messageIds.has(record.messageId);
829
+ });
830
+ const originalDeliveryIds = new Set(originalMatching.flatMap((candidate) => {
831
+ const deliveryId = candidate.deliveryId;
832
+ return typeof deliveryId === "string" ? [deliveryId] : [];
833
+ }));
834
+ const currentMatching = current.records.filter((candidate) => {
835
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
836
+ return false;
837
+ const record = candidate;
838
+ return (typeof record.deliveryId === "string" && originalDeliveryIds.has(record.deliveryId))
839
+ || (typeof record.messageId === "string" && messageIds.has(record.messageId));
840
+ });
841
+ const matchingRestored = currentMatching.length === originalMatching.length
842
+ && currentMatching.every((candidate, index) => JSON.stringify(candidate) === JSON.stringify(originalMatching[index]));
843
+ if (!matchingRestored) {
844
+ const unrelated = current.records.filter((candidate) => {
845
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
846
+ return true;
847
+ const record = candidate;
848
+ return !((typeof record.deliveryId === "string" && originalDeliveryIds.has(record.deliveryId))
849
+ || (typeof record.messageId === "string" && messageIds.has(record.messageId)));
850
+ });
851
+ const restoredRecords = [...unrelated];
852
+ for (const candidate of originalMatching) {
853
+ const originalIndex = ledgerRecords.indexOf(candidate);
854
+ restoredRecords.splice(Math.min(originalIndex, restoredRecords.length), 0, candidate);
855
+ }
856
+ this.writeJson("runtimeDeliveries", { ...current, records: restoredRecords });
857
+ }
858
+ restored = true;
859
+ };
860
+ const rollback = () => {
861
+ if (lockActive)
862
+ restoreUnlocked();
863
+ else
864
+ this.withInboxTransaction(restoreUnlocked);
865
+ };
866
+ try {
867
+ this.writeJson("runtimeDeliveries", committedLedger);
868
+ onCommit?.([...messageIds], rollback);
869
+ }
870
+ catch (error) {
871
+ try {
872
+ rollback();
873
+ }
874
+ catch {
875
+ throw new Error("context-window recovery ledger rollback failed");
876
+ }
877
+ throw error;
878
+ }
879
+ return { rearmedCount: messageIds.size, remainingPendingCount: rows.length };
880
+ });
881
+ }
882
+ finally {
883
+ lockActive = false;
884
+ }
885
+ }
593
886
  /**
594
887
  * Recoverably bridge an outbox wake into the canonical Inbox. A durable
595
888
  * Runtime ledger is authoritative after the Inbox has been drained, so a
@@ -601,6 +894,7 @@ export class AgentStateStore {
601
894
  const messageId = value.message_id;
602
895
  if (typeof messageId !== "string" || !messageId)
603
896
  throw new Error("Inbox envelope requires message_id");
897
+ targetKeyOfInboxEnvelope(value);
604
898
  const file = this.file("inbox");
605
899
  return this.withInboxLock(file, () => {
606
900
  const ledger = this.readJson("runtimeDeliveries", { version: 1, records: [] });
@@ -649,6 +943,9 @@ export class AgentStateStore {
649
943
  * this transaction is active waits and becomes part of the next batch.
650
944
  */
651
945
  pollInbox(options = {}) {
946
+ if (options.target && !isCanonicalInboxTarget(options.target)) {
947
+ throw new Error(`Invalid canonical Inbox poll target ${JSON.stringify(options.target)}`);
948
+ }
652
949
  const file = this.file("inbox");
653
950
  return this.withInboxLock(file, () => {
654
951
  let rawRows;
@@ -667,7 +964,7 @@ export class AgentStateStore {
667
964
  const envelopes = [];
668
965
  const remaining = [];
669
966
  for (const row of rows) {
670
- const selectedTarget = typeof row.target === "string" ? row.target : targetKeyOfInboxEnvelope(row);
967
+ const selectedTarget = targetKeyOfInboxEnvelope(row);
671
968
  if (envelopes.length < limit && (!options.target || options.target === selectedTarget))
672
969
  envelopes.push(row);
673
970
  else
@@ -676,7 +973,7 @@ export class AgentStateStore {
676
973
  const pendingCount = remaining.reduce((count, row) => {
677
974
  if (!options.target)
678
975
  return count + 1;
679
- const rowTarget = typeof row.target === "string" ? row.target : targetKeyOfInboxEnvelope(row);
976
+ const rowTarget = targetKeyOfInboxEnvelope(row);
680
977
  return count + (rowTarget === options.target ? 1 : 0);
681
978
  }, 0);
682
979
  if (!envelopes.length)
@@ -710,7 +1007,7 @@ export class AgentStateStore {
710
1007
  }
711
1008
  let seenThroughSeq = null;
712
1009
  for (const envelope of envelopes) {
713
- const target = String(envelope.target || targetKeyOfInboxEnvelope(envelope));
1010
+ const target = targetKeyOfInboxEnvelope(envelope);
714
1011
  const targetSeq = Number(envelope.target_seq);
715
1012
  const targetState = state.targets[target] ?? { latest_received_seq: targetSeq, model_seen_seq: 0 };
716
1013
  if (validSequence(targetSeq)) {
@@ -731,11 +1028,12 @@ export class AgentStateStore {
731
1028
  resolveInboxMessageTarget(messageId) {
732
1029
  return this.withInboxLock(this.file("inbox"), () => {
733
1030
  const state = this.inboxState();
734
- const known = state.messages[messageId]?.target;
1031
+ const known = state.messages[messageId];
735
1032
  if (known)
736
- return known;
1033
+ return targetKeyOfInboxEnvelope({ message_id: messageId, target: known.target,
1034
+ ...(known.kind ? { kind: known.kind } : {}) });
737
1035
  const row = this.readNdjson("inbox").find((candidate) => candidate.message_id === messageId);
738
- return row ? (typeof row.target === "string" ? row.target : targetKeyOfInboxEnvelope(row)) : null;
1036
+ return row ? targetKeyOfInboxEnvelope(row) : null;
739
1037
  });
740
1038
  }
741
1039
  listInboxDrafts() {
@@ -87,12 +87,14 @@ function createAgentTransport(env = process.env) {
87
87
  // —— legacy events/inbox characterization:从 host 写的 inbox 文件读入站正文(读后清空=已读)——
88
88
  // 必须严格对齐 agentApiEventsResponseSchema,否则 CLI 报 INVALID_JSON_RESPONSE,Claude 读不到消息。
89
89
  if (p.includes("/events") || p.includes("/inbox")) {
90
- let envelopes = [];
91
90
  try {
92
- envelopes = stateStore.pollInbox().envelopes;
91
+ const envelopes = stateStore.pollInbox().envelopes;
92
+ return { ok: true, status: 200, data: projectInboxEvents(envelopes) };
93
+ }
94
+ catch (error) {
95
+ return { ok: false, status: 409,
96
+ error: `Inbox consumption rejected; persisted rows were left unchanged: ${error instanceof Error ? error.message : String(error)}` };
93
97
  }
94
- catch { /* preserve legacy: malformed/unreadable inbox returns empty and is not cleared */ }
95
- return { ok: true, status: 200, data: projectInboxEvents(envelopes) };
96
98
  }
97
99
  // —— legacy history characterization:接飞书群历史 im +chat-messages-list ——
98
100
  // 需 bot 有 im:message:readonly;缺 scope 则优雅降级为空(不让 Claude 崩)。
@@ -80,12 +80,14 @@ export function createAgentTransport(env = process.env) {
80
80
  // —— legacy events/inbox characterization:从 host 写的 inbox 文件读入站正文(读后清空=已读)——
81
81
  // 必须严格对齐 agentApiEventsResponseSchema,否则 CLI 报 INVALID_JSON_RESPONSE,Claude 读不到消息。
82
82
  if (p.includes("/events") || p.includes("/inbox")) {
83
- let envelopes = [];
84
83
  try {
85
- envelopes = stateStore.pollInbox().envelopes;
84
+ const envelopes = stateStore.pollInbox().envelopes;
85
+ return { ok: true, status: 200, data: projectInboxEvents(envelopes) };
86
+ }
87
+ catch (error) {
88
+ return { ok: false, status: 409,
89
+ error: `Inbox consumption rejected; persisted rows were left unchanged: ${error instanceof Error ? error.message : String(error)}` };
86
90
  }
87
- catch { /* preserve legacy: malformed/unreadable inbox returns empty and is not cleared */ }
88
- return { ok: true, status: 200, data: projectInboxEvents(envelopes) };
89
91
  }
90
92
  // —— legacy history characterization:接飞书群历史 im +chat-messages-list ——
91
93
  // 需 bot 有 im:message:readonly;缺 scope 则优雅降级为空(不让 Claude 崩)。
@@ -37,6 +37,12 @@ exports.HostReminderOrchestrator = void 0;
37
37
  const fs = __importStar(require("node:fs"));
38
38
  const host_business_state_js_1 = require("../feishu/host-business-state.cjs");
39
39
  const defaultReminderStore = __importStar(require("./reminder-store.cjs"));
40
+ const inbox_projection_js_1 = require("./inbox-projection.cjs");
41
+ function withCanonicalTarget(envelope) {
42
+ const input = envelope;
43
+ const target = (0, inbox_projection_js_1.targetKeyOfInboxEnvelope)(input);
44
+ return input.target === target ? envelope : { ...envelope, target };
45
+ }
40
46
  class HostReminderOrchestrator {
41
47
  options;
42
48
  lastSnapshot = new Map();
@@ -99,14 +105,14 @@ class HostReminderOrchestrator {
99
105
  }
100
106
  deliver(agent, reminder, overdueMs) {
101
107
  const recurrence = reminder.repeat ? this.store.parseRepeat(reminder.repeat, reminder.tz) : null;
102
- const envelope = this.options.envelopeProjector.createReminderEnvelope(agent.agentId, {
108
+ const envelope = withCanonicalTarget(this.options.envelopeProjector.createReminderEnvelope(agent.agentId, {
103
109
  reminderId: reminder.reminderId,
104
110
  title: reminder.title,
105
111
  repeat: reminder.repeat ? String(reminder.repeat) : null,
106
112
  fireAt: reminder.fireAt,
107
113
  msgRef: reminder.msgRef,
108
114
  channel: reminder.channel ? String(reminder.channel) : null,
109
- }, overdueMs, reminder.repeat && recurrence && !("error" in recurrence) ? (recurrence.description || null) : null);
115
+ }, overdueMs, reminder.repeat && recurrence && !("error" in recurrence) ? (recurrence.description || null) : null));
110
116
  try {
111
117
  this.options.stateStore(agent).appendNdjson("inbox", envelope);
112
118
  }
@@ -268,7 +274,7 @@ class HostReminderOrchestrator {
268
274
  this.redelivered.add(agent.agentId);
269
275
  return;
270
276
  }
271
- const envelope = existing ?? this.options.envelopeProjector.createRedeliveryEnvelope(agent.agentId, wakeCount);
277
+ const envelope = withCanonicalTarget(existing ?? this.options.envelopeProjector.createRedeliveryEnvelope(agent.agentId, wakeCount));
272
278
  if (!existing) {
273
279
  try {
274
280
  this.options.stateStore(agent).appendNdjson("inbox", envelope);
@@ -1,6 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import { countWakeEnvelopes } from "../feishu/host-business-state.mjs";
3
3
  import * as defaultReminderStore from "./reminder-store.mjs";
4
+ import { targetKeyOfInboxEnvelope } from "./inbox-projection.mjs";
5
+ function withCanonicalTarget(envelope) {
6
+ const input = envelope;
7
+ const target = targetKeyOfInboxEnvelope(input);
8
+ return input.target === target ? envelope : { ...envelope, target };
9
+ }
4
10
  export class HostReminderOrchestrator {
5
11
  options;
6
12
  lastSnapshot = new Map();
@@ -63,14 +69,14 @@ export class HostReminderOrchestrator {
63
69
  }
64
70
  deliver(agent, reminder, overdueMs) {
65
71
  const recurrence = reminder.repeat ? this.store.parseRepeat(reminder.repeat, reminder.tz) : null;
66
- const envelope = this.options.envelopeProjector.createReminderEnvelope(agent.agentId, {
72
+ const envelope = withCanonicalTarget(this.options.envelopeProjector.createReminderEnvelope(agent.agentId, {
67
73
  reminderId: reminder.reminderId,
68
74
  title: reminder.title,
69
75
  repeat: reminder.repeat ? String(reminder.repeat) : null,
70
76
  fireAt: reminder.fireAt,
71
77
  msgRef: reminder.msgRef,
72
78
  channel: reminder.channel ? String(reminder.channel) : null,
73
- }, overdueMs, reminder.repeat && recurrence && !("error" in recurrence) ? (recurrence.description || null) : null);
79
+ }, overdueMs, reminder.repeat && recurrence && !("error" in recurrence) ? (recurrence.description || null) : null));
74
80
  try {
75
81
  this.options.stateStore(agent).appendNdjson("inbox", envelope);
76
82
  }
@@ -232,7 +238,7 @@ export class HostReminderOrchestrator {
232
238
  this.redelivered.add(agent.agentId);
233
239
  return;
234
240
  }
235
- const envelope = existing ?? this.options.envelopeProjector.createRedeliveryEnvelope(agent.agentId, wakeCount);
241
+ const envelope = withCanonicalTarget(existing ?? this.options.envelopeProjector.createRedeliveryEnvelope(agent.agentId, wakeCount));
236
242
  if (!existing) {
237
243
  try {
238
244
  this.options.stateStore(agent).appendNdjson("inbox", envelope);