@kingcrab/pi-imessage 0.0.42 → 1.0.0

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 (183) hide show
  1. package/README.md +101 -108
  2. package/dist/agent/behavior.js +79 -0
  3. package/dist/agent/chats.js +67 -0
  4. package/dist/agent/commands.js +163 -0
  5. package/dist/agent/compaction.js +51 -0
  6. package/dist/agent/deliver.js +139 -0
  7. package/dist/agent/direct-send.js +68 -0
  8. package/dist/agent/harness.js +79 -0
  9. package/dist/agent/health.js +32 -0
  10. package/dist/agent/models.js +69 -0
  11. package/dist/agent/progress-format.js +87 -0
  12. package/dist/agent/progress.js +135 -0
  13. package/dist/agent/prompt.js +79 -0
  14. package/dist/agent/replies.js +9 -0
  15. package/dist/agent/run.js +159 -0
  16. package/dist/agent/scheduling.js +290 -0
  17. package/dist/agent/time.js +18 -0
  18. package/dist/cli.js +88 -191
  19. package/dist/config/settings.js +60 -0
  20. package/dist/extensions/browser.js +326 -0
  21. package/dist/extensions/final-text.js +23 -0
  22. package/dist/extensions/memory.js +109 -0
  23. package/dist/extensions/message.js +33 -0
  24. package/dist/extensions/read-image.js +72 -0
  25. package/dist/extensions/schedules.js +41 -0
  26. package/dist/extensions/subagent.js +54 -0
  27. package/dist/extensions/web.js +99 -0
  28. package/dist/extensions/workspace.js +98 -0
  29. package/dist/main.js +571 -74
  30. package/dist/transport/attachments.js +37 -0
  31. package/dist/transport/http.js +110 -0
  32. package/dist/{rich-text.js → transport/rich-text.js} +25 -13
  33. package/dist/{send.js → transport/send.js} +13 -13
  34. package/dist/transport/watch.js +124 -0
  35. package/dist/web/app.js +748 -0
  36. package/dist/web/history.js +87 -0
  37. package/dist/web/memory.js +81 -0
  38. package/dist/web/page.js +28 -0
  39. package/dist/web/schedules.js +74 -0
  40. package/dist/web/server.js +348 -0
  41. package/dist/web/style.css +543 -0
  42. package/dist/web/tasks.js +63 -0
  43. package/docs/adr/0001-durable-owns-state.md +11 -0
  44. package/docs/adr/0002-module-layering.md +11 -0
  45. package/docs/adr/0003-replies-at-most-once.md +11 -0
  46. package/docs/adr/0004-native-extensions.md +11 -0
  47. package/docs/adr/0005-history-migration.md +11 -0
  48. package/docs/adr/0006-jsonl-storage.md +11 -0
  49. package/docs/adr/0007-images-by-path.md +11 -0
  50. package/docs/adr/0008-compaction-and-run.md +11 -0
  51. package/docs/adr/0009-one-shot-cutover.md +11 -0
  52. package/docs/adr/0010-test-budget.md +11 -0
  53. package/docs/adr/0011-reconcile-unsent-answers.md +24 -0
  54. package/docs/adr/0012-run-in-owned-conversation.md +22 -0
  55. package/docs/adr/0013-thin-adapters-around-durable.md +18 -0
  56. package/docs/adr/0014-ephemeral-private-storage.md +13 -0
  57. package/docs/adr/0015-ordinary-stop-keeps-native-inbox.md +21 -0
  58. package/docs/adr/0016-process-http-transport.md +21 -0
  59. package/docs/adr/0017-browser-cli-owned-scope.md +22 -0
  60. package/docs/adr/0018-tasks-without-prompt-api.md +19 -0
  61. package/docs/adr/0019-remove-scheduled-services.md +41 -0
  62. package/docs/adr/0020-chat-task-progress.md +41 -0
  63. package/docs/adr/0021-readable-progress-without-link-previews.md +33 -0
  64. package/docs/adr/0022-progress-admission-and-settlement.md +35 -0
  65. package/docs/adr/0023-retain-progress-type-prefixes.md +18 -0
  66. package/docs/adr/0024-status-marker-before-type-prefix.md +13 -0
  67. package/docs/adr/0025-reusable-progress-and-command-preview.md +49 -0
  68. package/docs/adr/0026-inline-command-only-requests.md +16 -0
  69. package/docs/adr/0027-command-preview-width.md +10 -0
  70. package/docs/adr/0028-command-preview-120.md +8 -0
  71. package/docs/adr/0030-shared-web-read-cache.md +33 -0
  72. package/docs/adr/0031-quiet-compaction-progress.md +32 -0
  73. package/docs/adr/0032-native-background-schedules.md +58 -0
  74. package/docs/adr/0033-operator-scheduled-run.md +23 -0
  75. package/docs/adr/0034-machine-local-schedule-time.md +36 -0
  76. package/docs/adr/0035-external-business-task-extensions.md +56 -0
  77. package/docs/adr/0036-workspace-policy-and-neutral-service-id.md +37 -0
  78. package/docs/adr/0037-restart-during-in-flight-work.md +30 -0
  79. package/docs/adr/0038-restart-any-time.md +25 -0
  80. package/docs/adr/0039-rerun-interrupted-tools.md +24 -0
  81. package/docs/adr/0040-chat-progress-notices.md +44 -0
  82. package/docs/adr/0041-restart-and-recovery.md +28 -0
  83. package/docs/adr/0042-background-schedules-and-workspace-extensions.md +44 -0
  84. package/docs/adr/0043-remove-legacy-migration.md +23 -0
  85. package/docs/adr/0044-message-channels.md +32 -0
  86. package/docs/adr/0045-chat-scheduling-tools.md +51 -0
  87. package/docs/adr/0046-selective-command-redaction.md +31 -0
  88. package/docs/adr/0047-one-scheduling-path.md +158 -0
  89. package/docs/adr/README.md +50 -0
  90. package/docs/api.md +92 -0
  91. package/docs/architecture.md +164 -0
  92. package/docs/browser.md +93 -0
  93. package/docs/memory.md +27 -0
  94. package/docs/screenshot.png +0 -0
  95. package/docs/workspace-extensions.md +133 -0
  96. package/examples/workspace-extensions/system-context/index.ts +69 -0
  97. package/examples/workspace-extensions/system-context/package.json +3 -0
  98. package/ops/README.md +267 -0
  99. package/ops/preflight.mjs +28 -0
  100. package/package.json +33 -45
  101. package/dist/agent.d.ts +0 -60
  102. package/dist/agent.d.ts.map +0 -1
  103. package/dist/agent.js +0 -715
  104. package/dist/agent.js.map +0 -1
  105. package/dist/cli.d.ts +0 -15
  106. package/dist/cli.d.ts.map +0 -1
  107. package/dist/cli.js.map +0 -1
  108. package/dist/imessage.d.ts +0 -33
  109. package/dist/imessage.d.ts.map +0 -1
  110. package/dist/imessage.js +0 -76
  111. package/dist/imessage.js.map +0 -1
  112. package/dist/logger.d.ts +0 -33
  113. package/dist/logger.d.ts.map +0 -1
  114. package/dist/logger.js +0 -104
  115. package/dist/logger.js.map +0 -1
  116. package/dist/main.d.ts +0 -5
  117. package/dist/main.d.ts.map +0 -1
  118. package/dist/main.js.map +0 -1
  119. package/dist/memory.d.ts +0 -53
  120. package/dist/memory.d.ts.map +0 -1
  121. package/dist/memory.js +0 -196
  122. package/dist/memory.js.map +0 -1
  123. package/dist/model-health.d.ts +0 -11
  124. package/dist/model-health.d.ts.map +0 -1
  125. package/dist/model-health.js +0 -67
  126. package/dist/model-health.js.map +0 -1
  127. package/dist/pipeline.d.ts +0 -27
  128. package/dist/pipeline.d.ts.map +0 -1
  129. package/dist/pipeline.js +0 -59
  130. package/dist/pipeline.js.map +0 -1
  131. package/dist/queue.d.ts +0 -23
  132. package/dist/queue.d.ts.map +0 -1
  133. package/dist/queue.js +0 -89
  134. package/dist/queue.js.map +0 -1
  135. package/dist/rich-text.d.ts +0 -26
  136. package/dist/rich-text.d.ts.map +0 -1
  137. package/dist/rich-text.js.map +0 -1
  138. package/dist/self-echo.d.ts +0 -19
  139. package/dist/self-echo.d.ts.map +0 -1
  140. package/dist/self-echo.js +0 -54
  141. package/dist/self-echo.js.map +0 -1
  142. package/dist/send.d.ts +0 -24
  143. package/dist/send.d.ts.map +0 -1
  144. package/dist/send.js.map +0 -1
  145. package/dist/settings.d.ts +0 -44
  146. package/dist/settings.d.ts.map +0 -1
  147. package/dist/settings.js +0 -80
  148. package/dist/settings.js.map +0 -1
  149. package/dist/store.d.ts +0 -57
  150. package/dist/store.d.ts.map +0 -1
  151. package/dist/store.js +0 -124
  152. package/dist/store.js.map +0 -1
  153. package/dist/tasks.d.ts +0 -95
  154. package/dist/tasks.d.ts.map +0 -1
  155. package/dist/tasks.js +0 -384
  156. package/dist/tasks.js.map +0 -1
  157. package/dist/types.d.ts +0 -93
  158. package/dist/types.d.ts.map +0 -1
  159. package/dist/types.js +0 -38
  160. package/dist/types.js.map +0 -1
  161. package/dist/watch.d.ts +0 -21
  162. package/dist/watch.d.ts.map +0 -1
  163. package/dist/watch.js +0 -219
  164. package/dist/watch.js.map +0 -1
  165. package/dist/web/data.d.ts +0 -11
  166. package/dist/web/data.d.ts.map +0 -1
  167. package/dist/web/data.js +0 -56
  168. package/dist/web/data.js.map +0 -1
  169. package/dist/web/html.d.ts +0 -5
  170. package/dist/web/html.d.ts.map +0 -1
  171. package/dist/web/html.js +0 -16
  172. package/dist/web/html.js.map +0 -1
  173. package/dist/web/index.d.ts +0 -23
  174. package/dist/web/index.d.ts.map +0 -1
  175. package/dist/web/index.js +0 -279
  176. package/dist/web/index.js.map +0 -1
  177. package/dist/web/render.d.ts +0 -11
  178. package/dist/web/render.d.ts.map +0 -1
  179. package/dist/web/render.js +0 -56
  180. package/dist/web/render.js.map +0 -1
  181. package/dist/web/templates/logs.eta +0 -75
  182. package/dist/web/templates/memory.eta +0 -91
  183. package/dist/web/templates/page.eta +0 -98
