@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100

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 (123) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/LICENSE +92 -0
  3. package/README.md +101 -2
  4. package/bin/hellolynk-client-setup.mjs +2748 -0
  5. package/bin/hellolynk-mcp-config-shape.mjs +23 -0
  6. package/hellolynk-client-pack-manifest.json +619 -0
  7. package/hellolynk-client-pack-pin.json +4 -0
  8. package/package.json +58 -4
  9. package/plugins/hellolynk-codex/.app.json +3 -0
  10. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
  11. package/plugins/hellolynk-codex/.mcp.json +19 -0
  12. package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
  13. package/plugins/hellolynk-codex/package.json +11 -0
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
  15. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
  16. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
  17. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
  18. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
  19. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
  20. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  21. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
  22. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
  23. package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  24. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
  34. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  35. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  36. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
  37. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
  38. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
  39. package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
  40. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  41. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
  42. package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
  43. package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
  44. package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
  45. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
  46. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
  47. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
  48. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
  49. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
  50. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
  51. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
  52. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
  53. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
  54. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
  55. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
  56. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
  57. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  58. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
  59. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
  60. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
  61. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
  62. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
  63. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  64. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
  65. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
  66. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  67. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  68. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
  69. package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
  70. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
  71. package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
  72. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
  73. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
  74. package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
  75. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
  76. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
  77. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
  78. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
  79. package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
  80. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
  81. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  82. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  83. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  84. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  85. package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
  86. package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
  87. package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
  88. package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
  89. package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
  90. package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
  91. package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
  92. package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
  93. package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
  94. package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
  95. package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
  96. package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
  97. package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
  98. package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
  99. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
  100. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
  101. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
  102. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
  103. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
  104. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
  105. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
  106. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
  107. package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
  108. package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
  109. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
  110. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
  111. package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
  112. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
  113. package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
  114. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
  115. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
  116. package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
  117. package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
  118. package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
  119. package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
  120. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
  121. package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
  122. package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
  123. package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