@@ -0,0 +1,37 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { access, copyFile, mkdir, readFile, rename, rm } from "node:fs/promises";
4
+ import { extname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execute = promisify(execFile);
7
+ export async function archiveAttachments(workingDir, chatGuid, paths) {
8
+ const directory = join(workingDir, "attachments", encodeURIComponent(chatGuid));
9
+ await mkdir(directory, { recursive: true });
10
+ const result = [];
11
+ for (const source of paths) {
12
+ const hash = createHash("sha256")
13
+ .update(await readFile(source))
14
+ .digest("hex");
15
+ const extension = extname(source).toLowerCase();
16
+ const heic = [".heic", ".heif"].includes(extension);
17
+ const destination = join(directory, hash + (heic ? ".jpg" : extension));
18
+ try {
19
+ await access(destination);
20
+ }
21
+ catch {
22
+ const temporary = `${destination}.${randomUUID()}`;
23
+ try {
24
+ if (heic)
25
+ await execute("/usr/bin/sips", ["-s", "format", "jpeg", source, "--out", temporary]);
26
+ else
27
+ await copyFile(source, temporary);
28
+ await rename(temporary, destination);
29
+ }
30
+ finally {
31
+ await rm(temporary, { force: true });
32
+ }
33
+ }
34
+ result.push(destination);
35
+ }
36
+ return result;
37
+ }
@@ -0,0 +1,110 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { Client, EnvHttpProxyAgent, getGlobalDispatcher, install, Pool, setGlobalDispatcher, } from "undici";
3
+ const initialFetch = globalThis.fetch;
4
+ const fetchGlobals = [
5
+ "fetch",
6
+ "Headers",
7
+ "Response",
8
+ "Request",
9
+ "FormData",
10
+ "WebSocket",
11
+ "CloseEvent",
12
+ "ErrorEvent",
13
+ "MessageEvent",
14
+ "EventSource",
15
+ ];
16
+ let processOwned = false;
17
+ // A terminated body still rejects its reader. Avoid an unhandled Client error crashing the process.
18
+ function listenForClientErrors(dispatcher) {
19
+ EventEmitter.prototype.on.call(dispatcher, "error", () => { });
20
+ return dispatcher;
21
+ }
22
+ function createClient(origin, options) {
23
+ return listenForClientErrors(new Client(origin, options));
24
+ }
25
+ /** One process owner; settings are defaults beneath explicit environment proxy values. */
26
+ export function openHttpTransport(settings) {
27
+ if (processOwned)
28
+ throw new Error("HTTP transport already has a process owner");
29
+ const previousDispatcher = getGlobalDispatcher();
30
+ const globals = [];
31
+ const retiring = new Set();
32
+ let retirementError;
33
+ let current;
34
+ let closing;
35
+ function retire(dispatcher) {
36
+ const pending = dispatcher
37
+ .close()
38
+ .catch((error) => {
39
+ retirementError ??= error;
40
+ })
41
+ .finally(() => retiring.delete(pending));
42
+ retiring.add(pending);
43
+ }
44
+ function reload(next) {
45
+ if (closing)
46
+ throw new Error("HTTP transport is closed");
47
+ if (current && getGlobalDispatcher() !== current)
48
+ throw new Error("HTTP dispatcher ownership changed; refusing to replace another caller's transport");
49
+ if (!Number.isFinite(next.idleTimeoutMs) || next.idleTimeoutMs < 0)
50
+ throw new Error("Invalid HTTP idle timeout");
51
+ const proxy = next.proxy?.trim() || undefined;
52
+ const dispatcher = listenForClientErrors(new EnvHttpProxyAgent({
53
+ httpProxy: process.env.http_proxy ?? process.env.HTTP_PROXY ?? proxy ?? "",
54
+ httpsProxy: process.env.https_proxy ?? process.env.HTTPS_PROXY ?? proxy ?? "",
55
+ noProxy: process.env.no_proxy ?? process.env.NO_PROXY ?? "",
56
+ allowH2: false,
57
+ proxyTunnel: true,
58
+ headersTimeout: Math.floor(next.idleTimeoutMs),
59
+ bodyTimeout: Math.floor(next.idleTimeoutMs),
60
+ connect: { autoSelectFamilyAttemptTimeout: 2000 },
61
+ clientFactory: createClient,
62
+ factory: (origin, options) => options.connections === 1
63
+ ? createClient(origin, options)
64
+ : listenForClientErrors(new Pool(origin, { ...options, factory: createClient })),
65
+ }));
66
+ setGlobalDispatcher(dispatcher);
67
+ const previous = current;
68
+ current = dispatcher;
69
+ if (previous)
70
+ retire(previous); // close drains active requests; it does not abort them.
71
+ }
72
+ reload(settings);
73
+ processOwned = true;
74
+ // Match fetch and its constructors to the dispatcher, but preserve a deliberate fetch override.
75
+ if (globalThis.fetch === initialFetch) {
76
+ for (const name of fetchGlobals)
77
+ globals.push({ name, previous: Object.getOwnPropertyDescriptor(globalThis, name) });
78
+ install();
79
+ for (const entry of globals)
80
+ entry.installed = Object.getOwnPropertyDescriptor(globalThis, entry.name);
81
+ }
82
+ return {
83
+ reload,
84
+ close() {
85
+ closing ??= (async () => {
86
+ if (getGlobalDispatcher() === current)
87
+ setGlobalDispatcher(previousDispatcher);
88
+ for (const entry of globals) {
89
+ if (Object.getOwnPropertyDescriptor(globalThis, entry.name)?.value !== entry.installed?.value)
90
+ continue;
91
+ if (entry.previous)
92
+ Object.defineProperty(globalThis, entry.name, entry.previous);
93
+ else
94
+ Reflect.deleteProperty(globalThis, entry.name);
95
+ }
96
+ if (current)
97
+ retire(current);
98
+ try {
99
+ await Promise.all(retiring);
100
+ if (retirementError)
101
+ throw new Error("HTTP transport cleanup failed", { cause: retirementError });
102
+ }
103
+ finally {
104
+ processOwned = false;
105
+ }
106
+ })();
107
+ return closing;
108
+ },
109
+ };
110
+ }
@@ -104,7 +104,12 @@ function parseMarkdownToSpans(markdown) {
104
104
  for (const match of markdown.matchAll(pattern)) {
105
105
  const index = match.index ?? 0;
106
106
  if (index > lastIndex) {
107
- spans.push({ text: markdown.slice(lastIndex, index), bold: false, italic: false, strikethrough: false });
107
+ spans.push({
108
+ text: markdown.slice(lastIndex, index),
109
+ bold: false,
110
+ italic: false,
111
+ strikethrough: false,
112
+ });
108
113
  }
109
114
  const token = match[0];
110
115
  if (token.startsWith("**") && token.endsWith("**")) {
@@ -163,16 +168,23 @@ async function copyRichTextToClipboard(text, options) {
163
168
  await rm(dir, { recursive: true, force: true });
164
169
  }
165
170
  }
166
- export async function sendRichTextMessage(chatGuid, text, options) {
167
- const parts = chatGuid.split(";");
168
- const isGroup = parts[1] === "+";
169
- if (isGroup) {
170
- throw new Error("Rich-text sending is currently only supported for direct-message chats");
171
- }
172
- const recipient = recipientFromChatGuid(chatGuid);
173
- await copyRichTextToClipboard(text, options);
174
- await openConversation(recipient);
175
- await pasteAndSend();
176
- console.log(`[send] sent rich text to ${chatGuid}: "${text.substring(0, 60)}"`);
171
+ // The pasteboard and frontmost conversation are process-wide, not per chat.
172
+ let pendingSend = Promise.resolve();
173
+ export function sendRichTextMessage(chatGuid, text, options) {
174
+ const resolvedOptions = { ...DEFAULT_RICH_TEXT_OPTIONS, ...options };
175
+ const operation = pendingSend.then(async () => {
176
+ const parts = chatGuid.split(";");
177
+ const isGroup = parts[1] === "+";
178
+ if (isGroup) {
179
+ throw new Error("Rich-text sending is currently only supported for direct-message chats");
180
+ }
181
+ const recipient = recipientFromChatGuid(chatGuid);
182
+ await copyRichTextToClipboard(text, resolvedOptions);
183
+ await openConversation(recipient);
184
+ await pasteAndSend();
185
+ console.log(`[send] sent rich text to ${chatGuid}: "${text.substring(0, 60)}"`);
186
+ });
187
+ // Release the transport turn on failure, without retrying a possibly completed send.
188
+ pendingSend = operation.catch(() => { });
189
+ return operation;
177
190
  }
178
- //# sourceMappingURL=rich-text.js.map
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { execFile } from "node:child_process";
17
17
  import { randomUUID } from "node:crypto";
18
- import { constants, accessSync } from "node:fs";
18
+ import { accessSync, constants } from "node:fs";
19
19
  import { copyFile, mkdir, unlink } from "node:fs/promises";
20
20
  import { homedir } from "node:os";
21
21
  import { basename, extname, join } from "node:path";
@@ -26,8 +26,6 @@ const SCRIPT_TIMEOUT_MS = 30_000;
26
26
  const ATTACHMENT_VERIFY_TIMEOUT_MS = 30_000;
27
27
  const ATTACHMENT_VERIFY_POLL_MS = 500;
28
28
  const MESSAGES_APP_PATH = "/System/Applications/Messages.app";
29
- const ATTACHMENTS_ROOT = join(homedir(), "Library", "Messages", "Attachments");
30
- const STAGING_DIR = join(ATTACHMENTS_ROOT, "pi-imessage");
31
29
  const CHAT_DB_PATH = join(homedir(), "Library", "Messages", "chat.db");
32
30
  // ── AppleScript helpers ───────────────────────────────────────────────────────
33
31
  /** Escape special characters for AppleScript string literals. */
@@ -94,17 +92,18 @@ end tell`;
94
92
  *
95
93
  * Returns the staged path; caller is responsible for cleanup after send.
96
94
  */
97
- async function stageAttachment(sourcePath) {
95
+ async function stageAttachment(sourcePath, attachmentsRoot) {
98
96
  // If the source is already inside the Attachments root, imagent can read it.
99
- if (sourcePath.startsWith(`${ATTACHMENTS_ROOT}/`)) {
97
+ if (sourcePath.startsWith(`${attachmentsRoot}/`)) {
100
98
  return { stagedPath: sourcePath, cleanup: async () => { } };
101
99
  }
102
- await mkdir(STAGING_DIR, { recursive: true, mode: 0o755 });
100
+ const stagingDirectory = join(attachmentsRoot, "pi-imessage");
101
+ await mkdir(stagingDirectory, { recursive: true, mode: 0o755 });
103
102
  const ext = extname(sourcePath);
104
103
  const base = basename(sourcePath, ext)
105
104
  .replace(/[^A-Za-z0-9._-]/g, "_")
106
105
  .slice(0, 40);
107
- const stagedPath = join(STAGING_DIR, `${Date.now()}-${randomUUID().slice(0, 8)}-${base}${ext}`);
106
+ const stagedPath = join(stagingDirectory, `${Date.now()}-${randomUUID().slice(0, 8)}-${base}${ext}`);
108
107
  await copyFile(sourcePath, stagedPath);
109
108
  return {
110
109
  stagedPath,
@@ -119,7 +118,7 @@ async function stageAttachment(sourcePath) {
119
118
  };
120
119
  }
121
120
  /** Poll chat.db until the staged attachment finishes transferring (or times out). */
122
- async function verifyAttachmentSent(stagedPath, timeoutMs) {
121
+ async function verifyAttachmentSent(stagedPath, timeoutMs, dbPath) {
123
122
  const stagedBase = basename(stagedPath);
124
123
  // filename in chat.db can be stored as absolute path or "~/Library/Messages/Attachments/...",
125
124
  // so match on the unique basename we generated (Date.now() + UUID prefix).
@@ -134,7 +133,7 @@ async function verifyAttachmentSent(stagedPath, timeoutMs) {
134
133
  let last = { attachmentId: null, transferState: null, isSent: null, error: null };
135
134
  while (Date.now() < deadline) {
136
135
  try {
137
- const { stdout } = await execFileAsync("sqlite3", ["-json", CHAT_DB_PATH, sql], { timeout: 5_000 });
136
+ const { stdout } = await execFileAsync("sqlite3", ["-json", dbPath, sql], { timeout: 5_000 });
138
137
  const rows = stdout.trim() ? JSON.parse(stdout) : [];
139
138
  const row = rows[0];
140
139
  if (row) {
@@ -221,7 +220,9 @@ end tell`;
221
220
  console.warn(`[send] failed to check iMessage status; continuing so the watchdog can diagnose send failures: ${error instanceof Error ? error.message : String(error)}`);
222
221
  }
223
222
  }
224
- export function createMessageSender() {
223
+ export function createMessageSender(options = {}) {
224
+ const attachmentsRoot = options.attachmentsRoot ?? join(homedir(), "Library", "Messages", "Attachments");
225
+ const dbPath = options.dbPath ?? CHAT_DB_PATH;
225
226
  return {
226
227
  async sendMessage(chatGuid, text, richText) {
227
228
  const startTime = Date.now();
@@ -241,7 +242,7 @@ export function createMessageSender() {
241
242
  accessSync(filePath, constants.R_OK);
242
243
  const origName = basename(filePath);
243
244
  console.log(`[send] start: ${chatGuid} mode=attachment file=${origName}`);
244
- const { stagedPath, cleanup } = await stageAttachment(filePath);
245
+ const { stagedPath, cleanup } = await stageAttachment(filePath, attachmentsRoot);
245
246
  const staged = stagedPath !== filePath;
246
247
  if (staged) {
247
248
  console.log(`[send] staged attachment for imagent: ${basename(stagedPath)}`);
@@ -249,7 +250,7 @@ export function createMessageSender() {
249
250
  try {
250
251
  const script = buildSendAttachmentScript(chatGuid, stagedPath);
251
252
  await execFileAsync("osascript", ["-e", script], { timeout: SCRIPT_TIMEOUT_MS });
252
- const status = await verifyAttachmentSent(stagedPath, ATTACHMENT_VERIFY_TIMEOUT_MS);
253
+ const status = await verifyAttachmentSent(stagedPath, ATTACHMENT_VERIFY_TIMEOUT_MS, dbPath);
253
254
  if (!status.ok) {
254
255
  throw new Error(`attachment send failed for ${origName}: ${status.detail}`);
255
256
  }
@@ -266,4 +267,3 @@ export function createMessageSender() {
266
267
  },
267
268
  };
268
269
  }
269
- //# sourceMappingURL=send.js.map
@@ -0,0 +1,124 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import Database from "better-sqlite3";
4
+ export const defaultDatabasePath = join(homedir(), "Library", "Messages", "chat.db");
5
+ export function resolveMessageText(row) {
6
+ if (row.text?.trim())
7
+ return row.text.trim();
8
+ const blob = row.attributedBody;
9
+ if (!blob)
10
+ return "";
11
+ const marker = blob.indexOf("NSString");
12
+ if (marker < 0)
13
+ return "";
14
+ let position = blob.indexOf(0x2b, marker + 8) + 1;
15
+ if (position <= 0 || position >= blob.length)
16
+ return "";
17
+ let length = blob[position++];
18
+ if (length === 0x81) {
19
+ if (position + 2 > blob.length)
20
+ return "";
21
+ length = blob.readUInt16LE(position);
22
+ position += 2;
23
+ }
24
+ return position + length <= blob.length
25
+ ? blob
26
+ .subarray(position, position + length)
27
+ .toString("utf8")
28
+ .trim()
29
+ : "";
30
+ }
31
+ export function createWatcher(options) {
32
+ const db = new Database(options.dbPath, { readonly: true, fileMustExist: true });
33
+ let cursor;
34
+ try {
35
+ const maximum = db.prepare("SELECT COALESCE(MAX(ROWID),0) AS value FROM message").get().value;
36
+ cursor = options.cursor ?? maximum;
37
+ if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > maximum)
38
+ throw new Error("Messages cursor is invalid or ahead of the database; reconcile it before starting");
39
+ }
40
+ catch (error) {
41
+ db.close();
42
+ throw error;
43
+ }
44
+ let stopped = false;
45
+ let started = false;
46
+ let closing;
47
+ let timer;
48
+ let pending;
49
+ async function readPage() {
50
+ const rows = db
51
+ .prepare(`SELECT m.ROWID AS rowid,m.guid,m.text,m.attributedBody,
52
+ m.is_from_me AS isFromMe,m.associated_message_type AS reaction,m.service,
53
+ m.thread_originator_guid AS replyGuid,h.id AS sender,c.guid AS chatGuid,c.display_name AS groupName
54
+ FROM message m LEFT JOIN handle h ON h.ROWID=m.handle_id
55
+ LEFT JOIN chat_message_join j ON j.message_id=m.ROWID LEFT JOIN chat c ON c.ROWID=j.chat_id
56
+ WHERE m.ROWID>? ORDER BY m.ROWID LIMIT 200`)
57
+ .all(cursor);
58
+ for (const row of rows) {
59
+ if (stopped)
60
+ break;
61
+ if (!row.isFromMe && !row.reaction && row.chatGuid) {
62
+ const attachments = db
63
+ .prepare(`SELECT a.filename FROM attachment a
64
+ JOIN message_attachment_join j ON j.attachment_id=a.ROWID WHERE j.message_id=?`)
65
+ .all(row.rowid).flatMap(({ filename }) => (filename ? [filename.replace(/^~(?=\/)/, homedir())] : []));
66
+ const reply = row.replyGuid
67
+ ? db.prepare("SELECT text,attributedBody FROM message WHERE guid=?").get(row.replyGuid)
68
+ : undefined;
69
+ const text = resolveMessageText(row);
70
+ if (text || attachments.length)
71
+ await options.accept({
72
+ rowid: row.rowid,
73
+ guid: row.guid,
74
+ chatGuid: row.chatGuid,
75
+ text,
76
+ sender: row.sender ?? "unknown",
77
+ service: row.service ?? "iMessage",
78
+ groupName: row.groupName ?? "",
79
+ replyTo: reply ? resolveMessageText(reply) : null,
80
+ attachments,
81
+ });
82
+ }
83
+ await options.saveCursor(row.rowid);
84
+ cursor = row.rowid;
85
+ }
86
+ }
87
+ function poll() {
88
+ if (stopped)
89
+ return Promise.resolve();
90
+ pending ??= readPage().finally(() => {
91
+ pending = undefined;
92
+ });
93
+ return pending;
94
+ }
95
+ function tick() {
96
+ void poll()
97
+ .catch(options.onError)
98
+ .finally(() => {
99
+ if (!stopped)
100
+ timer = setTimeout(tick, options.intervalMs ?? 2000);
101
+ });
102
+ }
103
+ return {
104
+ get cursor() {
105
+ return cursor;
106
+ },
107
+ poll,
108
+ start() {
109
+ if (stopped || started)
110
+ return;
111
+ started = true;
112
+ tick();
113
+ },
114
+ stop() {
115
+ stopped = true;
116
+ clearTimeout(timer);
117
+ closing ??= (async () => {
118
+ await pending?.catch(() => { });
119
+ db.close();
120
+ })();
121
+ return closing;
122
+ },
123
+ };
124
+ }