@@ -0,0 +1,1558 @@
1
+ import { nativeConversationId, nativeConversationTitle } from "./hellolynk-native-conversations.mjs";
2
+ import crypto from "node:crypto";
3
+ import { createReadStream } from "node:fs";
4
+ import { lstat, open, rename } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import {
7
+ HelloLynkLocalMessagingError, indexedMessageStoreLegacyInternals as L,
8
+ streamLegacyLocalMessages, replaceLocalStoreFileWithRetry,
9
+ } from "./hellolynk-local-messaging.mjs";
10
+ import { getMessageConversationBinding, getOutgoingConversationIntent,
11
+ normalizeInstanceConversationBinding, instanceConversationKey } from "./hellolynk-instance-conversation.mjs";
12
+ import { HELLOLYNK_WORKBENCH_MAX_QUERY_LENGTH } from "../workbench/contract.mjs";
13
+
14
+ // Local business records are plain JSON payloads. Index columns keep keyed
15
+ // hashes so databases converted from the former encrypted format retain every
16
+ // identifier; the hash key lives in meta and is not a secret.
17
+ export const HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT = "hellolynk-indexed-messages-v2";
18
+ const DELETED = "local-deleted";
19
+ const INDEX_KEY = /^[a-f0-9]{64}$/;
20
+ const MESSAGE_STATUSES = new Set(["received", "fetched", "pending_human_confirmation", "pending", "recipient_offline", "failed_retryable", "failed_permanent", "cancelled", "delivered_to_live_connection", "delivery_state_unknown", "delivery_state_unconfirmed"]);
21
+ const SQL = `
22
+ CREATE TABLE IF NOT EXISTS meta (id INTEGER PRIMARY KEY CHECK(id=1), version INTEGER NOT NULL, payload TEXT NOT NULL);
23
+ CREATE TABLE IF NOT EXISTS messages (
24
+ id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, client TEXT NOT NULL, event TEXT, replay TEXT, sender_client TEXT,
25
+ scope TEXT, thread TEXT, route TEXT, delegate TEXT, direction TEXT NOT NULL, status TEXT NOT NULL,
26
+ created TEXT NOT NULL, updated TEXT NOT NULL, receipt INTEGER, deleted INTEGER NOT NULL, admitted INTEGER NOT NULL,
27
+ unread INTEGER NOT NULL, outgoing_protected INTEGER NOT NULL, bytes INTEGER NOT NULL,
28
+ version INTEGER NOT NULL, payload TEXT NOT NULL);
29
+ CREATE INDEX IF NOT EXISTS messages_client ON messages(client,direction);
30
+ CREATE INDEX IF NOT EXISTS messages_event ON messages(event);
31
+ CREATE INDEX IF NOT EXISTS messages_replay ON messages(replay);
32
+ CREATE INDEX IF NOT EXISTS messages_sender_client ON messages(sender_client);
33
+ CREATE INDEX IF NOT EXISTS messages_page ON messages(scope,thread,deleted,created,seq);
34
+ CREATE INDEX IF NOT EXISTS messages_pending ON messages(delegate,direction,deleted,admitted,receipt);
35
+ CREATE INDEX IF NOT EXISTS messages_pending_route ON messages(delegate,route,direction,deleted,admitted,receipt);
36
+ CREATE INDEX IF NOT EXISTS messages_outbox ON messages(direction,deleted,status,seq);
37
+ CREATE TABLE IF NOT EXISTS claims (id TEXT PRIMARY KEY, message TEXT NOT NULL, delegate TEXT NOT NULL,
38
+ outcome TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL);
39
+ CREATE INDEX IF NOT EXISTS claims_message ON claims(message);
40
+ CREATE INDEX IF NOT EXISTS claims_message_delegate ON claims(message,delegate);
41
+ CREATE INDEX IF NOT EXISTS claims_delegate ON claims(delegate);
42
+ CREATE TABLE IF NOT EXISTS inbox_batches (id TEXT PRIMARY KEY, delegate TEXT NOT NULL, adapter TEXT NOT NULL,
43
+ target TEXT NOT NULL, state TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL);
44
+ CREATE INDEX IF NOT EXISTS inbox_batches_open ON inbox_batches(delegate,adapter,state,target);
45
+ CREATE TABLE IF NOT EXISTS readers (id TEXT PRIMARY KEY, version INTEGER NOT NULL, payload TEXT NOT NULL);
46
+ CREATE TABLE IF NOT EXISTS threads (id TEXT PRIMARY KEY, scope TEXT NOT NULL, updated TEXT NOT NULL,
47
+ archived INTEGER NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,
48
+ deleted INTEGER NOT NULL, delete_through INTEGER NOT NULL, purge_after INTEGER NOT NULL, purge_pending INTEGER NOT NULL);
49
+ CREATE INDEX IF NOT EXISTS threads_scope ON threads(scope,updated);
50
+ CREATE INDEX IF NOT EXISTS threads_purge ON threads(purge_pending,id);
51
+ CREATE TABLE IF NOT EXISTS settings (id TEXT PRIMARY KEY, version INTEGER NOT NULL, payload TEXT NOT NULL);
52
+ PRAGMA user_version=2;`;
53
+ const INDEX_COLUMNS = ["id", "seq", "client", "event", "replay", "sender_client", "scope", "thread", "route", "delegate", "direction", "status", "created", "updated", "receipt", "deleted", "admitted", "unread", "outgoing_protected", "bytes", "version"];
54
+ const fail = (code, options) => { throw new HelloLynkLocalMessagingError(code, options); };
55
+ const clone = (value) => structuredClone(value);
56
+ const emptyCounts = () => ({ inbox: { total: 0, unread: 0 }, outbox: { total: 0, pending: 0, retryEligible: 0, states: {} } });
57
+ function applyCountDelta(counts, index, sign) {
58
+ if (!index || index.deleted) return;
59
+ if (index.direction === "incoming") {
60
+ counts.inbox.total += sign; counts.inbox.unread += sign * index.unread;
61
+ } else {
62
+ counts.outbox.total += sign;
63
+ const publicStatus = index.status === "reply_target_deleted" ? "failed_permanent" : index.status;
64
+ const stateCount = (counts.outbox.states[publicStatus] || 0) + sign;
65
+ if (stateCount) counts.outbox.states[publicStatus] = stateCount;
66
+ else delete counts.outbox.states[publicStatus];
67
+ if (L.BACKGROUND_RETRYABLE_OUTGOING_STATUSES.has(index.status)) counts.outbox.pending += sign;
68
+ if (L.MANUAL_RETRYABLE_OUTGOING_STATUSES.has(index.status)) counts.outbox.retryEligible += sign;
69
+ }
70
+ }
71
+ function addCounts(target, source) {
72
+ for (const field of ["total", "unread"]) target.inbox[field] += source.inbox[field];
73
+ for (const field of ["total", "pending", "retryEligible"]) target.outbox[field] += source.outbox[field];
74
+ for (const [status, count] of Object.entries(source.outbox.states)) target.outbox.states[status] = (target.outbox.states[status] || 0) + count;
75
+ }
76
+ const checkedTime = (value) => {
77
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) fail("local_history_time_invalid");
78
+ return value;
79
+ };
80
+ // Same derivation as the former encrypted index, so converted rows keep their ids.
81
+ function indexToken(key, domain, value) {
82
+ if (!key) fail("local_message_store_not_initialized");
83
+ return crypto.createHmac("sha256", key).update(JSON.stringify([domain, value])).digest("hex");
84
+ }
85
+ const limitOf = (value, maximum) => {
86
+ const number = value ?? maximum;
87
+ if (!Number.isSafeInteger(number) || number < 1 || number > maximum) fail("local_history_limit_invalid");
88
+ return number;
89
+ };
90
+
91
+ export async function createIndexedLocalMessageStore(options = {}) {
92
+ if (options.deferInitialization !== true) return openIndexedLocalMessageStore(options);
93
+ let ready = null, opening = null, stopped = false;
94
+ const facade = { path: options.storePath, archivePath: `${options.storePath}.archive.ndjson`, localHistoryPath: `${options.storePath}.local-history.json`, dbPath: `${options.storePath}.sqlite`,
95
+ async initialize() {
96
+ if (stopped) fail("local_message_store_closed");
97
+ if (ready) return facade;
98
+ opening ||= openIndexedLocalMessageStore({ ...options, deferInitialization: false }).then((store) => { ready = store; return facade; }).catch((error) => { opening = null; throw error; });
99
+ return opening;
100
+ },
101
+ async close() { stopped = true; if (opening) await opening.catch(() => {}); await ready?.close(); },
102
+ };
103
+ for (const name of ["getReceiptHighWater", "getInboxReaderBoundary", "activateInboxReader", "listInboxClaimIds", "claimInboxMessage", "finishInboxClaim", "reserveInboxBatch", "listInboxBatches", "getInboxNativeAssignment", "getInboxNativeAssignments", "attemptInboxBatch", "confirmInboxBatch", "cancelInboxBatch", "expireInboxBatch", "closeInboxBatch", "claimInboxBatch", "readIncomingForModel", "getIncoming", "getOutgoing", "list", "listActive", "queryOutbox", "listPendingInboxCandidates", "getMessageCounts", "addOutgoing", "updateOutgoing", "attachOutgoingE2ee", "claimOutgoingForPreparation", "claimOutgoingForAttempt", "recoverArchivedOutgoingRetryClaim", "recoverAbandonedE2eeOutgoingAttempts", "listPending", "cancelOutgoing", "addIncoming", "findIncomingE2eeReplay", "updateIncoming", "deleteIncoming", "deleteOutgoing", "readWorkbenchSummary", "readWorkbenchRevision", "queryConversationPage", "readLocalHistoryStats", "searchWorkbenchThreads", "configureLocalHistory", "hideLocalConversation", "restoreLocalConversation", "clearLocalConversation", "deleteLocalConversation", "deleteArchivedConversations", "deleteSentLocal", "maintainLocalHistory", "readWorkbenchState", "diagnostics"]) {
104
+ facade[name] = (...args) => { if (!ready) fail("local_message_store_not_initialized"); return ready[name](...args); };
105
+ }
106
+ facade.markIncomingRead = (...args) => { if (!ready) fail("local_message_store_not_initialized"); return ready.markIncomingRead(...args); };
107
+ facade.listInboxMessagesForDelegate = (...args) => { if (!ready) fail("local_message_store_not_initialized"); return ready.listInboxMessagesForDelegate(...args); };
108
+ facade.configureConversationAutoRead = (...args) => { if (!ready) fail("local_message_store_not_initialized"); return ready.configureConversationAutoRead(...args); };
109
+ return facade;
110
+ }
111
+
112
+ // One-time forward conversion of a database written by the former encrypted
113
+ // release. All payloads become plain JSON in one transaction; index columns and
114
+ // ids embedded in payloads stay valid because the former index key moves into
115
+ // meta. A database without encrypted meta is left untouched.
116
+ export async function convertEncryptedIndexedMessageDatabase({ storePath, profileId, isEncrypted, decrypt, deriveIndexKey }) {
117
+ const dbPath = `${path.resolve(storePath)}.sqlite`;
118
+ try { if (!(await lstat(dbPath)).isFile()) fail("local_message_store_invalid"); }
119
+ catch (error) { if (error.code === "ENOENT") return { converted: false, rows: 0 }; throw error; }
120
+ const { DatabaseSync } = await import("node:sqlite");
121
+ const db = new DatabaseSync(dbPath, { timeout: 5000 });
122
+ try {
123
+ const metaRow = db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name='meta'").get()
124
+ && db.prepare("SELECT * FROM meta WHERE id=1").get();
125
+ if (!metaRow || !isEncrypted(JSON.parse(metaRow.payload))) return { converted: false, rows: 0 };
126
+ const context = (kind, id) => ({ profileId, assetKind: "messages", logicalPath: `messages/${profileId}.sqlite/${kind}/${id}` });
127
+ db.exec("PRAGMA synchronous=FULL; BEGIN IMMEDIATE");
128
+ let rows = 0;
129
+ try {
130
+ const meta = await decrypt(JSON.parse(metaRow.payload), context("meta", "root"));
131
+ if (meta?.contract !== HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT || meta.profileId !== profileId) fail("local_message_index_version_unsupported");
132
+ const key = await deriveIndexKey(context("index", "v1"));
133
+ db.prepare("UPDATE meta SET payload=? WHERE id=1").run(JSON.stringify({ ...meta, indexKey: key.toString("hex") }));
134
+ for (const [table, kind] of [["messages", "message"], ["claims", "claim"], ["readers", "reader"], ["threads", "thread"], ["settings", "setting"]]) {
135
+ const select = db.prepare(`SELECT id,payload FROM ${table} WHERE id>? ORDER BY id LIMIT 256`);
136
+ const update = db.prepare(`UPDATE ${table} SET payload=? WHERE id=?`);
137
+ for (let batch = select.all(""); batch.length; batch = select.all(batch.at(-1).id)) {
138
+ for (const row of batch) {
139
+ const value = await decrypt(JSON.parse(row.payload), context(kind, row.id));
140
+ // A wrong index key would orphan every row; prove it on each id.
141
+ const id = kind === "message" ? indexToken(key, "message", String(value?.message?.localMessageId)) : value?.id;
142
+ if (id !== row.id) fail("local_message_index_invalid");
143
+ update.run(JSON.stringify(value), row.id);
144
+ rows++;
145
+ }
146
+ }
147
+ }
148
+ db.exec("COMMIT");
149
+ } catch (error) { db.exec("ROLLBACK"); throw error; }
150
+ return { converted: true, rows };
151
+ } finally { db.close(); }
152
+ }
153
+
154
+ async function openIndexedLocalMessageStore(options = {}) {
155
+ const [major, minor] = process.versions.node.split(".").map(Number);
156
+ if (major < 22 || (major === 22 && minor < 16)) fail("local_message_node_22_16_required");
157
+ if (options.dataProtector != null) fail("local_message_data_protector_unsupported");
158
+ const profileId = L.safeId(options.profileId, "local_message_profile_id_invalid");
159
+ const storePath = path.resolve(options.storePath || "");
160
+ if (!options.storePath || !path.isAbsolute(options.storePath)) fail("local_message_store_path_required");
161
+ const dbPath = `${storePath}.sqlite`, archivePath = `${storePath}.archive.ndjson`, localHistoryPath = `${storePath}.local-history.json`;
162
+ const now = options.now || (() => Date.now());
163
+ let indexKey = null;
164
+ const token = (domain, value) => indexToken(indexKey, domain, String(value));
165
+ const scopeKey = (scope) => token("scope", L.localHistoryScopeKey(L.localHistoryScope(scope)));
166
+ const threadKey = (scope, threadId) => token("thread", `${L.localHistoryScopeKey(L.localHistoryScope(scope))}\0${L.safeId(threadId, "local_history_thread_invalid")}`);
167
+ const ownerScope = options.ownerScope ? L.localHistoryScope({ ...options.ownerScope, delegateId: "legacy-unbound" }) : null;
168
+ function legacyThreadId(message, scope) {
169
+ const peerPrincipalId = message.direction === "incoming" ? (message.peerPrincipalId ?? message.senderPrincipalId) : (message.peerPrincipalId ?? message.recipientPrincipalId);
170
+ const peerHelloLynkId = message.direction === "incoming" ? (message.peerHelloLynkId ?? message.senderHelloLynkId) : (message.recipientHelloLynkId ?? message.peerHelloLynkId);
171
+ if (!peerPrincipalId && !peerHelloLynkId) fail("local_message_legacy_peer_invalid");
172
+ const peer = peerHelloLynkId == null ? `principal:${peerPrincipalId}` : `hellolynk:${peerHelloLynkId}`;
173
+ return `thread_${crypto.createHash("sha256").update(`${scope.region}\0${scope.principalId}\0${peer}`).digest("hex").slice(0, 40)}`;
174
+ }
175
+ function messageOwner(message) {
176
+ const binding = getMessageConversationBinding(message) || getOutgoingConversationIntent(message);
177
+ if (binding) {
178
+ if (ownerScope && (binding.region !== ownerScope.region || binding.localPrincipalId !== ownerScope.principalId)) fail("local_message_scope_mismatch");
179
+ const scope = { region: binding.region, principalId: binding.localPrincipalId, delegateId: binding.localDelegateId };
180
+ return { binding, scope, threadId: instanceConversationKey(binding), legacy: false };
181
+ }
182
+ if (!ownerScope) fail("local_message_legacy_owner_required");
183
+ if (message.direction === "outgoing" && message.senderPrincipalId !== ownerScope.principalId) fail("local_message_legacy_owner_invalid");
184
+ return { binding: null, scope: ownerScope, threadId: legacyThreadId(message, ownerScope), legacy: true };
185
+ }
186
+ function readScopeKeys(input) {
187
+ const scope = L.localHistoryScope(input), values = [scopeKey(scope)];
188
+ if (ownerScope && ownerScope.region === scope.region && ownerScope.principalId === scope.principalId) values.push(scopeKey(ownerScope));
189
+ return [...new Set(values)];
190
+ }
191
+ const metrics = { messageReads: 0, claimReads: 0, recordReads: 0, payloadBytesRead: 0, messageQueries: 0, messageAggregateQueries: 0 };
192
+ let db, identity, closed = false;
193
+ const { DatabaseSync } = await import("node:sqlite");
194
+ const encode = (value) => JSON.stringify(value);
195
+ const decode = (kind, payload) => {
196
+ metrics.recordReads++; metrics.payloadBytesRead += Buffer.byteLength(payload);
197
+ const value = JSON.parse(payload);
198
+ // Synthetic tests observe real message reads to interleave mutations.
199
+ if (kind === "message") { metrics.messageReads++; options.onMessageRead?.(value.message); }
200
+ if (kind === "claim") metrics.claimReads++;
201
+ return value;
202
+ };
203
+ async function assertFiles() {
204
+ await L.assertMessageStoreAccess(dbPath, null, { create: true });
205
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
206
+ let stat; try { stat = await lstat(`${dbPath}${suffix}`); } catch (error) { if (error.code === "ENOENT" && suffix) continue; throw error; }
207
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail("local_message_store_invalid");
208
+ if (!suffix && identity && (stat.dev !== identity.dev || stat.ino !== identity.ino)) fail("local_message_store_replaced");
209
+ }
210
+ }
211
+ function observeRead(sql) {
212
+ if (/\bFROM\s+messages\b/i.test(sql)) {
213
+ metrics.messageQueries++;
214
+ if (/\b(count|sum)\s*\(|\bGROUP BY\b/i.test(sql)) metrics.messageAggregateQueries++;
215
+ }
216
+ }
217
+ const get = (sql, ...params) => { observeRead(sql); return db.prepare(sql).get(...params); };
218
+ const all = (sql, ...params) => { observeRead(sql); return db.prepare(sql).all(...params); };
219
+ const run = (sql, ...params) => db.prepare(sql).run(...params);
220
+ async function readMeta() {
221
+ const row = get("SELECT * FROM meta WHERE id=1");
222
+ if (!row) fail("local_message_index_invalid");
223
+ const value = decode("meta", row.payload);
224
+ if (value?.contract !== HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT || value.profileId !== profileId || value.version !== row.version
225
+ || !Number.isSafeInteger(value.highWater) || value.highWater < 0 || !Number.isSafeInteger(value.sequence) || value.sequence < 0
226
+ || typeof value.migrated !== "boolean" || value.countsVersion !== 1 || !value.globalCounts || !value.scopeCounts
227
+ || typeof value.indexKey !== "string" || !INDEX_KEY.test(value.indexKey)) fail("local_message_index_invalid");
228
+ return value;
229
+ }
230
+ function messageIndex(message, seq, version, claimAnchors = []) {
231
+ L.validateCurrentMessage(message);
232
+ if (!MESSAGE_STATUSES.has(message.status)) fail("local_message_status_invalid");
233
+ const { binding, scope, threadId } = messageOwner(message);
234
+ const deleted = message.messageType === DELETED;
235
+ return {
236
+ id: token("message", message.localMessageId), seq, client: token("client", message.clientMessageId),
237
+ event: message.eventId ? token("event", message.eventId) : null,
238
+ replay: message.senderIdentityKeyId ? token("replay", `${message.senderIdentityKeyId}\0${message.clientMessageId}`) : null,
239
+ sender_client: message.direction === "incoming" ? token("sender-client", `${message.peerPrincipalId}\0${message.clientMessageId}`) : null,
240
+ scope: scopeKey(scope), thread: threadKey(scope, threadId),
241
+ route: binding ? token("route", instanceConversationKey(binding)) : null,
242
+ delegate: binding ? token("delegate", binding.localDelegateId) : (message.direction === "outgoing" ? token("delegate", message.senderDelegateId) : null),
243
+ // A definite deleted-target rejection has the public failed status, but
244
+ // its authenticated index must never count it as a generic retry action.
245
+ direction: message.direction, status: message.status === "failed_permanent" && message.lastErrorCode === "reply_target_deleted" ? "reply_target_deleted" : message.status,
246
+ created: checkedTime(message.createdAt), updated: checkedTime(message.updatedAt),
247
+ receipt: message.receiptSeq ?? null, deleted: Number(deleted), admitted: Number(claimAnchors.length > 0), unread: Number(!deleted && L.localHistoryUnread(message)),
248
+ outgoing_protected: Number(!deleted && message.direction === "outgoing" && !L.localHistorySent(message)),
249
+ bytes: deleted ? 0 : Buffer.byteLength(message.text || "", "utf8"), version,
250
+ };
251
+ }
252
+ async function verifiedMessageIndex(row, record) {
253
+ if (!record || !record.message) fail("local_message_index_invalid");
254
+ const expected = messageIndex(record.message, row.seq, row.version, record.claimAnchors || []);
255
+ if (JSON.stringify(record.index) !== JSON.stringify(expected)) fail("local_message_index_invalid");
256
+ // An authenticated deletion boundary immediately hides the old conversation.
257
+ // Body redaction then proceeds in small, recoverable transactions.
258
+ const threadRow = row.thread && get("SELECT * FROM threads WHERE id=?", row.thread);
259
+ if (row.thread && !threadRow) fail("local_message_index_invalid");
260
+ const thread = threadRow ? await openRecord("thread", threadRow) : null;
261
+ const conversationDeleted = (thread?.deleteThrough ?? 0) >= row.seq;
262
+ if (conversationDeleted) {
263
+ Object.assign(expected, { deleted: 1, unread: 0, outgoing_protected: 0, bytes: 0 });
264
+ }
265
+ if (INDEX_COLUMNS.some((key) => row[key] !== expected[key])) fail("local_message_index_invalid");
266
+ return { ...expected, conversationDeleted };
267
+ }
268
+ async function openMessage(row) {
269
+ if (!row) return null;
270
+ const record = decode("message", row.payload);
271
+ const verified = await verifiedMessageIndex(row, record);
272
+ // The message record owns terminal admission facts. A deleted or
273
+ // misindexed auxiliary claim cannot turn a consumed receipt into new work.
274
+ for (const anchor of record.claimAnchors || []) {
275
+ const claimRow = get("SELECT * FROM claims WHERE id=?", anchor.id);
276
+ if (!claimRow) fail("local_message_claim_anchor_invalid");
277
+ const claim = await openRecord("claim", claimRow);
278
+ if (claim.localMessageId !== record.message.localMessageId || Object.keys(anchor).some((key) => anchor[key] !== claim[key])) fail("local_message_claim_anchor_invalid");
279
+ }
280
+ if (verified.conversationDeleted) return { ...L.localHistoryTombstone(record.message, messageOwner(record.message).scope), conversationDeleted: true };
281
+ if (row.friend_target == null) return record.message;
282
+ const target = JSON.parse(row.friend_target);
283
+ if (target?.heldTargetId != null) return L.validateCurrentMessage({ ...record.message, friendTargetHeld: true,
284
+ targetConversationId: nativeConversationId(target.heldTargetId) });
285
+ // Only a Host with the title lookup can use the saved encryption inputs.
286
+ return L.validateCurrentMessage({ ...record.message, targetConversationTitle: nativeConversationTitle(target?.title),
287
+ e2ee: target.e2ee ?? null, ...(target.preparation ? { e2eePreparation: target.preparation } : {}) });
288
+ }
289
+ async function openRecord(kind, row) {
290
+ if (!row) return null;
291
+ const value = decode(kind, row.payload);
292
+ if (value?.id !== row.id || value.version !== row.version) fail("local_message_index_invalid");
293
+ if (kind === "claim" && (value.outcome !== row.outcome || token("message", value.localMessageId) !== row.message || token("delegate", value.delegateId) !== row.delegate)) fail("local_message_index_invalid");
294
+ if (kind === "batch" && (token("delegate", value.delegateId) !== row.delegate || value.adapterId !== row.adapter || value.target !== row.target || value.state !== row.state)) fail("local_message_index_invalid");
295
+ if (kind === "thread" && (scopeKey(value.scope) !== row.scope || threadKey(value.scope, value.threadId) !== row.id
296
+ || value.updatedAt !== row.updated || Number(value.archived) !== row.archived
297
+ || Number(value.deleted === true) !== row.deleted || (value.deleteThrough ?? 0) !== row.delete_through
298
+ || (value.purgeAfter ?? 0) !== row.purge_after || Number(value.purgePending === true) !== row.purge_pending)) fail("local_message_index_invalid");
299
+ return value;
300
+ }
301
+ async function locked(callback) {
302
+ if (closed) fail("local_message_store_closed");
303
+ return L.withPathMutationQueue(storePath, async () => { await assertFiles(); return callback(await readMeta()); });
304
+ }
305
+ function rawById(id) { return get("SELECT * FROM messages WHERE id=?", token("message", L.safeId(id, "local_message_id_invalid"))); }
306
+ function rawOutgoing(id) { return get("SELECT * FROM messages WHERE client=? AND direction='outgoing'", token("client", L.safeId(id, "client_message_id_invalid"))); }
307
+ async function threadRecord(scope, threadId) {
308
+ return openRecord("thread", get("SELECT * FROM threads WHERE id=?", threadKey(scope, threadId)));
309
+ }
310
+ function selectThreadScope(scope, threadId) {
311
+ if (ownerScope && readScopeKeys(scope).includes(scopeKey(ownerScope)) && get("SELECT 1 FROM threads WHERE id=?", threadKey(ownerScope, threadId))) return ownerScope;
312
+ return L.localHistoryScope(scope);
313
+ }
314
+ // Caller holds the cross-process store lock across asynchronous cryptography.
315
+ // SQLite transactions themselves contain only synchronous prepared operations.
316
+ async function commit(meta, change = {}) {
317
+ const nextMeta = clone({ ...meta, ...(change.meta || {}), version: meta.version + 1 });
318
+ const messages = [], records = [];
319
+ const updatedThreads = new Map();
320
+ const changedMessages = new Map((change.messages || []).map((item) => [item.localMessageId, item]));
321
+ for (const claim of [...(change.claims || []), ...(change.deleteClaims || [])]) {
322
+ if (!changedMessages.has(claim.localMessageId)) {
323
+ const message = await openMessage(rawById(claim.localMessageId));
324
+ if (!message) fail("local_message_claim_anchor_invalid");
325
+ changedMessages.set(claim.localMessageId, message);
326
+ }
327
+ }
328
+ for (const message of changedMessages.values()) {
329
+ const id = token("message", message.localMessageId), previous = get("SELECT * FROM messages WHERE id=?", id);
330
+ const seq = previous?.seq ?? ++nextMeta.sequence;
331
+ const previousRecord = previous ? decode("message", previous.payload) : null;
332
+ const previousIndex = previous ? await verifiedMessageIndex(previous, previousRecord) : null;
333
+ const anchors = new Map((previousRecord?.claimAnchors || []).map((claim) => [claim.id, claim]));
334
+ for (const claim of change.deleteClaims || []) if (claim.localMessageId === message.localMessageId) anchors.delete(claim.id);
335
+ for (const claim of change.claims || []) if (claim.localMessageId === message.localMessageId) anchors.set(claim.id, clone(claim));
336
+ const claimAnchors = [...anchors.values()];
337
+ const { base, friendTarget } = storedMessage(message);
338
+ const index = messageIndex(base, seq, (previous?.version ?? 0) + 1, claimAnchors);
339
+ for (const [record, sign] of [[previousIndex, -1], [index, 1]]) {
340
+ if (!record || record.deleted) continue;
341
+ applyCountDelta(nextMeta.globalCounts, record, sign);
342
+ applyCountDelta(nextMeta.scopeCounts[record.scope] ||= emptyCounts(), record, sign);
343
+ }
344
+ messages.push({ ...index, payload: encode({ message: base, index, claimAnchors }), friend_target: friendTarget,
345
+ previousVersion: previous?.version ?? null });
346
+ const movedThread = previousIndex != null && previousIndex.thread !== index.thread;
347
+ if (movedThread && previousIndex.thread) {
348
+ const oldThread = updatedThreads.get(previousIndex.thread) || await openRecord("thread", get("SELECT * FROM threads WHERE id=?", previousIndex.thread));
349
+ if (!oldThread) fail("local_message_index_invalid");
350
+ updatedThreads.set(oldThread.id, { ...oldThread,
351
+ unreadCount: oldThread.unreadCount - previousIndex.unread,
352
+ outgoingProtectedCount: oldThread.outgoingProtectedCount - previousIndex.outgoing_protected,
353
+ taskProtectedCount: 0 });
354
+ }
355
+ if (index.thread) {
356
+ const { binding, scope, threadId, legacy } = messageOwner(message);
357
+ let thread = updatedThreads.get(index.thread) || await threadRecord(scope, threadId);
358
+ if (!thread) thread = { id: index.thread, version: 0, scope, binding, legacy, threadId,
359
+ peerPrincipalId: binding?.peerPrincipalId ?? message.peerPrincipalId ?? (message.direction === "incoming" ? message.senderPrincipalId : null),
360
+ peerHelloLynkId: message.peerHelloLynkId ?? (message.direction === "incoming" ? message.senderHelloLynkId : message.recipientHelloLynkId) ?? null,
361
+ archived: false, updatedAt: message.updatedAt,
362
+ unreadCount: 0, outgoingProtectedCount: 0, taskProtectedCount: 0 };
363
+ // A new or newly bound message restores the receiving conversation.
364
+ thread = { ...thread, archived: index.deleted || (previous && !movedThread) ? thread.archived : false,
365
+ deleted: index.deleted || (previous && !movedThread) ? thread.deleted : false,
366
+ updatedAt: thread.updatedAt > message.updatedAt ? thread.updatedAt : message.updatedAt,
367
+ unreadCount: thread.unreadCount + index.unread - (movedThread ? 0 : previousIndex?.unread ?? 0),
368
+ outgoingProtectedCount: thread.outgoingProtectedCount + index.outgoing_protected - (movedThread ? 0 : previousIndex?.outgoing_protected ?? 0),
369
+ taskProtectedCount: 0 };
370
+ updatedThreads.set(index.thread, thread);
371
+ }
372
+ }
373
+ for (const thread of change.threads || []) updatedThreads.set(thread.id, thread);
374
+ for (const thread of change.deleteThreads || []) {
375
+ for (const group of all("SELECT direction,status,count(*) AS n,sum(unread) AS unread FROM messages WHERE thread=? AND seq<=? AND deleted=0 GROUP BY direction,status", thread.id, thread.deleteThrough)) {
376
+ for (const counts of [nextMeta.globalCounts, nextMeta.scopeCounts[scopeKey(thread.scope)]]) {
377
+ if (!counts) fail("local_message_index_invalid");
378
+ if (group.direction === "incoming") { counts.inbox.total -= group.n; counts.inbox.unread -= group.unread; }
379
+ else applyCountDelta(counts, { ...group, deleted: 0 }, -group.n);
380
+ }
381
+ }
382
+ updatedThreads.set(thread.id, { ...thread, unreadCount: 0, outgoingProtectedCount: 0, taskProtectedCount: 0 });
383
+ }
384
+ for (const thread of updatedThreads.values()) records.push({ kind: "thread", value: { ...thread, version: thread.version + 1 } });
385
+ for (const value of change.claims || []) records.push({ kind: "claim", value });
386
+ for (const value of change.readers || []) records.push({ kind: "reader", value });
387
+ for (const value of change.settings || []) records.push({ kind: "setting", value });
388
+ for (const value of change.batches || []) records.push({ kind: "batch", value });
389
+ for (const record of records) record.payload = encode(record.value);
390
+ const metaPayload = encode(nextMeta);
391
+ await options.fault?.("before-commit");
392
+ db.exec("BEGIN IMMEDIATE");
393
+ try {
394
+ if (get("SELECT version FROM meta WHERE id=1").version !== meta.version) fail("local_message_store_conflict", { retryable: true });
395
+ for (const thread of change.deleteThreads || []) run("UPDATE messages SET deleted=1,unread=0,outgoing_protected=0,bytes=0 WHERE thread=? AND seq<=?", thread.id, thread.deleteThrough);
396
+ const columns = [...INDEX_COLUMNS, "payload", "friend_target"];
397
+ for (const row of messages) {
398
+ const current = get("SELECT version FROM messages WHERE id=?", row.id);
399
+ if ((current?.version ?? null) !== row.previousVersion) fail("local_message_store_conflict", { retryable: true });
400
+ run(`INSERT INTO messages(${columns.join(",")}) VALUES(${columns.map(() => "?").join(",")}) ON CONFLICT(id) DO UPDATE SET ${columns.filter((key) => key !== "id").map((key) => `${key}=excluded.${key}`).join(",")}`, ...columns.map((key) => row[key]));
401
+ }
402
+ for (const { kind, value, payload } of records) {
403
+ if (kind === "thread") run("INSERT INTO threads VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET scope=excluded.scope,updated=excluded.updated,archived=excluded.archived,version=excluded.version,payload=excluded.payload,deleted=excluded.deleted,delete_through=excluded.delete_through,purge_after=excluded.purge_after,purge_pending=excluded.purge_pending", value.id, scopeKey(value.scope), value.updatedAt, Number(value.archived), value.version, payload, Number(value.deleted === true), value.deleteThrough ?? 0, value.purgeAfter ?? 0, Number(value.purgePending === true));
404
+ else if (kind === "claim") run("INSERT INTO claims VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET outcome=excluded.outcome,version=excluded.version,payload=excluded.payload", value.id, token("message", value.localMessageId), token("delegate", value.delegateId), value.outcome, value.version, payload);
405
+ else if (kind === "batch") run("INSERT INTO inbox_batches VALUES(?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state,version=excluded.version,payload=excluded.payload", value.id, token("delegate", value.delegateId), value.adapterId, value.target, value.state, value.version, payload);
406
+ else run(`INSERT INTO ${kind === "reader" ? "readers" : "settings"} VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,payload=excluded.payload`, value.id, value.version, payload);
407
+ }
408
+ for (const value of change.deleteClaims || []) run("DELETE FROM claims WHERE id=?", value.id);
409
+ for (const value of change.deleteBatches || []) run("DELETE FROM inbox_batches WHERE id=?", value.id);
410
+ run("UPDATE meta SET version=?,payload=? WHERE id=1", nextMeta.version, metaPayload);
411
+ // Synchronous fault injection exercises rollback without an awaited transaction.
412
+ options.transactionFault?.("before-commit");
413
+ db.exec("COMMIT");
414
+ } catch (error) { db.exec("ROLLBACK"); throw error; }
415
+ await options.fault?.("after-commit");
416
+ return nextMeta;
417
+ }
418
+
419
+ function storedMessage(message) {
420
+ if (message.targetConversationTitle) {
421
+ L.validateCurrentMessage(message);
422
+ const { targetConversationTitle: title, e2ee, e2eePreparation: preparation, ...base } = message;
423
+ // The previous Host still reads the text, status and peer. It cannot
424
+ // reuse this ciphertext or prepare a send without confirming the title.
425
+ return { base: { ...base, e2ee: null }, friendTarget: encode({ title, e2ee, preparation }) };
426
+ }
427
+ if (message.friendTargetHeld) {
428
+ L.validateCurrentMessage(message);
429
+ const { friendTargetHeld: _held, targetConversationId: heldTargetId, ...base } = message;
430
+ // Old readers use the target ID to authorize a manual read. Leave both
431
+ // that assignment and automatic wake off in the compatible record.
432
+ return { base: { ...base, automaticDeliveryState: "expired" }, friendTarget: encode({ heldTargetId }) };
433
+ }
434
+ return { base: message, friendTarget: null };
435
+ }
436
+ async function initialize() {
437
+ await L.assertMessageStoreAccess(dbPath, null, { create: true });
438
+ let created = false;
439
+ try { const handle = await open(dbPath, "wx", 0o600); await handle.close(); created = true; await options.fault?.("database-created"); } catch (error) { if (error.code !== "EEXIST") throw error; }
440
+ await assertFiles(); identity = await lstat(dbPath);
441
+ db = new DatabaseSync(dbPath, { timeout: 5000 });
442
+ if (get("SELECT 1 FROM sqlite_schema WHERE type='table' LIMIT 1") && get("PRAGMA user_version").user_version !== 2) {
443
+ fail("local_message_index_version_unsupported");
444
+ }
445
+ // Pointer maps must be enabled before the first table exists. Never run a
446
+ // whole-database VACUUM to retrofit an already-created database.
447
+ if (!get("SELECT 1 FROM sqlite_schema WHERE type='table' LIMIT 1")) {
448
+ db.exec("PRAGMA auto_vacuum=INCREMENTAL;");
449
+ if (get("PRAGMA auto_vacuum").auto_vacuum !== 2) fail("local_message_index_vacuum_mode_invalid");
450
+ }
451
+ db.exec("PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA temp_store=MEMORY; PRAGMA trusted_schema=OFF;");
452
+ db.exec(SQL);
453
+ // Keep the previous release's validated message shape and indexes. Its
454
+ // explicit upserts preserve this optional column. Its deletes erase it too.
455
+ if (!all("PRAGMA table_info(messages)").some(column => column.name === "friend_target"))
456
+ db.exec("ALTER TABLE messages ADD COLUMN friend_target TEXT");
457
+ db.exec(`CREATE TRIGGER IF NOT EXISTS message_deleted_friend_target
458
+ AFTER UPDATE OF deleted ON messages WHEN NEW.deleted=1 AND NEW.friend_target IS NOT NULL
459
+ BEGIN UPDATE messages SET friend_target=NULL WHERE id=NEW.id; END;`);
460
+ await options.fault?.("schema-created");
461
+ if (!get("SELECT 1 FROM meta")) {
462
+ if (!created) {
463
+ if (await isLegacyFenced()) fail("local_message_index_metadata_missing");
464
+ // Before the format fence the legacy input is authoritative; a crash
465
+ // can leave an empty DB or a schema-only staging database.
466
+ await L.readStore(storePath, profileId, L.MAX_STORE_BYTES, null);
467
+ }
468
+ const value = { contract: HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT, profileId, version: 0, highWater: 0, sequence: 0, migrated: false,
469
+ countsVersion: 1, globalCounts: emptyCounts(), scopeCounts: {}, indexKey: crypto.randomBytes(32).toString("hex") };
470
+ run("INSERT INTO meta VALUES(1,0,?)", encode(value));
471
+ }
472
+ let meta = await readMeta();
473
+ indexKey = Buffer.from(meta.indexKey, "hex");
474
+ if (!meta.migrated && meta.migrationPrepared === true) {
475
+ const fenced = await isLegacyFenced();
476
+ if (fenced || meta.sourceDigest === await legacySourceDigest()) {
477
+ if (!fenced) await installLegacyFence();
478
+ meta = await commit(meta, { meta: { migrated: true, migrationPrepared: false } });
479
+ }
480
+ }
481
+ if (!meta.migrated) {
482
+ // An interrupted staging import is rebuilt from untouched legacy inputs.
483
+ db.exec("BEGIN IMMEDIATE; DELETE FROM messages; DELETE FROM claims; DELETE FROM readers; DELETE FROM threads; DELETE FROM settings; COMMIT;");
484
+ meta = await commit(meta, { meta: { highWater: 0, sequence: 0, globalCounts: emptyCounts(), scopeCounts: {} } });
485
+ let legacyMetadata, batch = [], previousSource;
486
+ for await (const record of streamLegacyLocalMessages(options)) {
487
+ if (record.kind === "metadata") { legacyMetadata = record; continue; }
488
+ // Materialize the latest archive record before considering main-file
489
+ // retry claims. The old store persisted its unknown claim before
490
+ // removing the archive proof, so that exact interrupted state is safe
491
+ // to recover without attempting delivery.
492
+ if (previousSource === "archive" && record.source !== "archive" && batch.length) {
493
+ meta = await commit(meta, { messages: batch }); batch = [];
494
+ }
495
+ previousSource = record.source;
496
+ let message = record.message;
497
+ if (record.source === "main" && message.direction === "outgoing") {
498
+ const unknown = message.status === "delivery_state_unknown" && message.lastErrorCode === "awaiting_relay_result";
499
+ const interrupted = message.status === "delivery_state_unconfirmed" && message.lastErrorCode === "e2ee_delivery_attempt_interrupted";
500
+ if ((unknown && !L.deliveryAttemptHasLiveOwner(message)) || interrupted) {
501
+ const retained = await openMessage(rawById(message.localMessageId));
502
+ if (retained?.direction === "outgoing" && retained.clientMessageId === message.clientMessageId && retained.status === "failed_permanent") message = retained;
503
+ }
504
+ }
505
+ batch.push(message);
506
+ if (batch.length === 256) { meta = await commit(meta, { messages: batch }); batch = []; await options.fault?.("migration-batch"); }
507
+ }
508
+ if (batch.length) meta = await commit(meta, { messages: batch });
509
+ const old = legacyMetadata?.store || {};
510
+ const claims = (old.inboxClaims || []).map((value) => ({ ...value, id: token("claim", `${value.delegateId}\0${value.localMessageId}`), version: 1 }));
511
+ const readers = (old.inboxReaders || []).map((value) => ({ ...value, id: token("reader", `${value.adapterId}\0${value.delegateId}`), version: 1 }));
512
+ const highest = get("SELECT coalesce(max(receipt),0) AS n FROM messages").n;
513
+ if (highest > (old.receiptHighWater ?? 0)) fail("local_message_receipt_sequence_invalid");
514
+ for (let offset = 0; offset < claims.length; offset += 256) meta = await commit(meta, { claims: claims.slice(offset, offset + 256) });
515
+ meta = await commit(meta, { readers, meta: { highWater: old.receiptHighWater ?? 0 } });
516
+ for (const hidden of legacyMetadata?.history?.hidden || []) {
517
+ const thread = await threadRecord(hidden, hidden.threadId);
518
+ if (thread) {
519
+ const known = new Set(hidden.knownMessageIds.map((id) => token("message", id)));
520
+ const changed = all("SELECT id FROM messages WHERE scope=? AND thread=?", scopeKey(hidden), thread.id).some((row) => !known.has(row.id));
521
+ if (!changed && !thread.unreadCount && !thread.outgoingProtectedCount) meta = await commit(meta, { threads: [{ ...thread, archived: true }] });
522
+ }
523
+ }
524
+ const integrity = get("PRAGMA integrity_check");
525
+ if (Object.values(integrity)[0] !== "ok") fail("local_message_index_invalid");
526
+ meta = await commit(meta, { meta: { migrationPrepared: true, sourceDigest: await legacySourceDigest() } });
527
+ await options.fault?.("migration-before-switch");
528
+ await installLegacyFence();
529
+ await options.fault?.("migration-after-fence");
530
+ meta = await commit(meta, { meta: { migrated: true, migrationPrepared: false } });
531
+ }
532
+ if (!await isLegacyFenced()) fail("local_message_migration_fence_missing");
533
+ await purgeDeletedBatch();
534
+ }
535
+
536
+ async function purgeDeletedBatch(threadKeyValue = null) {
537
+ const row = threadKeyValue == null
538
+ ? get("SELECT * FROM threads WHERE purge_pending=1 ORDER BY id LIMIT 1")
539
+ : get("SELECT * FROM threads WHERE id=? AND purge_pending=1", threadKeyValue);
540
+ if (!row) return false;
541
+ const thread = await openRecord("thread", row), meta = await readMeta();
542
+ const rows = all("SELECT * FROM messages WHERE thread=? AND seq>? AND seq<=? ORDER BY seq LIMIT 100", thread.id, thread.purgeAfter ?? 0, thread.deleteThrough);
543
+ const messages = [];
544
+ for (const message of rows) messages.push(await openMessage(message));
545
+ const done = rows.length < 100;
546
+ await commit(meta, { messages, threads: [{ ...thread, purgeAfter: rows.at(-1)?.seq ?? thread.purgeAfter ?? 0, purgePending: !done }] });
547
+ await options.fault?.("conversation-delete-batch");
548
+ return !done;
549
+ }
550
+ async function legacySourceDigest() {
551
+ const hash = crypto.createHash("sha256");
552
+ for (const file of [storePath, archivePath, localHistoryPath]) {
553
+ let stat; try { stat = await lstat(file); } catch (error) { if (error.code === "ENOENT") { hash.update("absent\0"); continue; } throw error; }
554
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail("local_message_store_invalid");
555
+ hash.update(`${stat.size}\0`);
556
+ for await (const chunk of createReadStream(file)) hash.update(chunk);
557
+ }
558
+ return hash.digest("hex");
559
+ }
560
+ async function isLegacyFenced() {
561
+ try {
562
+ const raw = await readFenceInput(storePath);
563
+ const value = JSON.parse(raw.toString("utf8"));
564
+ return value?.contract === HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT && value.storage === "sqlite" && value.profileId === profileId;
565
+ } catch (error) { if (error.code === "ENOENT") return false; throw error; }
566
+ }
567
+ async function readFenceInput(file) {
568
+ await L.assertMessageStoreAccess(file, null, { create: false });
569
+ const before = await lstat(file);
570
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 16 * 1024 * 1024) fail("local_message_store_invalid");
571
+ const handle = await open(file, "r");
572
+ try {
573
+ const opened = await handle.stat();
574
+ if (!opened.isFile() || opened.ino !== before.ino || opened.size !== before.size || (process.platform !== "win32" && opened.dev !== before.dev)) fail("local_message_store_invalid");
575
+ const bytes = await handle.readFile();
576
+ if (bytes.length > 16 * 1024 * 1024) fail("local_message_store_invalid");
577
+ return bytes;
578
+ } finally { await handle.close(); }
579
+ }
580
+ async function installLegacyFence() {
581
+ let raw; try { raw = await readFenceInput(storePath); } catch (error) { if (error.code !== "ENOENT") throw error; }
582
+ if (raw) {
583
+ const current = JSON.parse(raw.toString("utf8"));
584
+ if (current?.contract === HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT && current.storage === "sqlite") return;
585
+ const backup = `${storePath}.pre-indexed.json`;
586
+ let present = false;
587
+ try { if (!(await readFenceInput(backup)).equals(raw)) fail("local_message_migration_backup_conflict"); present = true; }
588
+ catch (error) { if (error.code !== "ENOENT") throw error; }
589
+ if (!present) {
590
+ const staging = migrationTemporary("backup");
591
+ const copy = await open(staging, "wx", 0o600);
592
+ try { await copy.writeFile(raw); await copy.sync(); } finally { await copy.close(); }
593
+ await options.fault?.("migration-backup-staged");
594
+ await rename(staging, backup); await L.syncParentDirectory(backup); await L.syncParentDirectory(staging);
595
+ }
596
+ }
597
+ await writeIndexedFence();
598
+ }
599
+ async function writeIndexedFence() {
600
+ const temporary = migrationTemporary("fence");
601
+ const handle = await open(temporary, "wx", 0o600);
602
+ try { await handle.writeFile(JSON.stringify({ contract: HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT, storage: "sqlite", profileId })); await handle.sync(); } finally { await handle.close(); }
603
+ await replaceLocalStoreFileWithRetry(temporary, storePath);
604
+ await L.syncParentDirectory(storePath);
605
+ }
606
+ function migrationTemporary(kind) {
607
+ const directory = path.dirname(storePath), parent = path.basename(directory) === "messages" ? path.dirname(directory) : directory;
608
+ return path.join(parent, `.hellolynk-indexed-${profileId}-${kind}-${crypto.randomUUID()}.tmp`);
609
+ }
610
+
611
+ try { await L.withPathMutationQueue(storePath, initialize); }
612
+ catch (error) { try { db?.close(); } catch {} throw error; }
613
+
614
+ async function mutateMessage(id, outgoing, callback, allowDeleted = false) {
615
+ return locked(async (meta) => {
616
+ const row = outgoing ? rawOutgoing(id) : rawById(id), message = await openMessage(row);
617
+ if (!message || message.direction !== (outgoing ? "outgoing" : "incoming")) fail(outgoing ? "local_outgoing_message_not_found" : "local_incoming_message_not_found");
618
+ if (message.messageType === DELETED && !allowDeleted) throw L.localHistoryFailure("record_not_found");
619
+ const changed = await callback(message);
620
+ if (changed !== false) await commit(meta, { messages: [message] });
621
+ return clone(message);
622
+ });
623
+ }
624
+ function reclaimUnusedPages() {
625
+ // A maintenance tick frees at most 64 freelist pages. Logical UTF-8 body
626
+ // bytes reported by history stats are not a promise of equal disk savings:
627
+ // retained facts, occupied pages and live WAL readers affect reclamation.
628
+ const before = get("PRAGMA freelist_count").freelist_count;
629
+ let checkpointBusy = false;
630
+ db.exec("PRAGMA busy_timeout=0;");
631
+ try {
632
+ if (before > 0) db.exec("PRAGMA incremental_vacuum(64);");
633
+ // With a zero busy timeout this never waits for an active reader/writer;
634
+ // SQLite leaves its durable WAL in place and a later tick retries.
635
+ checkpointBusy = get("PRAGMA wal_checkpoint(TRUNCATE)").busy !== 0;
636
+ } catch (error) {
637
+ if (![5, 6].includes(Number(error.errcode) & 255)) throw error;
638
+ checkpointBusy = true;
639
+ } finally { db.exec("PRAGMA busy_timeout=5000;"); }
640
+ return { reclaimedPageCount: Math.max(0, before - get("PRAGMA freelist_count").freelist_count), checkpointBusy };
641
+ }
642
+ function scopeWhere(scope) { if (!scope) return ["", []]; const keys = readScopeKeys(scope); return [` AND scope IN (${keys.map(() => "?").join(",")})`, keys]; }
643
+ async function readMessages(sql, ...args) {
644
+ const result = [];
645
+ for (const row of all(sql, ...args)) result.push(await openMessage(row));
646
+ return result;
647
+ }
648
+ async function protectedReason(message) {
649
+ if (L.localHistoryUnread(message)) return "unread";
650
+ if (message.direction === "outgoing" && !L.localHistorySent(message)) return "outgoing";
651
+ return null;
652
+ }
653
+ function assertScope(message, scope) {
654
+ scope = L.localHistoryScope(scope);
655
+ if (L.localHistoryBinding(message, scope)) return;
656
+ const owner = messageOwner(message);
657
+ if (owner.legacy && ownerScope && scope.region === ownerScope.region && scope.principalId === ownerScope.principalId) return;
658
+ throw L.localHistoryFailure("record_not_found");
659
+ }
660
+ async function readerBoundary(adapterId, delegateId) {
661
+ L.inboxReaderId(adapterId); L.inboxReaderId(delegateId);
662
+ const id = token("reader", `${adapterId}\0${delegateId}`), record = await openRecord("reader", get("SELECT * FROM readers WHERE id=?", id));
663
+ if (record && (record.adapterId !== adapterId || record.delegateId !== delegateId)) fail("local_message_index_invalid");
664
+ return record;
665
+ }
666
+ async function exposeIncomingToModel(meta, message, authorize) {
667
+ const owner = messageOwner(message);
668
+ const thread = await threadRecord(owner.scope, owner.threadId);
669
+ const automatic = !owner.legacy && !thread?.deleted && thread?.autoReadAfterAi === true;
670
+ const next = automatic && L.localHistoryUnread(message)
671
+ ? { ...message, status: "fetched", updatedAt: new Date(now()).toISOString(), nextRetryAt: null, lastErrorCode: null }
672
+ : message;
673
+ // The human opted in for this exact local instance conversation. Metadata
674
+ // listing, delivery, failed authorization and claim completion never enter
675
+ // this path. Only the exposed body may change its human unread state.
676
+ // Report a human-read transition, not merely an enabled preference. Reading
677
+ // an already-read body must not publish a fresh workbench change event.
678
+ const projected = await authorize(clone(next), meta.highWater, next !== message);
679
+ if (next !== message) await commit(meta, { messages: [next] });
680
+ return projected;
681
+ }
682
+ async function inboxClaim(input, authorize, finish) {
683
+ return locked(async (meta) => {
684
+ const keys = ["adapterId", "conversationKey", "delegateId", "localMessageId", ...(finish ? ["claimId", "outcome"] : [])].sort();
685
+ if (!input || Object.keys(input).sort().join() !== keys.join() || typeof authorize !== "function") fail("local_inbox_claim_invalid");
686
+ for (const key of ["adapterId", "conversationKey", "delegateId", "localMessageId"]) L.inboxReaderId(input[key]);
687
+ if (finish && (!/^inclaim_[a-f0-9]{48}$/.test(input.claimId) || !["completed", "unknown"].includes(input.outcome))) fail("local_inbox_claim_invalid");
688
+ const reader = await readerBoundary(input.adapterId, input.delegateId), message = await openMessage(rawById(input.localMessageId));
689
+ const binding = message && getMessageConversationBinding(message);
690
+ if (!reader || !message || message.direction !== "incoming" || (!finish && message.messageType === DELETED) || !binding
691
+ || binding.localDelegateId !== input.delegateId || instanceConversationKey(binding) !== input.conversationKey
692
+ || !Number.isSafeInteger(message.receiptSeq) || message.receiptSeq <= reader.afterSeq) fail("local_inbox_claim_ineligible");
693
+ await authorize(clone(message), reader.afterSeq, meta.highWater);
694
+ const id = token("claim", `${input.delegateId}\0${input.localMessageId}`);
695
+ const existing = await openRecord("claim", get("SELECT * FROM claims WHERE id=?", id));
696
+ if (finish) {
697
+ if (!existing || existing.adapterId !== input.adapterId || existing.claimId !== input.claimId || existing.conversationKey !== input.conversationKey || existing.receiptSeq !== message.receiptSeq) fail("local_inbox_claim_mismatch");
698
+ if (existing.outcome !== "inflight" && existing.outcome !== input.outcome) fail("local_inbox_claim_terminal");
699
+ const next = { ...existing, outcome: input.outcome, version: existing.version + 1 };
700
+ await commit(meta, { claims: [next] });
701
+ await authorize(clone(message), reader.afterSeq, meta.highWater);
702
+ return { claimId: next.claimId, outcome: next.outcome };
703
+ }
704
+ if (existing) return { claimed: false, claimId: null, message: null };
705
+ const claim = { ...input, id, version: 1, claimId: `inclaim_${crypto.randomBytes(24).toString("hex")}`, receiptSeq: message.receiptSeq, outcome: "inflight" };
706
+ const claimedMeta = await commit(meta, { claims: [claim] });
707
+ await options.fault?.("claim-committed");
708
+ // Keep the durable claim and final authorization check. Human unread is
709
+ // retained by default; its separate per-conversation opt-in is applied
710
+ // only when this claim actually exposes the body.
711
+ const projected = await exposeIncomingToModel(claimedMeta, message,
712
+ (value, highWater, automatic) => authorize(value, reader.afterSeq, highWater, automatic));
713
+ return { claimed: true, claimId: claim.claimId, message: projected };
714
+ });
715
+ }
716
+
717
+ const api = {
718
+ path: storePath, dbPath, archivePath, localHistoryPath,
719
+ diagnostics() { return { ...metrics }; },
720
+ async close() { if (closed) return; await L.withPathMutationQueue(storePath, async () => { if (closed) return; closed = true; db.close(); }); },
721
+ async getReceiptHighWater() { return locked((meta) => meta.highWater); },
722
+ async readWorkbenchRevision() { return locked((meta) => `indexed_${meta.version}`); },
723
+ async getInboxReaderBoundary(adapterId, delegateId) { return locked(async () => (await readerBoundary(adapterId, delegateId))?.afterSeq ?? null); },
724
+ async activateInboxReader(adapterId, delegateId) {
725
+ return locked(async (meta) => {
726
+ const existing = await readerBoundary(adapterId, delegateId); if (existing) return existing.afterSeq;
727
+ const reader = { id: token("reader", `${adapterId}\0${delegateId}`), version: 1, adapterId, delegateId, afterSeq: meta.highWater };
728
+ await commit(meta, { readers: [reader] }); return reader.afterSeq;
729
+ });
730
+ },
731
+ async listInboxClaimIds(delegateId) {
732
+ L.inboxReaderId(delegateId);
733
+ return locked(async () => { const result = []; for (const row of all("SELECT * FROM claims WHERE delegate=?", token("delegate", delegateId))) result.push((await openRecord("claim", row)).localMessageId); return result; });
734
+ },
735
+ claimInboxMessage: (input, authorize) => inboxClaim(input, authorize, false),
736
+ finishInboxClaim: (input, authorize) => inboxClaim(input, authorize, true),
737
+ async readIncomingForModel(id, authorize) {
738
+ if (typeof authorize !== "function") fail("local_inbox_read_invalid");
739
+ return locked(async (meta) => {
740
+ const message = await openMessage(rawById(id));
741
+ if (!message || message.direction !== "incoming" || message.messageType === DELETED) throw L.localHistoryFailure("record_not_found");
742
+ return exposeIncomingToModel(meta, message, authorize);
743
+ });
744
+ },
745
+ async getIncoming(id) { return locked(async () => { const message = await openMessage(rawById(id)); return message?.direction === "incoming" ? message : null; }); },
746
+ async getOutgoing(id) { return locked(() => openMessage(rawOutgoing(id))); },
747
+ async list() { return locked(() => readMessages("SELECT * FROM messages WHERE deleted=0 ORDER BY seq")); },
748
+ async listInboxMessagesForDelegate(delegateId, { unreadOnly = false, limit = null, scope = null, before = null } = {}) {
749
+ const delegate = token("delegate", L.safeId(delegateId, "local_inbox_reader_invalid"));
750
+ if (typeof unreadOnly !== "boolean") fail("local_inbox_reader_invalid");
751
+ const bound = limit == null ? null : limitOf(limit, 1000);
752
+ // An older page continues a bounded newest-first window.
753
+ if (before != null && bound == null) fail("local_history_cursor_invalid");
754
+ const beforeId = before == null ? null : L.safeId(before, "local_history_cursor_invalid");
755
+ return locked(() => {
756
+ const [where, args] = scopeWhere(scope);
757
+ let older = "", anchorArgs = [];
758
+ if (beforeId != null) {
759
+ // Any incoming message of this AI and account anchors the page, even one
760
+ // deleted since it was listed, so paging continues from its position.
761
+ const anchor = rawById(beforeId);
762
+ if (!anchor || anchor.delegate !== delegate || anchor.direction !== "incoming"
763
+ || (scope && !readScopeKeys(scope).includes(anchor.scope))) fail("local_history_cursor_invalid");
764
+ older = " AND (created<? OR (created=? AND seq<?))";
765
+ anchorArgs = [anchor.created, anchor.created, anchor.seq];
766
+ }
767
+ return readMessages(`SELECT * FROM messages WHERE delegate=? AND direction='incoming' AND deleted=0${unreadOnly ? " AND unread=1" : ""}${where}${older}
768
+ ORDER BY ${bound == null ? "seq" : "created DESC,seq DESC"}${bound == null ? "" : " LIMIT ?"}`,
769
+ delegate, ...args, ...anchorArgs, ...(bound == null ? [] : [bound]));
770
+ });
771
+ },
772
+ async listActive() { return locked(() => readMessages("SELECT * FROM messages WHERE deleted=0 AND (direction='incoming' AND status NOT IN ('fetched','pending_human_confirmation') OR direction='outgoing' AND status NOT IN ('cancelled','delivered_to_live_connection','delivery_state_unconfirmed','failed_permanent')) ORDER BY seq")); },
773
+ async queryOutbox({ scope = null, limit = 100 } = {}) { return locked(() => { const [where, args] = scopeWhere(scope); return readMessages(`SELECT * FROM messages WHERE deleted=0 AND direction='outgoing' AND status NOT IN ('cancelled','delivered_to_live_connection','delivery_state_unconfirmed')${where} ORDER BY seq DESC LIMIT ?`, ...args, limitOf(limit, 1000)); }); },
774
+ async listPendingInboxCandidates({ delegateId, afterSeq = 0, limit = 1000, cursor = null, conversationKeys = null, receivedSince = null, localMessageId = null, modelPending = false }) {
775
+ L.inboxReaderId(delegateId);
776
+ const boundary = cursor ?? afterSeq;
777
+ if (!Number.isSafeInteger(boundary) || boundary < 0 || typeof modelPending !== "boolean") fail("local_inbox_reader_invalid");
778
+ if (conversationKeys != null && (!Array.isArray(conversationKeys) || conversationKeys.length > 1000)) fail("local_inbox_reader_invalid");
779
+ const cutoff = receivedSince == null ? null : checkedTime(receivedSince);
780
+ // A single-message recheck uses the same pending conditions by primary key.
781
+ const only = localMessageId == null ? null : token("message", L.safeId(localMessageId, "local_inbox_reader_invalid"));
782
+ return locked(async () => {
783
+ if (conversationKeys?.length === 0) return [];
784
+ const accepted = conversationKeys == null ? null : [...new Set(conversationKeys.map((id) => L.safeId(id, "local_inbox_reader_invalid")))];
785
+ const restriction = `${only == null ? "" : " AND m.id=?"}${accepted ? ` AND m.route IN (${accepted.map(() => "?").join(",")})` : ""}`;
786
+ const messages = await readMessages(`SELECT m.* FROM messages m WHERE m.delegate=? AND m.direction='incoming' AND m.deleted=0 AND ${modelPending ? "(m.admitted=0 OR EXISTS (SELECT 1 FROM claims c WHERE c.message=m.id AND c.delegate=m.delegate AND c.outcome IN ('reserved','delivered')))" : "m.admitted=0"} AND m.receipt>?${restriction}${cutoff == null ? "" : " AND m.created>=?"} ORDER BY m.receipt LIMIT ?`, token("delegate", delegateId), boundary, ...(only == null ? [] : [only]), ...(accepted ? accepted.map((id) => token("route", id)) : []), ...(cutoff == null ? [] : [cutoff]), limitOf(limit, 1000));
787
+ if (accepted && messages.some((message) => { const binding = getMessageConversationBinding(message); return !binding || !accepted.includes(instanceConversationKey(binding)); })) fail("local_message_index_invalid");
788
+ return messages;
789
+ });
790
+ },
791
+ async getMessageCounts(scope = null) {
792
+ return locked((meta) => {
793
+ if (scope == null) return clone(meta.globalCounts);
794
+ const result = emptyCounts();
795
+ for (const key of readScopeKeys(scope)) if (meta.scopeCounts[key]) addCounts(result, meta.scopeCounts[key]);
796
+ return result;
797
+ });
798
+ },
799
+ };
800
+ return Object.assign(api, batchApi(), outgoingApi(), historyApi(), incomingApi());
801
+
802
+ // Delivery is a transport receipt, not a promise that an AI completed work.
803
+ // The worker owns native routing; this ledger owns selection, admission and
804
+ // the one-retry budget across process restarts. It never resends external mail.
805
+ function batchApi() {
806
+ const describe = (batch) => ({ batchId: batch.id, target: batch.target, state: batch.state,
807
+ count: batch.messageIds.length, expiredCount: batch.expiredCount, full: batch.full,
808
+ highWater: batch.highWater, oldestReceipt: batch.oldestReceipt, attempts: batch.attempts });
809
+ function identity(input) {
810
+ L.inboxReaderId(input?.adapterId); L.inboxReaderId(input?.delegateId);
811
+ }
812
+ async function load(input) {
813
+ identity(input); L.inboxReaderId(input.batchId);
814
+ const batch = await openRecord("batch", get("SELECT * FROM inbox_batches WHERE id=?", input.batchId));
815
+ if (!batch || batch.adapterId !== input.adapterId || batch.delegateId !== input.delegateId) fail("local_inbox_batch_not_found");
816
+ return batch;
817
+ }
818
+ async function assignment(input, batches) {
819
+ identity(input);
820
+ let batchId = input.batchId;
821
+ if (batchId == null) {
822
+ L.inboxReaderId(input.localMessageId);
823
+ const claim = await openRecord('claim', get('SELECT * FROM claims WHERE id=?',
824
+ token('claim', input.delegateId + '\0' + input.localMessageId)));
825
+ if (!claim || claim.adapterId !== input.adapterId || !claim.batchId) return null;
826
+ batchId = claim.batchId;
827
+ }
828
+ if (!batches.has(batchId)) batches.set(batchId, await load({ ...input, batchId }));
829
+ const batch = batches.get(batchId);
830
+ return { batchId: batch.id, target: batch.target, nativeThreadId: batch.nativeThreadId ?? null };
831
+ }
832
+ async function candidates(input, batch, authorize, meta) {
833
+ identity(input);
834
+ if (!Array.isArray(input.candidates) || input.candidates.length > 1000 || typeof authorize !== "function") fail("local_inbox_batch_invalid");
835
+ const reader = await readerBoundary(input.adapterId, input.delegateId);
836
+ if (!reader) fail("local_inbox_claim_ineligible");
837
+ const result = [];
838
+ for (const id of new Set(input.candidates)) {
839
+ const row = rawById(id);
840
+ if (!row || row.deleted) continue;
841
+ const message = await openMessage(row), binding = getMessageConversationBinding(message);
842
+ if (!binding || message.direction !== "incoming" || binding.localDelegateId !== input.delegateId) fail("local_inbox_claim_ineligible");
843
+ if (message.receiptSeq <= reader.afterSeq || row.admitted || (batch && message.receiptSeq > batch.highWater)) continue;
844
+ // A newly resolved target may reveal a gap in an interrupted collection.
845
+ // Keep that newer letter pending; resumption only appends older receipts.
846
+ if (batch && message.receiptSeq >= batch.oldestReceipt) continue;
847
+ await authorize(clone(message), reader.afterSeq, meta.highWater);
848
+ result.push(message);
849
+ }
850
+ return result.sort((a, b) => b.receiptSeq - a.receiptSeq);
851
+ }
852
+ function admission(input, message, batch, outcome) {
853
+ return { id: token("claim", `${input.delegateId}\0${message.localMessageId}`), version: 1,
854
+ adapterId: input.adapterId, delegateId: input.delegateId, localMessageId: message.localMessageId,
855
+ conversationKey: instanceConversationKey(getMessageConversationBinding(message)),
856
+ receiptSeq: message.receiptSeq, batchId: batch.id, outcome };
857
+ }
858
+ async function append(meta, input, batch, messages) {
859
+ if (batch.state !== "reserved" || batch.attempts !== 0) fail("local_inbox_batch_started");
860
+ if (messages.some(message => message.receiptSeq >= batch.oldestReceipt)) fail("local_inbox_batch_order_invalid");
861
+ const claims = [], expired = [];
862
+ for (const message of messages) {
863
+ const length = Array.from(message.text).length;
864
+ batch.full ||= batch.characters + length > 20_000;
865
+ claims.push(admission(input, message, batch, batch.full ? "expired" : "reserved"));
866
+ if (batch.full) {
867
+ batch.expiredCount++;
868
+ expired.push({ ...message, automaticDeliveryState: "expired" });
869
+ } else {
870
+ batch.messageIds.push(message.localMessageId); batch.characters += length;
871
+ }
872
+ batch.oldestReceipt = message.receiptSeq;
873
+ }
874
+ if (messages.length || batch.version === 0) {
875
+ batch.version++;
876
+ await commit(meta, { claims, messages: expired, batches: [batch] });
877
+ }
878
+ return describe(batch);
879
+ }
880
+ return {
881
+ async reserveInboxBatch(input, authorize) {
882
+ return locked(async meta => {
883
+ identity(input); L.inboxReaderId(input.target);
884
+ let batch = input.batchId ? await load(input) : await openRecord("batch", get(
885
+ "SELECT * FROM inbox_batches WHERE delegate=? AND adapter=? AND target=? AND state='reserved' AND json_extract(payload,'$.attempts')=0 LIMIT 1",
886
+ token("delegate", input.delegateId), input.adapterId, input.target));
887
+ if (batch && batch.target !== input.target) fail("local_inbox_batch_not_found");
888
+ const messages = await candidates(input, batch, authorize, meta);
889
+ if (!batch && !messages.length) return { status: "empty" };
890
+ if (!batch) batch = { id: `inbatch_${crypto.randomBytes(24).toString("hex")}`, version: 0,
891
+ adapterId: input.adapterId, delegateId: input.delegateId, target: input.target, state: "reserved",
892
+ messageIds: [], characters: 0, expiredCount: 0, attempts: 0, full: false,
893
+ highWater: messages[0].receiptSeq, oldestReceipt: messages[0].receiptSeq + 1, createdAt: new Date(now()).toISOString() };
894
+ return append(meta, input, batch, messages);
895
+ });
896
+ },
897
+ async expireInboxBatch(input, authorize) {
898
+ return locked(async meta => {
899
+ const batch = await load(input);
900
+ if (!batch.full) fail("local_inbox_batch_not_full");
901
+ return append(meta, input, batch, await candidates(input, batch, authorize, meta));
902
+ });
903
+ },
904
+ // Keep native ownership with the durable batch, not the current carrier.
905
+ // Reading this never claims mail or changes human unread state.
906
+ async getInboxNativeAssignment(input) {
907
+ return locked(() => assignment(input, new Map()));
908
+ },
909
+ async getInboxNativeAssignments(input) {
910
+ return locked(async () => {
911
+ identity(input);
912
+ if (!Array.isArray(input.localMessageIds) || input.localMessageIds.length > 1000) fail('local_inbox_batch_invalid');
913
+ const batches = new Map(), result = {};
914
+ for (const localMessageId of input.localMessageIds) result[localMessageId] = await assignment({ ...input, localMessageId }, batches);
915
+ return result;
916
+ });
917
+ },
918
+ async listInboxBatches(input) {
919
+ identity(input);
920
+ if (input.state != null && !["reserved", "delivered"].includes(input.state)) fail("local_inbox_batch_invalid");
921
+ return locked(async () => {
922
+ const batches = [];
923
+ for (const row of all(`SELECT * FROM inbox_batches WHERE delegate=? AND adapter=? AND ${input.state ? "state=?" : "state!='closed'"}${input.target ? " AND target=?" : ""}`,
924
+ token("delegate", input.delegateId), input.adapterId, ...(input.state ? [input.state] : []), ...(input.target ? [input.target] : []))) {
925
+ const batch = await openRecord("batch", row);
926
+ batches.push(describe(batch));
927
+ }
928
+ return { batches };
929
+ });
930
+ },
931
+ async attemptInboxBatch(input) {
932
+ return locked(async meta => {
933
+ const batch = await load(input);
934
+ if (input.attemptId != null) L.inboxReaderId(input.attemptId);
935
+ let ownerChanged = false;
936
+ if (input.nativeThreadId != null) {
937
+ L.inboxReaderId(input.nativeThreadId);
938
+ if (batch.nativeThreadId && batch.nativeThreadId !== input.nativeThreadId) fail('local_inbox_batch_owner_conflict');
939
+ if (!batch.nativeThreadId) { batch.nativeThreadId = input.nativeThreadId; ownerChanged = true; }
940
+ }
941
+ // Bind the owner and register its attempt in one transaction. A late
942
+ // owner update for the same ticket must not spend another chance.
943
+ const replay = input.attemptId != null && batch.attemptIds?.includes(input.attemptId);
944
+ if (batch.state !== 'reserved' || replay || batch.attempts >= 2) {
945
+ if (ownerChanged) { batch.version++; await commit(meta, { batches: [batch] }); }
946
+ return { ...describe(batch), attempted: batch.state === 'reserved' && Boolean(replay) };
947
+ }
948
+ if (!batch.messageIds.length) {
949
+ batch.state = "closed"; batch.version++;
950
+ await commit(meta, { batches: [batch] });
951
+ return { ...describe(batch), attempted: false };
952
+ }
953
+ batch.attempts++; batch.version++;
954
+ if (input.attemptId != null) batch.attemptIds = [...(batch.attemptIds || []), input.attemptId];
955
+ await commit(meta, { batches: [batch] });
956
+ return { ...describe(batch), attempted: true };
957
+ });
958
+ },
959
+ async confirmInboxBatch(input) {
960
+ return locked(async meta => {
961
+ const batch = await load(input);
962
+ // A fast model may claim before native confirmation. Still retain its
963
+ // carrier, without reopening taken claims or replacing an owner.
964
+ if (input.nativeThreadId != null) {
965
+ L.inboxReaderId(input.nativeThreadId);
966
+ if (batch.nativeThreadId && batch.nativeThreadId !== input.nativeThreadId) fail("local_inbox_batch_owner_conflict");
967
+ if (!batch.nativeThreadId) {
968
+ batch.nativeThreadId = input.nativeThreadId;
969
+ if (batch.state !== "reserved") {
970
+ batch.version++;
971
+ await commit(meta, { batches: [batch] });
972
+ }
973
+ }
974
+ }
975
+ if (batch.state !== "reserved") return describe(batch);
976
+ const claims = [];
977
+ for (const id of batch.messageIds) {
978
+ const claim = await openRecord("claim", get("SELECT * FROM claims WHERE id=?", token("claim", `${input.delegateId}\0${id}`)));
979
+ if (claim?.outcome === "reserved") claims.push({ ...claim, version: claim.version + 1, outcome: "delivered" });
980
+ }
981
+ batch.state = "delivered"; batch.version++;
982
+ await commit(meta, { claims, batches: [batch] });
983
+ return describe(batch);
984
+ });
985
+ },
986
+ async cancelInboxBatch(input) {
987
+ return locked(async meta => {
988
+ const batch = await load(input);
989
+ // A rejection of the second attempt cannot disprove delivery of
990
+ // the first, uncertain one. Keep its original batch/dedup boundary.
991
+ if (batch.state !== "reserved" || batch.attempts > 1) return { ...describe(batch), cancelled: false };
992
+ const claims = [];
993
+ for (const id of batch.messageIds) {
994
+ const claim = await openRecord("claim", get("SELECT * FROM claims WHERE id=?", token("claim", `${input.delegateId}\0${id}`)));
995
+ if (claim?.outcome !== "reserved") return { ...describe(batch), cancelled: false };
996
+ claims.push(claim);
997
+ }
998
+ await commit(meta, { deleteClaims: claims, deleteBatches: [batch] });
999
+ return { ...describe(batch), cancelled: true };
1000
+ });
1001
+ },
1002
+ async claimInboxBatch(input, authorize) {
1003
+ return locked(async meta => {
1004
+ const batch = await load(input), messages = [], claims = [], read = [];
1005
+ if (typeof authorize !== "function") fail("local_inbox_batch_invalid");
1006
+ const reader = await readerBoundary(input.adapterId, input.delegateId);
1007
+ if (!reader) fail("local_inbox_claim_ineligible");
1008
+ if (batch.state !== "closed") for (const id of [...batch.messageIds].reverse()) {
1009
+ const claim = await openRecord("claim", get("SELECT * FROM claims WHERE id=?", token("claim", `${input.delegateId}\0${id}`)));
1010
+ if (!claim || !["reserved", "delivered"].includes(claim.outcome)) continue;
1011
+ const message = await openMessage(rawById(id));
1012
+ claims.push({ ...claim, version: claim.version + 1, outcome: "taken" });
1013
+ if (!message || message.messageType === DELETED) continue;
1014
+ const owner = messageOwner(message), thread = await threadRecord(owner.scope, owner.threadId);
1015
+ const automatic = thread?.autoReadAfterAi === true && L.localHistoryUnread(message);
1016
+ const next = automatic ? { ...message, status: "fetched", updatedAt: new Date(now()).toISOString(), nextRetryAt: null, lastErrorCode: null } : message;
1017
+ messages.push(await authorize(clone(next), reader.afterSeq, meta.highWater, automatic));
1018
+ if (automatic) read.push(next);
1019
+ }
1020
+ if (claims.length) {
1021
+ batch.state = "delivered"; batch.version++;
1022
+ await commit(meta, { claims, messages: read, batches: [batch] });
1023
+ }
1024
+ return { claimed: messages.length > 0, messages, expiredCount: batch.expiredCount,
1025
+ notice: messages.length ? null : "这批来信已读取,无需重复读取。" };
1026
+ });
1027
+ },
1028
+ async closeInboxBatch(input) {
1029
+ return locked(async meta => {
1030
+ const batch = await load(input);
1031
+ if (batch.state === "closed") return describe(batch);
1032
+ if (batch.state !== "delivered") fail("local_inbox_batch_not_delivered");
1033
+ const messages = [];
1034
+ for (const id of batch.messageIds) {
1035
+ const message = await openMessage(rawById(id));
1036
+ if (message && message.messageType !== DELETED && L.localHistoryUnread(message)) messages.push({ ...message,
1037
+ status: "fetched", updatedAt: new Date(now()).toISOString(), nextRetryAt: null, lastErrorCode: null });
1038
+ }
1039
+ batch.state = "closed"; batch.version++;
1040
+ await commit(meta, { messages, batches: [batch] });
1041
+ return describe(batch);
1042
+ });
1043
+ },
1044
+ };
1045
+ }
1046
+
1047
+ function outgoingApi() {
1048
+ return {
1049
+ async addOutgoing(input) {
1050
+ const recipient = L.safeId(input.recipientHelloLynkId, "recipient_hellolynk_id_invalid"), client = L.safeId(input.clientMessageId, "client_message_id_invalid");
1051
+ const text = L.safeText(input.text), principal = L.safeId(input.senderPrincipalId, "sender_principal_id_invalid"), delegate = L.safeId(input.senderDelegateId, "sender_delegate_id_invalid");
1052
+ const e2ee = L.normalizeStoredE2ee(input.e2ee), provenance = L.provenanceFromStoredE2ee(e2ee, principal, delegate);
1053
+ const replyToClientMessageId = input.replyToClientMessageId == null ? null : L.safeId(input.replyToClientMessageId, "reply_target_invalid");
1054
+ const targetConversationId = input.targetConversationId == null ? null : nativeConversationId(input.targetConversationId);
1055
+ const targetConversationTitle = input.targetConversationTitle == null ? null : nativeConversationTitle(input.targetConversationTitle);
1056
+ if (targetConversationId && replyToClientMessageId) fail("native_target_conflicts_with_reply");
1057
+ if (targetConversationTitle && !targetConversationId) fail("native_target_not_authorized");
1058
+ if ((e2ee && (e2ee.envelope.protected.replyToClientMessageId ?? null) !== replyToClientMessageId)
1059
+ || replyToClientMessageId === client) fail("reply_target_invalid");
1060
+ return locked(async (meta) => {
1061
+ if (get("SELECT 1 FROM messages WHERE client=?", token("client", client))) fail("client_message_id_exists");
1062
+ const at = new Date(now()).toISOString();
1063
+ const message = { localMessageId: `local_message_${crypto.randomUUID()}`, clientMessageId: client, direction: "outgoing", peerHelloLynkId: recipient,
1064
+ senderPrincipalId: principal, senderDelegateId: delegate, ...provenance, messageType: "text", text, transportSecurity: e2ee ? L.E2EE_TRANSPORT_SECURITY : "plaintext-v0.1.0", e2ee,
1065
+ ...(replyToClientMessageId ? { replyToClientMessageId } : {}),
1066
+ ...(targetConversationId ? { targetConversationId } : {}),
1067
+ ...(targetConversationTitle ? { targetConversationTitle } : {}),
1068
+ ...(input.e2eePreparation ? { e2eePreparation: clone(input.e2eePreparation), deliveryAttemptPid: process.pid, deliveryAttemptInstance: L.PROCESS_INSTANCE_ID, deliveryAttemptStartedAt: at } : {}),
1069
+ conversationIntent: normalizeInstanceConversationBinding(input.conversationIntent), status: "pending", attempts: 0, nextRetryAt: null, lastErrorCode: null, createdAt: at, updatedAt: at };
1070
+ message.conversationBinding = getMessageConversationBinding(message);
1071
+ const changes = [message];
1072
+ if (input.resendSourceClientMessageId != null) {
1073
+ const originalId = L.safeId(input.resendSourceClientMessageId, "reply_resend_invalid");
1074
+ const original = await openMessage(rawOutgoing(originalId));
1075
+ const originalBinding = original && (getMessageConversationBinding(original) || getOutgoingConversationIntent(original));
1076
+ const newBinding = message.conversationBinding || getOutgoingConversationIntent(message);
1077
+ if (!original || original.messageType !== "text" || original.status !== "failed_permanent"
1078
+ || original.lastErrorCode !== "reply_target_deleted" || !original.replyToClientMessageId || original.resendClientMessageId
1079
+ || original.senderPrincipalId !== principal || original.senderDelegateId !== delegate || original.peerHelloLynkId !== recipient
1080
+ || !originalBinding || !newBinding || instanceConversationKey(originalBinding) !== instanceConversationKey(newBinding)
1081
+ || original.text !== text || client === originalId || e2ee || replyToClientMessageId
1082
+ || input.e2eePreparation?.intentSource !== "human_native_dialog") fail("reply_resend_invalid", { httpStatus: 409 });
1083
+ message.resendSourceClientMessageId = originalId;
1084
+ original.resendClientMessageId = client; original.updatedAt = at;
1085
+ changes.push(original);
1086
+ }
1087
+ // Reserve the original and create its fixed saved-body copy together.
1088
+ // A crash or a second human click cannot create another result copy.
1089
+ await commit(meta, { messages: changes }); return clone(message);
1090
+ });
1091
+ },
1092
+ async updateOutgoing(id, patch) {
1093
+ return mutateMessage(id, true, (message) => {
1094
+ if (message.messageType === DELETED) {
1095
+ // Human deletion may win while transport is in flight. Settle its
1096
+ // minimal ledger only; never restore content, claims or retry work.
1097
+ if (!message.conversationDeleted || message.status !== "delivery_state_unknown"
1098
+ || patch.incrementAttempts !== false || !["delivered_to_live_connection", "delivery_state_unconfirmed",
1099
+ "failed_permanent", "failed_retryable", "recipient_offline"].includes(patch.status)) {
1100
+ throw L.localHistoryFailure("record_not_found");
1101
+ }
1102
+ message.status = patch.status;
1103
+ message.updatedAt = new Date(now()).toISOString();
1104
+ return;
1105
+ }
1106
+ if (patch.preparationOnly && (message.status !== "pending" || message.attempts !== 0)) return false;
1107
+ if (patch.beforeTransportClaim && (message.status !== "delivery_state_unknown" || message.lastErrorCode !== "awaiting_relay_result"
1108
+ || message.deliveryAttemptPid !== process.pid || message.deliveryAttemptInstance !== L.PROCESS_INSTANCE_ID
1109
+ || message.attempts !== patch.beforeTransportClaim.attempts || message.deliveryAttemptStartedAt !== patch.beforeTransportClaim.startedAt)) fail("local_outgoing_attempt_mismatch", { httpStatus: 409 });
1110
+ message.status = String(patch.status || message.status); message.attempts = Number(message.attempts || 0) + (patch.incrementAttempts === false ? 0 : 1);
1111
+ message.lastErrorCode = patch.lastErrorCode == null ? null : String(patch.lastErrorCode);
1112
+ message.deliveryAttemptPid = null; message.deliveryAttemptInstance = null; message.deliveryAttemptStartedAt = null;
1113
+ message.nextRetryAt = ["recipient_offline", "failed_retryable"].includes(message.status) ? new Date(now() + Math.min(1000 * 2 ** Math.min(Math.max(message.attempts - 1, 0), 6), 60000)).toISOString() : null;
1114
+ message.updatedAt = new Date(now()).toISOString();
1115
+ }, patch.settleDelivery === true);
1116
+ },
1117
+ async attachOutgoingE2ee(id, value) {
1118
+ const e2ee = L.normalizeStoredE2ee(value); if (!e2ee) fail("e2ee_required");
1119
+ return mutateMessage(id, true, (message) => {
1120
+ if (message.status !== "pending" || message.e2ee != null) fail("local_outgoing_e2ee_attach_conflict", { httpStatus: 409 });
1121
+ const provenance = L.provenanceFromStoredE2ee(e2ee, message.senderPrincipalId, message.senderDelegateId);
1122
+ if ((e2ee.envelope.protected.replyToClientMessageId ?? null) !== (message.replyToClientMessageId ?? null)) fail("reply_target_invalid");
1123
+ message.e2ee = e2ee; message.conversationBinding = getMessageConversationBinding(message); Object.assign(message, provenance);
1124
+ message.transportSecurity = L.E2EE_TRANSPORT_SECURITY; message.updatedAt = new Date(now()).toISOString();
1125
+ });
1126
+ },
1127
+ async claimOutgoingForPreparation(id, expected) {
1128
+ return mutateMessage(id, true, (message) => {
1129
+ if (message.senderPrincipalId !== expected.senderPrincipalId || message.senderDelegateId !== expected.senderDelegateId) fail("local_outgoing_identity_mismatch", { httpStatus: 409 });
1130
+ if (!L.MANUAL_RETRYABLE_OUTGOING_STATUSES.has(message.status) || message.e2ee || message.attempts !== 0) fail("local_outgoing_message_not_retryable", { httpStatus: 409 });
1131
+ if (!message.e2eePreparation) fail("e2ee_preparation_context_missing", { httpStatus: 409 });
1132
+ Object.assign(message, { status: "pending", nextRetryAt: null, lastErrorCode: null, deliveryAttemptPid: process.pid, deliveryAttemptInstance: L.PROCESS_INSTANCE_ID, deliveryAttemptStartedAt: new Date(now()).toISOString(), updatedAt: new Date(now()).toISOString() });
1133
+ });
1134
+ },
1135
+ async claimOutgoingForAttempt(id, expected = {}) {
1136
+ const principal = L.safeId(expected.senderPrincipalId, "sender_principal_id_invalid"), delegate = L.safeId(expected.senderDelegateId, "sender_delegate_id_invalid");
1137
+ return mutateMessage(id, true, (message) => {
1138
+ if (!message.senderPrincipalId || !message.senderDelegateId) fail("local_outgoing_identity_unbound", { httpStatus: 409 });
1139
+ if (message.senderPrincipalId !== principal || message.senderDelegateId !== delegate) fail("local_outgoing_identity_mismatch", { httpStatus: 409 });
1140
+ if (!L.CLAIMABLE_OUTGOING_STATUSES.has(message.status) || message.lastErrorCode === "reply_target_deleted") fail("local_outgoing_message_not_retryable", { httpStatus: 409 });
1141
+ Object.assign(message, { status: "delivery_state_unknown", attempts: Number(message.attempts || 0) + 1, lastErrorCode: "awaiting_relay_result", deliveryAttemptPid: process.pid, deliveryAttemptInstance: L.PROCESS_INSTANCE_ID, deliveryAttemptStartedAt: new Date(now()).toISOString(), updatedAt: new Date(now()).toISOString() });
1142
+ });
1143
+ },
1144
+ async recoverArchivedOutgoingRetryClaim() { return null; }, // SQLite has no two-file retry-claim intermediate state.
1145
+ async recoverAbandonedE2eeOutgoingAttempts({ clientMessageId = null } = {}) {
1146
+ return locked(async (meta) => {
1147
+ const messages = clientMessageId ? [await openMessage(rawOutgoing(clientMessageId))].filter(message => message && message.messageType !== DELETED) : await readMessages("SELECT * FROM messages WHERE deleted=0 AND direction='outgoing' AND status IN ('pending','delivery_state_unknown')");
1148
+ const changed = L.recoverAbandonedE2eeOutgoingAttempts({ messages }, now, clientMessageId);
1149
+ if (changed.length) await commit(meta, { messages: changed }); return changed;
1150
+ });
1151
+ },
1152
+ async listPending() {
1153
+ await api.recoverAbandonedE2eeOutgoingAttempts();
1154
+ return locked(() => readMessages("SELECT * FROM messages WHERE deleted=0 AND direction='outgoing' AND status IN ('pending','recipient_offline','failed_retryable') ORDER BY seq"));
1155
+ },
1156
+ async cancelOutgoing(id) {
1157
+ return mutateMessage(id, true, (message) => {
1158
+ if (!L.BACKGROUND_RETRYABLE_OUTGOING_STATUSES.has(message.status)) fail("local_outgoing_message_not_cancellable", { httpStatus: 409 });
1159
+ Object.assign(message, { status: "cancelled", lastErrorCode: null, nextRetryAt: null, deliveryAttemptPid: null, deliveryAttemptInstance: null, deliveryAttemptStartedAt: null, updatedAt: new Date(now()).toISOString() });
1160
+ });
1161
+ },
1162
+ };
1163
+ }
1164
+
1165
+ function incomingApi() {
1166
+ return {
1167
+ async addIncoming(event) {
1168
+ const eventId = L.safeId(event?.eventId, "relay_event_id_invalid"), client = L.safeId(event?.clientMessageId, "client_message_id_invalid");
1169
+ const principal = L.safeId(event?.senderPrincipalId, "sender_principal_id_invalid"), delegate = L.safeId(event?.senderDelegateId, "sender_delegate_id_invalid");
1170
+ const provenance = L.normalizeMessageProvenance(event?.authorizationScope, event?.intentSource, { required: event?.envelopeDigest != null, code: "message_provenance_invalid" });
1171
+ const peer = event?.senderHelloLynkId == null ? null : L.safeId(event.senderHelloLynkId, "sender_hellolynk_id_invalid");
1172
+ const text = L.safeText(event?.text), digest = event?.envelopeDigest == null ? null : L.safeSha256(event.envelopeDigest);
1173
+ const senderKey = event?.senderIdentityKeyId == null ? null : L.safeId(event.senderIdentityKeyId, "e2ee_sender_identity_key_id_invalid");
1174
+ const recipientKey = event?.recipientIdentityKeyId == null ? null : L.safeId(event.recipientIdentityKeyId, "e2ee_recipient_identity_key_id_invalid");
1175
+ const binding = normalizeInstanceConversationBinding(event?.conversationBinding);
1176
+ const replyToClientMessageId = event?.replyToClientMessageId == null ? null : L.safeId(event.replyToClientMessageId, "reply_target_invalid");
1177
+ const targetConversationId = event?.targetConversationId == null ? null : nativeConversationId(event.targetConversationId);
1178
+ if (targetConversationId && (replyToClientMessageId || !digest || !binding
1179
+ || (principal !== binding.localPrincipalId && (provenance.intentSource !== "human_preauthorized_contact"
1180
+ || !["direct", "held"].includes(event.friendTarget))))) fail("native_target_not_authorized");
1181
+ if (replyToClientMessageId && (!binding || !digest || provenance.intentSource !== "human_preauthorized_contact" || replyToClientMessageId === client)) fail("reply_target_invalid");
1182
+ if (binding) { if (!digest) fail("message_provenance_invalid"); getMessageConversationBinding({ ...event, direction: "incoming", conversationBinding: binding, peerHelloLynkId: peer }); }
1183
+ const principalName = L.optionalVerifiedDisplayName(event?.senderPrincipalDisplayName, "sender_principal_display_name_invalid");
1184
+ const delegateName = L.optionalVerifiedDisplayName(event?.senderDelegateDisplayName, "sender_delegate_display_name_invalid");
1185
+ return locked(async (meta) => {
1186
+ const candidates = all("SELECT * FROM messages WHERE direction='incoming' AND (event=? OR sender_client=?)", token("event", eventId), token("sender-client", `${principal}\0${client}`));
1187
+ if (candidates.length > 1) fail("local_incoming_message_conflict", { httpStatus: 409 });
1188
+ const existing = await openMessage(candidates[0]);
1189
+ if (existing) {
1190
+ const isDeleted = existing.messageType === DELETED;
1191
+ const legacyMissing = !Object.hasOwn(existing, "senderPrincipalId") && !Object.hasOwn(existing, "authorizationScope") && !Object.hasOwn(existing, "intentSource");
1192
+ // An older Host's deletion can erase the held target's side data.
1193
+ // Its unchanged ciphertext digest still fences this replay.
1194
+ const deletedHeldReplay = isDeleted && !existing.targetConversationId && targetConversationId && digest
1195
+ && principal !== binding?.localPrincipalId;
1196
+ if (existing.clientMessageId !== client || existing.peerPrincipalId !== principal || existing.senderDelegateId !== delegate || (existing.peerHelloLynkId || null) !== peer
1197
+ || (existing.replyToClientMessageId ?? null) !== replyToClientMessageId
1198
+ || (!deletedHeldReplay && (existing.targetConversationId ?? null) !== targetConversationId)
1199
+ || (existing.envelopeDigest || null) !== digest || (existing.senderIdentityKeyId || null) !== senderKey || (existing.recipientIdentityKeyId || null) !== recipientKey
1200
+ || (isDeleted ? (!existing.conversationBinding || !binding || instanceConversationKey(existing.conversationBinding) !== instanceConversationKey(binding))
1201
+ : (existing.messageType !== "text" || existing.text !== text || (existing.conversationBinding && (!binding || instanceConversationKey(existing.conversationBinding) !== instanceConversationKey(binding)))
1202
+ || (!legacyMissing && (existing.senderPrincipalId !== principal || (existing.authorizationScope ?? null) !== provenance.authorizationScope || (existing.intentSource ?? null) !== provenance.intentSource))))) fail("local_incoming_message_conflict", { httpStatus: 409 });
1203
+ return { message: clone(existing), duplicate: true };
1204
+ }
1205
+ if (meta.highWater >= Number.MAX_SAFE_INTEGER) fail("local_message_receipt_sequence_invalid");
1206
+ let replyTargetDeleted = false;
1207
+ if (replyToClientMessageId) {
1208
+ const original = await openMessage(rawOutgoing(replyToClientMessageId));
1209
+ const originalBinding = original && (getMessageConversationBinding(original) || getOutgoingConversationIntent(original));
1210
+ if (!originalBinding || instanceConversationKey(originalBinding) !== instanceConversationKey(binding)) fail("reply_target_not_found", { httpStatus: 409 });
1211
+ replyTargetDeleted = original.conversationDeleted === true;
1212
+ }
1213
+ const at = new Date(now()).toISOString();
1214
+ let message = {
1215
+ localMessageId: `local_message_${crypto.randomUUID()}`, eventId, receiptSeq: meta.highWater + 1, clientMessageId: client, direction: "incoming",
1216
+ peerPrincipalId: principal, peerHelloLynkId: peer, senderPrincipalId: principal, senderDelegateId: delegate, ...provenance,
1217
+ senderPrincipalDisplayName: principalName, senderDelegateDisplayName: delegateName, messageType: "text", text,
1218
+ transportSecurity: digest ? L.E2EE_TRANSPORT_SECURITY : "plaintext-v0.1.0", envelopeDigest: digest, senderIdentityKeyId: senderKey, recipientIdentityKeyId: recipientKey,
1219
+ conversationBinding: binding, status: "received", attempts: 0, nextRetryAt: null, lastErrorCode: null, createdAt: at, updatedAt: at,
1220
+ ...(replyToClientMessageId ? { replyToClientMessageId } : {}),
1221
+ ...(targetConversationId ? { targetConversationId } : {}),
1222
+ ...(targetConversationId && event.friendTarget === "held" ? { friendTargetHeld: true, automaticDeliveryState: "expired" } : {}),
1223
+ };
1224
+ if (replyTargetDeleted) message = L.localHistoryTombstone({ ...message, replyTargetDeleted: true, status: "fetched" }, {
1225
+ region: binding.region, principalId: binding.localPrincipalId, delegateId: binding.localDelegateId,
1226
+ });
1227
+ await commit(meta, { messages: [message], meta: { highWater: message.receiptSeq } }); return { message: clone(message), duplicate: false };
1228
+ });
1229
+ },
1230
+ async findIncomingE2eeReplay({ senderIdentityKeyId, clientMessageId, envelopeDigest }) {
1231
+ const sender = L.safeId(senderIdentityKeyId, "e2ee_sender_identity_key_id_invalid"), client = L.safeId(clientMessageId, "client_message_id_invalid"), digest = L.safeSha256(envelopeDigest);
1232
+ return locked(async () => { const message = await openMessage(get("SELECT * FROM messages WHERE direction='incoming' AND replay=?", token("replay", `${sender}\0${client}`))); if (message && message.envelopeDigest !== digest) fail("e2ee_message_conflict", { httpStatus: 409 }); return message; });
1233
+ },
1234
+ async updateIncoming(id, patch) {
1235
+ return mutateMessage(id, false, (message) => {
1236
+ message.status = String(patch.status || message.status);
1237
+ if (message.status === "failed_retryable") { message.attempts = Number(message.attempts || 0) + 1; message.nextRetryAt = new Date(now() + Math.min(1000 * 2 ** Math.min(message.attempts - 1, 6), 60000)).toISOString(); message.lastErrorCode = patch.lastErrorCode == null ? "inbound_projection_failed" : String(patch.lastErrorCode); }
1238
+ else if (["fetched", "pending_human_confirmation"].includes(message.status)) { message.nextRetryAt = null; message.lastErrorCode = null; }
1239
+ message.updatedAt = new Date(now()).toISOString();
1240
+ });
1241
+ },
1242
+ async deleteIncoming(ids) { return deleteIds(ids, false); },
1243
+ async deleteOutgoing(ids) { return deleteIds(ids, true); },
1244
+ };
1245
+ }
1246
+
1247
+ async function deleteIds(input, outgoing) {
1248
+ const ids = [...new Set((Array.isArray(input) ? input : []).map((id) => L.safeId(id, outgoing ? "client_message_id_invalid" : "local_message_id_invalid")))];
1249
+ if (!ids.length || ids.length > 500) fail(ids.length ? "local_message_delete_capacity_exceeded" : outgoing ? "client_message_ids_required" : "local_message_ids_required");
1250
+ return locked(async (meta) => {
1251
+ const messages = [], deletedIds = [];
1252
+ for (const id of ids) {
1253
+ const message = await openMessage(outgoing ? rawOutgoing(id) : rawById(id));
1254
+ if (!message || message.direction !== (outgoing ? "outgoing" : "incoming") || message.messageType === DELETED) continue;
1255
+ if (await protectedReason(message)) fail(outgoing ? "local_outgoing_message_not_deletable" : "local_incoming_message_not_deletable", { httpStatus: 409 });
1256
+ const binding = getMessageConversationBinding(message) || getOutgoingConversationIntent(message);
1257
+ const scope = binding ? { region: binding.region, principalId: binding.localPrincipalId, delegateId: binding.localDelegateId } : null;
1258
+ messages.push(scope ? L.localHistoryTombstone(message, scope) : { ...message, text: "", e2ee: null, messageType: DELETED });
1259
+ deletedIds.push(id);
1260
+ }
1261
+ if (messages.length) await commit(meta, { messages });
1262
+ return { requested: ids.length, deleted: deletedIds.length, deletedIds };
1263
+ });
1264
+ }
1265
+
1266
+ function historyApi() {
1267
+ async function configuration(scope) {
1268
+ const id = scopeKey(scope), value = await openRecord("setting", get("SELECT * FROM settings WHERE id=?", id));
1269
+ return value || { id, version: 0, scope: L.localHistoryScope(scope), autoArchiveAfterHours: null };
1270
+ }
1271
+ async function stats(scope, threadId, before) {
1272
+ scope = selectThreadScope(scope, threadId);
1273
+ const key = threadKey(scope, threadId), values = [scopeKey(scope), key], cutoff = before == null ? "" : " AND m.created<?";
1274
+ if (before != null) values.push(checkedTime(before));
1275
+ const row = get(`SELECT count(*) AS totalCount,coalesce(sum(bytes),0) AS totalBytes,
1276
+ coalesce(sum(CASE WHEN unread=0 AND outgoing_protected=0 THEN 1 ELSE 0 END),0) AS deletableCount,
1277
+ coalesce(sum(CASE WHEN unread=0 AND outgoing_protected=0 THEN bytes ELSE 0 END),0) AS deletableBytes,
1278
+ coalesce(sum(unread),0) AS retainedUnreadCount,coalesce(sum(outgoing_protected),0) AS retainedOutgoingCount,
1279
+ 0 AS retainedTaskCount
1280
+ FROM messages m WHERE scope=? AND thread=? AND deleted=0${cutoff}`, ...values);
1281
+ return { threadId, before: before ?? null, ...row };
1282
+ }
1283
+ async function archive(input, hidden) {
1284
+ const requestedScope = L.localHistoryScope(input), threadId = L.safeId(input.threadId, "local_history_thread_invalid");
1285
+ return locked(async (meta) => {
1286
+ const scope = selectThreadScope(requestedScope, threadId);
1287
+ let thread = await threadRecord(scope, threadId);
1288
+ if (thread?.deleted) {
1289
+ if (hidden) throw L.localHistoryFailure("record_not_found");
1290
+ // A human opening this saved contact starts an empty conversation.
1291
+ // The deletion boundary remains: old results cannot enter it.
1292
+ await commit(meta, { threads: [{ ...thread, deleted: false, archived: false }] });
1293
+ return {};
1294
+ }
1295
+ if (!thread) {
1296
+ if (!hidden) return {};
1297
+ // Host supplies this binding only after finding the saved target in
1298
+ // the current delegate's contacts. An opaque thread key is not enough.
1299
+ const binding = normalizeInstanceConversationBinding(input.conversationBinding);
1300
+ if (!binding || binding.region !== scope.region || binding.localPrincipalId !== scope.principalId
1301
+ || binding.localDelegateId !== scope.delegateId || instanceConversationKey(binding) !== threadId) {
1302
+ throw L.localHistoryFailure("record_not_found");
1303
+ }
1304
+ const peer = input.peerHelloLynkId == null ? null : L.safeId(input.peerHelloLynkId, "sender_hellolynk_id_invalid");
1305
+ thread = { id: threadKey(scope, threadId), version: 0, scope, binding, legacy: false, threadId,
1306
+ peerPrincipalId: binding.peerPrincipalId, peerHelloLynkId: peer,
1307
+ archived: false, updatedAt: new Date(now()).toISOString(),
1308
+ unreadCount: 0, outgoingProtectedCount: 0, taskProtectedCount: 0 };
1309
+ }
1310
+ // A manual archive changes visibility only. Bodies, unread facts,
1311
+ // inflight/unknown claims and send state stay intact. Automatic archive
1312
+ // still protects these records in maintainLocalHistory.
1313
+ await commit(meta, { threads: [{ ...thread, archived: hidden }] }); return {};
1314
+ });
1315
+ }
1316
+ async function deleteConversations(input, archivedOnly = false) {
1317
+ const requestedScope = L.localHistoryScope(input);
1318
+ const prepared = await locked(async (meta) => {
1319
+ const threads = [];
1320
+ if (archivedOnly) {
1321
+ const keys = readScopeKeys(requestedScope);
1322
+ for (const row of all(`SELECT * FROM threads WHERE scope IN (${keys.map(() => "?").join(",")}) AND archived=1 AND deleted=0`, ...keys)) threads.push(await openRecord("thread", row));
1323
+ } else {
1324
+ const threadId = L.safeId(input.threadId, "local_history_thread_invalid"), scope = selectThreadScope(requestedScope, threadId);
1325
+ let thread = await threadRecord(scope, threadId);
1326
+ if (!thread) {
1327
+ const binding = normalizeInstanceConversationBinding(input.conversationBinding);
1328
+ if (!binding || binding.region !== scope.region || binding.localPrincipalId !== scope.principalId
1329
+ || binding.localDelegateId !== scope.delegateId || instanceConversationKey(binding) !== threadId) throw L.localHistoryFailure("record_not_found");
1330
+ thread = { id: threadKey(scope, threadId), version: 0, scope, binding, legacy: false, threadId,
1331
+ peerPrincipalId: binding.peerPrincipalId, peerHelloLynkId: input.peerHelloLynkId == null ? null : L.safeId(input.peerHelloLynkId, "sender_hellolynk_id_invalid"),
1332
+ archived: false, updatedAt: new Date(now()).toISOString(), unreadCount: 0, outgoingProtectedCount: 0, taskProtectedCount: 0 };
1333
+ }
1334
+ threads.push(thread);
1335
+ }
1336
+ let deletedCount = 0;
1337
+ const changes = [];
1338
+ for (const thread of threads) {
1339
+ // Resume the original boundary after interruption, including if a new
1340
+ // independent message already restored this thread in the meantime.
1341
+ if (thread.purgePending || thread.deleted) continue;
1342
+ deletedCount += get("SELECT count(*) AS n FROM messages WHERE thread=? AND deleted=0", thread.id).n;
1343
+ changes.push({ ...thread, archived: false, deleted: true, deleteThrough: meta.sequence,
1344
+ purgeAfter: 0, purgePending: true });
1345
+ }
1346
+ if (changes.length) await commit(meta, { deleteThreads: changes });
1347
+ return { deletedCount, ids: threads.map(thread => thread.id) };
1348
+ });
1349
+ await options.fault?.("conversation-delete-intent");
1350
+ for (const id of prepared.ids) {
1351
+ while (await locked(() => purgeDeletedBatch(id))) await new Promise(resolve => setImmediate(resolve));
1352
+ }
1353
+ return { deletedCount: prepared.deletedCount, retainedUnreadCount: 0, retainedOutgoingCount: 0, retainedTaskCount: 0 };
1354
+ }
1355
+ return {
1356
+ async configureConversationAutoRead({ scope: input, threadId, enabled, conversationBinding, peerHelloLynkId = null }) {
1357
+ const scope = L.localHistoryScope(input);
1358
+ L.safeId(threadId, "local_history_thread_invalid");
1359
+ if (typeof enabled !== "boolean") fail("local_history_configuration_invalid");
1360
+ return locked(async (meta) => {
1361
+ let thread = await threadRecord(scope, threadId);
1362
+ const binding = normalizeInstanceConversationBinding(thread?.binding ?? conversationBinding);
1363
+ if (!binding || binding.region !== scope.region || binding.localPrincipalId !== scope.principalId
1364
+ || binding.localDelegateId !== scope.delegateId || instanceConversationKey(binding) !== threadId
1365
+ || thread?.deleted || thread?.legacy) throw L.localHistoryFailure("record_not_found");
1366
+ if (!thread) thread = { id: threadKey(scope, threadId), version: 0, scope, binding, legacy: false, threadId,
1367
+ peerPrincipalId: binding.peerPrincipalId,
1368
+ peerHelloLynkId: peerHelloLynkId == null ? null : L.safeId(peerHelloLynkId, "sender_hellolynk_id_invalid"),
1369
+ archived: false, updatedAt: new Date(now()).toISOString(),
1370
+ unreadCount: 0, outgoingProtectedCount: 0, taskProtectedCount: 0 };
1371
+ if (thread.autoReadAfterAi !== enabled) await commit(meta, { threads: [{ ...thread, autoReadAfterAi: enabled }] });
1372
+ return { threadId, enabled };
1373
+ });
1374
+ },
1375
+ async markIncomingRead({ scope: input, threadId = null, beforeReceiptSeq }) {
1376
+ const scope = L.localHistoryScope(input);
1377
+ if (!Number.isSafeInteger(beforeReceiptSeq) || beforeReceiptSeq < 0) fail("local_message_receipt_sequence_invalid");
1378
+ if (threadId !== null) L.safeId(threadId, "local_history_thread_invalid");
1379
+ return locked(async (meta) => {
1380
+ if (beforeReceiptSeq > meta.highWater) fail("local_message_receipt_sequence_invalid");
1381
+ const keys = readScopeKeys(scope), values = [...keys, beforeReceiptSeq];
1382
+ let condition = "";
1383
+ if (threadId !== null) {
1384
+ const selectedScope = selectThreadScope(scope, threadId);
1385
+ condition = " AND thread=?";
1386
+ values.push(threadKey(selectedScope, threadId));
1387
+ }
1388
+ const messages = await readMessages(`SELECT * FROM messages WHERE scope IN (${keys.map(() => "?").join(",")}) AND direction='incoming' AND deleted=0 AND unread=1 AND receipt<=?${condition} ORDER BY receipt`, ...values);
1389
+ const at = new Date(now()).toISOString();
1390
+ for (const message of messages) {
1391
+ assertScope(message, scope);
1392
+ Object.assign(message, { status: "fetched", updatedAt: at, nextRetryAt: null, lastErrorCode: null });
1393
+ }
1394
+ // One durable transaction updates the message rows and unread
1395
+ // counts. AI claims/reader boundaries and later arrivals are untouched.
1396
+ if (messages.length) await commit(meta, { messages });
1397
+ return { markedCount: messages.length };
1398
+ });
1399
+ },
1400
+ async readWorkbenchSummary(input) {
1401
+ const scope = L.localHistoryScope(input);
1402
+ return locked(async (meta) => {
1403
+ const config = await configuration(scope), threads = [], hiddenThreadIds = [];
1404
+ const keys = [scopeKey(scope)];
1405
+ for (const row of all(`SELECT * FROM threads WHERE scope IN (${keys.map(() => "?").join(",")}) ORDER BY updated DESC,id`, ...keys)) {
1406
+ const thread = await openRecord("thread", row);
1407
+ if (scopeKey(thread.scope) !== row.scope || thread.updatedAt !== row.updated || Number(thread.archived) !== row.archived) fail("local_message_index_invalid");
1408
+ if (thread.legacy) continue;
1409
+ if (thread.deleted) { hiddenThreadIds.push(thread.threadId); continue; }
1410
+ // Count both directions in the actual conversation, not the outbox
1411
+ // page or a search result. Fetch only status columns for the five
1412
+ // records, so undisplayed bodies never cross the SQLite boundary.
1413
+ const recentMessages = all("SELECT id,direction,status FROM messages WHERE scope=? AND thread=? AND deleted=0 ORDER BY created DESC,seq DESC LIMIT 5", row.scope, row.id);
1414
+ const lastMessage = await openMessage(recentMessages.length ? get("SELECT * FROM messages WHERE id=?", recentMessages[0].id) : null);
1415
+ const recentSendFailureCount = recentMessages.filter((message) => message.direction === "outgoing"
1416
+ && ["recipient_offline", "failed_retryable", "failed_permanent", "reply_target_deleted"].includes(message.status)).length;
1417
+ if (lastMessage) assertScope(lastMessage, scope);
1418
+ threads.push({ threadId: thread.threadId, binding: thread.binding, legacy: thread.legacy,
1419
+ peerPrincipalId: thread.peerPrincipalId, peerHelloLynkId: thread.peerHelloLynkId,
1420
+ lastMessage, unreadCount: thread.unreadCount, updatedAt: thread.updatedAt, archived: thread.archived,
1421
+ ...(recentSendFailureCount ? { recentSendFailureCount } : {}),
1422
+ ...(thread.autoReadAfterAi === true ? { autoReadAfterAi: true } : {}) });
1423
+ }
1424
+ return { threads, hiddenThreadIds, inboxReadBoundary: meta.highWater, revision: `indexed_${meta.version}`, latestTimestamp: threads.reduce((value, item) => item.updatedAt > value ? item.updatedAt : value, "1970-01-01T00:00:00.000Z"), archivedThreadCount: threads.filter((item) => item.archived).length, autoArchiveAfterHours: config.autoArchiveAfterHours };
1425
+ });
1426
+ },
1427
+ async queryConversationPage({ scope: input, threadId, anchor = null, direction = "before", limit = 20 }) {
1428
+ const windowRead = ["from", "older-window", "newer-window"].includes(direction);
1429
+ const scope = L.localHistoryScope(input), size = limitOf(limit, windowRead ? 200 : 20);
1430
+ if (!["before", "after", "from", "older-window", "newer-window"].includes(direction)) fail("local_history_cursor_invalid");
1431
+ return locked(async () => {
1432
+ const selectedScope = selectThreadScope(scope, threadId);
1433
+ const key = threadKey(selectedScope, threadId), selectedScopeKey = scopeKey(selectedScope);
1434
+ let anchorRow = null;
1435
+ if (anchor != null) { anchorRow = rawById(anchor.localMessageId); if (!anchorRow || anchorRow.scope !== selectedScopeKey || anchorRow.thread !== key || anchorRow.created !== checkedTime(anchor.createdAt)) fail("local_history_cursor_invalid"); const message = await openMessage(anchorRow); assertScope(message, scope); }
1436
+ let selection = direction;
1437
+ if (["older-window", "newer-window"].includes(direction)) {
1438
+ if (!anchorRow) fail("local_history_cursor_invalid");
1439
+ const older = direction === "older-window", op = older ? "<" : ">", order = older ? "DESC" : "ASC";
1440
+ // Advance 20 records, then read the complete window under this lock.
1441
+ // Its cursors describe real boundaries, even after deletion/arrival.
1442
+ const edge = all(`SELECT created,seq FROM messages WHERE scope=? AND thread=? AND deleted=0 AND (created ${op} ? OR (created=? AND seq ${op} ?)) ORDER BY created ${order},seq ${order} LIMIT 20`, selectedScopeKey, key, anchorRow.created, anchorRow.created, anchorRow.seq).at(-1);
1443
+ anchorRow = edge ?? anchorRow;
1444
+ selection = older ? "from" : "through";
1445
+ }
1446
+ const values = [selectedScopeKey, key]; let condition = "";
1447
+ const descending = ["before", "through"].includes(selection), order = descending ? "DESC" : "ASC";
1448
+ if (anchorRow) { const comparator = descending ? "<" : ">", sequenceComparator = ["from", "through"].includes(selection) ? comparator + "=" : comparator; condition = ` AND (created ${comparator} ? OR (created=? AND seq ${sequenceComparator} ?))`; values.push(anchorRow.created, anchorRow.created, anchorRow.seq); }
1449
+ let rows = all(`SELECT * FROM messages WHERE scope=? AND thread=? AND deleted=0${condition} ORDER BY created ${order},seq ${order} LIMIT ?`, ...values, size);
1450
+ if (descending) rows.reverse();
1451
+ if (windowRead && !rows.length) {
1452
+ // The page's entire tail may have been deleted since it was shown.
1453
+ // Keep the remaining history reachable instead of returning an
1454
+ // empty page with neither navigation cursor.
1455
+ rows = all("SELECT * FROM messages WHERE scope=? AND thread=? AND deleted=0 ORDER BY created DESC,seq DESC LIMIT ?", selectedScopeKey, key, size).reverse();
1456
+ }
1457
+ const messages = []; for (const row of rows) { const message = await openMessage(row); assertScope(message, scope); messages.push(message); }
1458
+ const first = rows[0], last = rows.at(-1);
1459
+ const hasEarlier = first ? !!get("SELECT 1 FROM messages WHERE scope=? AND thread=? AND deleted=0 AND (created<? OR (created=? AND seq<?)) LIMIT 1", selectedScopeKey, key, first.created, first.created, first.seq) : false;
1460
+ const hasLater = last ? !!get("SELECT 1 FROM messages WHERE scope=? AND thread=? AND deleted=0 AND (created>? OR (created=? AND seq>?)) LIMIT 1", selectedScopeKey, key, last.created, last.created, last.seq) : false;
1461
+ return { messages, hasEarlier, hasLater };
1462
+ });
1463
+ },
1464
+ async readLocalHistoryStats({ scope, threadId, before = null }) { return locked(() => stats(scope, threadId, before)); },
1465
+ async searchWorkbenchThreads({ scope: input, query, limit = 500 }) {
1466
+ const scope = L.localHistoryScope(input), maximum = limitOf(limit, Number.MAX_SAFE_INTEGER);
1467
+ if (typeof query !== "string" || query.length > HELLOLYNK_WORKBENCH_MAX_QUERY_LENGTH) fail("local_history_query_invalid");
1468
+ const needles = [...new Set(query.normalize("NFKC").trim().toLocaleLowerCase("und").split(/\s+/u).filter(Boolean))]; if (!needles.length) return [];
1469
+ const found = new Set(), partial = new Map(); let cursor = 0;
1470
+ const keys = readScopeKeys(scope);
1471
+ const upper = await locked(() => get(`SELECT coalesce(max(seq),0) AS n FROM messages WHERE scope IN (${keys.map(() => "?").join(",")})`, ...keys).n);
1472
+ // Explicit search alone may scan message bodies. Release the store lock
1473
+ // between batches so history search never starves live message writes.
1474
+ // Fix the scan boundary: a steady receiver must not extend this read
1475
+ // forever. The read model retries if its surrounding revision changed.
1476
+ for (;;) {
1477
+ const batch = await locked(async () => {
1478
+ const rows = all(`SELECT * FROM messages WHERE scope IN (${keys.map(() => "?").join(",")}) AND deleted=0 AND seq>? AND seq<=? ORDER BY seq LIMIT 100`, ...keys, cursor, upper);
1479
+ const matches = [];
1480
+ for (const row of rows) { const message = await openMessage(row); assertScope(message, scope); const thread = messageOwner(message).threadId;
1481
+ const text = message.text.normalize("NFKC").toLocaleLowerCase("und"), matched = partial.get(thread) || new Set();
1482
+ needles.forEach((needle, index) => { if (text.includes(needle)) matched.add(index); });
1483
+ partial.set(thread, matched); if (matched.size === needles.length) matches.push(thread);
1484
+ }
1485
+ return { cursor: rows.at(-1)?.seq ?? cursor, done: rows.length < 100, matches };
1486
+ });
1487
+ cursor = batch.cursor; for (const thread of batch.matches) if (thread) found.add(thread);
1488
+ if (batch.done || found.size >= maximum) return [...found].slice(0, maximum);
1489
+ await new Promise((resolve) => setImmediate(resolve));
1490
+ }
1491
+ },
1492
+ async configureLocalHistory({ scope, autoArchiveAfterHours }) {
1493
+ if (autoArchiveAfterHours !== null && autoArchiveAfterHours !== 24) fail("local_history_configuration_invalid");
1494
+ return locked(async (meta) => { const current = await configuration(scope); await commit(meta, { settings: [{ ...current, version: current.version + 1, autoArchiveAfterHours }] }); return { autoArchiveAfterHours }; });
1495
+ },
1496
+ hideLocalConversation: (input) => archive(input, true),
1497
+ restoreLocalConversation: (input) => archive(input, false),
1498
+ deleteLocalConversation: (input) => deleteConversations(input),
1499
+ deleteArchivedConversations: (input) => deleteConversations(input, true),
1500
+ async clearLocalConversation(input) {
1501
+ const requestedScope = L.localHistoryScope(input), threadId = L.safeId(input.threadId, "local_history_thread_invalid"), before = input.before == null ? null : checkedTime(input.before);
1502
+ const scope = await locked(() => selectThreadScope(requestedScope, threadId));
1503
+ const result = { deletedCount: 0, retainedUnreadCount: 0, retainedOutgoingCount: 0, retainedTaskCount: 0 };
1504
+ const upper = await locked(() => get("SELECT coalesce(max(seq),0) AS n FROM messages WHERE scope=? AND thread=?", scopeKey(scope), threadKey(scope, threadId)).n);
1505
+ let cursor = 0;
1506
+ for (;;) {
1507
+ const batch = await locked(async (meta) => {
1508
+ const rows = all(`SELECT * FROM messages WHERE scope=? AND thread=? AND deleted=0 AND seq>? AND seq<=?${before ? " AND created<?" : ""} ORDER BY seq LIMIT 100`, scopeKey(scope), threadKey(scope, threadId), cursor, upper, ...(before ? [before] : []));
1509
+ const counts = { deletedCount: 0, retainedUnreadCount: 0, retainedOutgoingCount: 0, retainedTaskCount: 0 }, changes = [];
1510
+ for (const row of rows) { const message = await openMessage(row); assertScope(message, scope); const reason = await protectedReason(message);
1511
+ if (reason) { counts[reason === "unread" ? "retainedUnreadCount" : reason === "outgoing" ? "retainedOutgoingCount" : "retainedTaskCount"]++; continue; }
1512
+ changes.push(L.localHistoryTombstone(message, scope)); counts.deletedCount++;
1513
+ }
1514
+ if (changes.length) { await commit(meta, { messages: changes }); await options.fault?.("delete-batch-committed"); }
1515
+ return { counts, cursor: rows.at(-1)?.seq ?? cursor, done: rows.length < 100 };
1516
+ });
1517
+ for (const key of Object.keys(result)) result[key] += batch.counts[key]; cursor = batch.cursor;
1518
+ if (batch.done) return result;
1519
+ await new Promise((resolve) => setImmediate(resolve));
1520
+ }
1521
+ },
1522
+ async deleteSentLocal(input) {
1523
+ const scope = L.localHistoryScope(input), ids = input.localMessageIds;
1524
+ if (!Array.isArray(ids) || !ids.length || ids.length > 500) throw L.localHistoryFailure("message_invalid");
1525
+ return locked(async (meta) => {
1526
+ const changes = [];
1527
+ for (const id of [...new Set(ids)]) { const message = await openMessage(rawById(id)); if (!message) throw L.localHistoryFailure("record_not_found"); assertScope(message, scope);
1528
+ if (message.direction !== "outgoing" || !L.localHistorySent(message) || await protectedReason(message)) throw L.localHistoryFailure("outgoing_protected");
1529
+ if (message.messageType !== DELETED) changes.push(L.localHistoryTombstone(message, scope));
1530
+ }
1531
+ if (changes.length) await commit(meta, { messages: changes }); return { deletedCount: changes.length, retainedUnreadCount: 0, retainedOutgoingCount: 0, retainedTaskCount: 0 };
1532
+ });
1533
+ },
1534
+ async maintainLocalHistory({ limit = 50 } = {}) {
1535
+ const size = limitOf(limit, 50);
1536
+ return locked(async (meta) => {
1537
+ await purgeDeletedBatch(); meta = await readMeta();
1538
+ const cursor = meta.maintenanceCursor ?? null, cutoff = new Date(now() - 86400000).toISOString();
1539
+ const rows = all(`SELECT * FROM threads WHERE archived=0 AND updated<?${cursor ? " AND (updated>? OR (updated=? AND id>?))" : ""} ORDER BY updated,id LIMIT ?`, cutoff, ...(cursor ? [cursor.updated, cursor.updated, cursor.id] : []), size);
1540
+ const changes = [];
1541
+ for (const row of rows) {
1542
+ const thread = await openRecord("thread", row), config = await configuration(thread.scope);
1543
+ if (thread.deleted || thread.legacy || config.autoArchiveAfterHours !== 24) continue;
1544
+ if (thread.unreadCount || thread.outgoingProtectedCount) continue;
1545
+ changes.push({ ...thread, archived: true });
1546
+ }
1547
+ const tail = rows.at(-1), maintenanceCursor = rows.length < size ? null : { updated: tail.updated, id: tail.id };
1548
+ if (changes.length || cursor || maintenanceCursor) await commit(meta, { threads: changes, meta: { maintenanceCursor } });
1549
+ return { archivedCount: changes.length, ...reclaimUnusedPages() };
1550
+ });
1551
+ },
1552
+ async readWorkbenchState(input) {
1553
+ const summary = await api.readWorkbenchSummary(input);
1554
+ return { messages: await api.list(), hiddenThreadIds: summary.threads.filter((thread) => thread.archived).map((thread) => thread.threadId) };
1555
+ },
1556
+ };
1557
+ }
1558
+ }