@kingcrab/pi-imessage 0.0.43 → 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 (187) hide show
  1. package/README.md +101 -127
  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 +568 -88
  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 +36 -37
  101. package/dist/agent.d.ts +0 -84
  102. package/dist/agent.d.ts.map +0 -1
  103. package/dist/agent.js +0 -780
  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 -69
  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/reminders.d.ts +0 -42
  136. package/dist/reminders.d.ts.map +0 -1
  137. package/dist/reminders.js +0 -207
  138. package/dist/reminders.js.map +0 -1
  139. package/dist/rich-text.d.ts +0 -26
  140. package/dist/rich-text.d.ts.map +0 -1
  141. package/dist/rich-text.js.map +0 -1
  142. package/dist/self-echo.d.ts +0 -19
  143. package/dist/self-echo.d.ts.map +0 -1
  144. package/dist/self-echo.js +0 -54
  145. package/dist/self-echo.js.map +0 -1
  146. package/dist/send.d.ts +0 -24
  147. package/dist/send.d.ts.map +0 -1
  148. package/dist/send.js.map +0 -1
  149. package/dist/settings.d.ts +0 -44
  150. package/dist/settings.d.ts.map +0 -1
  151. package/dist/settings.js +0 -80
  152. package/dist/settings.js.map +0 -1
  153. package/dist/store.d.ts +0 -57
  154. package/dist/store.d.ts.map +0 -1
  155. package/dist/store.js +0 -124
  156. package/dist/store.js.map +0 -1
  157. package/dist/tasks.d.ts +0 -95
  158. package/dist/tasks.d.ts.map +0 -1
  159. package/dist/tasks.js +0 -384
  160. package/dist/tasks.js.map +0 -1
  161. package/dist/types.d.ts +0 -93
  162. package/dist/types.d.ts.map +0 -1
  163. package/dist/types.js +0 -38
  164. package/dist/types.js.map +0 -1
  165. package/dist/watch.d.ts +0 -21
  166. package/dist/watch.d.ts.map +0 -1
  167. package/dist/watch.js +0 -219
  168. package/dist/watch.js.map +0 -1
  169. package/dist/web/data.d.ts +0 -11
  170. package/dist/web/data.d.ts.map +0 -1
  171. package/dist/web/data.js +0 -56
  172. package/dist/web/data.js.map +0 -1
  173. package/dist/web/html.d.ts +0 -5
  174. package/dist/web/html.d.ts.map +0 -1
  175. package/dist/web/html.js +0 -16
  176. package/dist/web/html.js.map +0 -1
  177. package/dist/web/index.d.ts +0 -25
  178. package/dist/web/index.d.ts.map +0 -1
  179. package/dist/web/index.js +0 -335
  180. package/dist/web/index.js.map +0 -1
  181. package/dist/web/render.d.ts +0 -11
  182. package/dist/web/render.d.ts.map +0 -1
  183. package/dist/web/render.js +0 -56
  184. package/dist/web/render.js.map +0 -1
  185. package/dist/web/templates/logs.eta +0 -75
  186. package/dist/web/templates/memory.eta +0 -91
  187. package/dist/web/templates/page.eta +0 -98
package/dist/agent.js DELETED
@@ -1,780 +0,0 @@
1
- /**
2
- * Agent module — text-in / text-out AI processor per iMessage chat.
3
- *
4
- * Each chat gets a lazily-created AgentSession with persistent context
5
- * (context.jsonl per chat directory).
6
- *
7
- * Concurrency: callers must serialize messages for the same chat externally
8
- * (imessage.ts does this via per-chat promise chains). Different chats run
9
- * concurrently.
10
- *
11
- * Model: uses ~/.pi/agent/ defaults (via createAgentSession).
12
- */
13
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
- import { join } from "node:path";
15
- import { Type } from "@earendil-works/pi-ai";
16
- import { DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, createAgentSession, defineTool, getAgentDir, } from "@earendil-works/pi-coding-agent";
17
- import { listMemoryNamespaces, loadMemoryNamespaces, readCoreMemory, saveMemory, searchMemory } from "./memory.js";
18
- // ── Config & Types ────────────────────────────────────────────────────────────
19
- /**
20
- * Abort a prompt only after this much continuous inactivity. Any agent event —
21
- * including streamed model output and tool start/end events — refreshes it.
22
- */
23
- export const AGENT_IDLE_TIMEOUT_MS = Number.parseInt(process.env.AGENT_IDLE_TIMEOUT_MS || "120000", 10);
24
- /** Absolute safety ceiling that activity cannot extend. */
25
- export const AGENT_MAX_PROMPT_DURATION_MS = Number.parseInt(process.env.AGENT_MAX_PROMPT_DURATION_MS || "1800000", 10);
26
- export const AGENT_COMPACT_TIMEOUT_MS = Number.parseInt(process.env.AGENT_COMPACT_TIMEOUT_MS || "60000", 10);
27
- export const AUTO_COMPACT_CONTEXT_RATIO = Number.parseFloat(process.env.AGENT_AUTO_COMPACT_RATIO || "0.7");
28
- export const AUTO_COMPACT_FALLBACK_TOKENS = 100_000;
29
- /**
30
- * Compact relative to the selected model's context window instead of using a
31
- * fixed low threshold. AGENT_AUTO_COMPACT_TOKENS remains an explicit override.
32
- */
33
- export function getAutoCompactTokenThreshold(contextWindow, explicitTokens = process.env.AGENT_AUTO_COMPACT_TOKENS || "") {
34
- const explicit = Number.parseInt(explicitTokens, 10);
35
- if (Number.isFinite(explicit) && explicit > 0)
36
- return explicit;
37
- if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) {
38
- return AUTO_COMPACT_FALLBACK_TOKENS;
39
- }
40
- const ratio = Number.isFinite(AUTO_COMPACT_CONTEXT_RATIO) && AUTO_COMPACT_CONTEXT_RATIO > 0 && AUTO_COMPACT_CONTEXT_RATIO < 1
41
- ? AUTO_COMPACT_CONTEXT_RATIO
42
- : 0.7;
43
- return Math.floor(contextWindow * ratio);
44
- }
45
- const FAST_OPENAI_CODEX_MODELS = /^gpt-5\.6-(?:sol|terra|luna)$/;
46
- /** Enable OpenAI priority processing without enabling user-discovered extensions. */
47
- export function openAiCodexFastExtension(pi) {
48
- pi.on("before_provider_request", (event, ctx) => {
49
- if (ctx.model?.provider !== "openai-codex" || !FAST_OPENAI_CODEX_MODELS.test(ctx.model.id)) {
50
- return;
51
- }
52
- if (typeof event.payload !== "object" || event.payload === null || Array.isArray(event.payload)) {
53
- return;
54
- }
55
- return { ...event.payload, service_tier: "priority" };
56
- });
57
- }
58
- /**
59
- * Stop model work without injecting a synthetic user message into the transcript.
60
- *
61
- * AgentSession.steer("stop") is not an abort API: it queues a literal user
62
- * message. If the current operation fails before consuming that queue (for
63
- * example, compaction fails), the stale "stop" can be delivered with a later
64
- * real chat message and misattributed to that sender.
65
- */
66
- export async function clearAndAbortSession(session) {
67
- session.clearQueue();
68
- await session.abort();
69
- }
70
- // ── Helpers ────────────────────────────────────────────────────────────────────
71
- /**
72
- * Race an async operation against a wall-clock timeout.
73
- *
74
- * If `operation()` settles first, its result (or rejection) is passed through
75
- * and the timer is cleared. If the timeout fires first, `onTimeout()` is invoked
76
- * (best-effort — e.g. to abort the underlying request) and the returned promise
77
- * rejects with a timeout Error. A failure inside `onTimeout()` never masks the
78
- * timeout rejection.
79
- *
80
- * Note: this does not cancel `operation()` itself — cancellation must happen via
81
- * `onTimeout` (the SDK exposes `AgentSession.abort()`). The timeout rejects
82
- * immediately without waiting for cancellation, because a stuck abort must not
83
- * keep the caller blocked.
84
- */
85
- export async function runWithTimeout(operation, onTimeout, timeoutMs) {
86
- let timer;
87
- const timeout = new Promise((_resolve, reject) => {
88
- timer = setTimeout(() => {
89
- try {
90
- void Promise.resolve(onTimeout()).catch(() => { });
91
- }
92
- catch {
93
- // A cleanup failure must never suppress the timeout.
94
- }
95
- reject(new Error(`operation timed out after ${timeoutMs}ms`));
96
- }, timeoutMs);
97
- });
98
- try {
99
- return await Promise.race([operation(), timeout]);
100
- }
101
- finally {
102
- if (timer)
103
- clearTimeout(timer);
104
- }
105
- }
106
- /**
107
- * Race an operation against a sliding inactivity timeout and a separate hard
108
- * duration ceiling. Calling `markActivity()` refreshes only the idle timer.
109
- */
110
- export async function runWithActivityTimeout(operation, onTimeout, idleTimeoutMs, maxDurationMs) {
111
- let idleTimer;
112
- let maxTimer;
113
- let settled = false;
114
- let rejectTimeout = () => { };
115
- const clearTimers = () => {
116
- if (idleTimer)
117
- clearTimeout(idleTimer);
118
- if (maxTimer)
119
- clearTimeout(maxTimer);
120
- };
121
- const fireTimeout = (kind, timeoutMs) => {
122
- if (settled)
123
- return;
124
- settled = true;
125
- clearTimers();
126
- try {
127
- void Promise.resolve(onTimeout(kind)).catch(() => { });
128
- }
129
- catch {
130
- // A cleanup failure must never suppress the timeout.
131
- }
132
- const message = kind === "idle"
133
- ? `operation idle timed out after ${timeoutMs}ms`
134
- : `operation exceeded maximum duration of ${timeoutMs}ms`;
135
- rejectTimeout(new Error(message));
136
- };
137
- const markActivity = () => {
138
- if (settled)
139
- return;
140
- if (idleTimer)
141
- clearTimeout(idleTimer);
142
- idleTimer = setTimeout(() => fireTimeout("idle", idleTimeoutMs), idleTimeoutMs);
143
- };
144
- const timeout = new Promise((_resolve, reject) => {
145
- rejectTimeout = reject;
146
- markActivity();
147
- if (maxDurationMs > 0) {
148
- maxTimer = setTimeout(() => fireTimeout("max_duration", maxDurationMs), maxDurationMs);
149
- }
150
- });
151
- const operationPromise = Promise.resolve().then(() => operation(markActivity));
152
- try {
153
- return await Promise.race([operationPromise, timeout]);
154
- }
155
- finally {
156
- settled = true;
157
- clearTimers();
158
- }
159
- }
160
- /**
161
- * Format a prompt with sender/chat context prefix.
162
- *
163
- * Examples:
164
- * [DM from +1234567890] hey
165
- * [SMS from +1234567890] hey
166
- * [Group 'Family' from alice@example.com] hey
167
- */
168
- function formatPromptText(msg) {
169
- const text = msg.text ?? "";
170
- let prefix;
171
- if (msg.messageType === "group") {
172
- const name = msg.groupName || "unnamed";
173
- prefix = `[Group '${name}' from ${msg.sender}]`;
174
- }
175
- else if (msg.messageType === "sms") {
176
- prefix = `[SMS from ${msg.sender}]`;
177
- }
178
- else {
179
- prefix = `[DM from ${msg.sender}]`;
180
- }
181
- if (msg.replyToText) {
182
- return `${prefix} [replying to: "${msg.replyToText}"] ${text}`;
183
- }
184
- return `${prefix} ${text}`;
185
- }
186
- /** Replace characters that are invalid in directory names. */
187
- function sanitizeChatGuid(chatGuid) {
188
- return chatGuid.replace(/[^a-zA-Z0-9_\-;+.@]/g, "_");
189
- }
190
- /** Read a file's trimmed content, or return undefined if missing/empty. */
191
- function readFileIfExists(path) {
192
- if (!existsSync(path))
193
- return undefined;
194
- try {
195
- const content = readFileSync(path, "utf-8").trim();
196
- return content || undefined;
197
- }
198
- catch (error) {
199
- console.warn(`[agent] failed to read ${path}: ${error}`);
200
- return undefined;
201
- }
202
- }
203
- function getCustomPrompt(workingDir, chatDir) {
204
- const parts = [];
205
- const global = readFileIfExists(join(workingDir, "SYSTEM.md"));
206
- if (global)
207
- parts.push(global);
208
- if (chatDir) {
209
- const chat = readFileIfExists(join(chatDir, "SYSTEM.md"));
210
- if (chat)
211
- parts.push(chat);
212
- }
213
- return parts.join("\n\n");
214
- }
215
- function buildSystemPrompt(workingDir, chatDir) {
216
- const coreMemory = readCoreMemory(workingDir);
217
- const namespaces = listMemoryNamespaces(workingDir)
218
- .map((item) => `${item.namespace} (${item.active} active)`)
219
- .join(", ");
220
- const customPrompt = getCustomPrompt(workingDir, chatDir);
221
- return `You are the user's best friend communicating via iMessage. Be concise. No emojis.
222
-
223
- ## Context
224
- - Plain text only. Do not use Markdown formatting, double asterisks (**like this**), or [markdown](links).
225
- - Reply in the same language the user is writing in.
226
-
227
- ## Environment
228
- You are running directly on the host machine.
229
- - Bash working directory: ${workingDir}
230
- - Be careful with system modifications;
231
-
232
- ## Workspace Layout
233
- ${workingDir}/
234
- ├── settings.json # Bot configuration (see below)
235
- ├── MEMORY.md # Legacy memory archive; do not write new entries
236
- ├── SYSTEM.md # System configuration log
237
- ├── skills/file-memory/ # Structured memory store and CLI
238
- └── <chatId>/ # Each iMessage chat gets a directory
239
- ├── MEMORY.md # Legacy chat memory archive
240
- ├── context.jsonl # LLM context (session persistence)
241
- ├── log.jsonl # Message history
242
- ├── attachments/ # User-shared files
243
- ├── scratch/ # Your working directory
244
- └── skills/ # Chat-specific tools
245
-
246
- ## Memory
247
- Structured memory is the only active read/write memory. Legacy MEMORY.md files are read-only archives.
248
-
249
- Read:
250
- - Use the typed load_memory tool when prior context may help. You choose one or more relevant namespaces from meaning and conversation context, not fixed keywords.
251
- - load_memory returns every active record in each selected namespace. A namespace is injected only once per session; later calls return only newly added records.
252
- - Available namespaces: ${namespaces || "(none yet)"}
253
- - Use search_memory only to find a specific old record, especially before a correction. Do not use it as automatic per-record retrieval.
254
- - Treat retrieved memory as context, not as a substitute for the current message or verified facts.
255
-
256
- Write:
257
- - When you learn something important and durable, call save_memory with concise atomic text, namespace, kind, subjects, factual event date when known, and a specific source.
258
- - Do not store every message, guesses, short-lived states, secrets, or duplicate facts.
259
- - For corrections, find the old ID and set supersedes_id. Never silently overwrite history.
260
- - Do not edit JSONL or legacy MEMORY.md directly.
261
-
262
- ### Core Memory
263
- ${coreMemory}
264
-
265
- ## System Configuration Log
266
- Maintain ${workingDir}/SYSTEM.md to log all environment modifications:
267
- - Installed packages (npm install, pip install, brew install, etc.)
268
- - Environment variables set
269
- - Config files modified (~/.gitconfig, cron jobs, etc.)
270
- - Skill dependencies installed
271
-
272
- Update this file whenever you modify the environment.
273
-
274
- ## Messaging and Reminder API
275
- A local HTTP server runs at http://localhost:7750 with endpoints for sending messages and scheduling reminders:
276
-
277
- ### POST /send — send a message or local file attachment directly
278
- \`\`\`bash
279
- curl -X POST http://localhost:7750/send \\
280
- -H "Content-Type: application/json" \\
281
- -d '{"chatGuid":"<chatGuid>","text":"hello"}'
282
-
283
- curl -X POST http://localhost:7750/send \\
284
- -H "Content-Type: application/json" \\
285
- -d '{"chatGuid":"<chatGuid>","filePath":"/path/to/image.png"}'
286
- \`\`\`
287
-
288
- ### POST /prompt — send a prompt to the agent, send the agent's reply to the chat
289
- The prompt is processed by the agent (you). After processing completes, only the final
290
- assistant text replies are sent to the chat. Tool execution output is not sent.
291
- \`\`\`bash
292
- curl -X POST http://localhost:7750/prompt \\
293
- -H "Content-Type: application/json" \\
294
- -d '{"chatGuid":"<chatGuid>","prompt":"generate a daily summary"}'
295
- \`\`\`
296
- If the agent is already processing a message for this chat, the prompt is queued (followUp)
297
- and will run after the current processing finishes.
298
-
299
- ### POST /reminders — schedule a persistent one-time reminder
300
- Use this instead of creating a one-off script or crontab entry. \`scheduledAt\` must be
301
- ISO 8601 with an explicit timezone. The reminder survives service restarts, catches up
302
- after downtime, retries transient send failures, and supports an optional idempotency key.
303
- \`\`\`bash
304
- curl -X POST http://localhost:7750/reminders \\
305
- -H "Content-Type: application/json" \\
306
- -d '{"chatGuid":"<chatGuid>","text":"check the oven","scheduledAt":"2026-08-08T21:30:00+08:00","idempotencyKey":"check-oven-2026-08-08"}'
307
-
308
- curl 'http://localhost:7750/reminders?status=pending'
309
- curl -X DELETE http://localhost:7750/reminders/<reminderId>
310
- \`\`\`
311
-
312
- Use system crontab (\`crontab -e\`) only for recurring messages or tasks.
313
- Example recurring crontab entries:
314
- \`\`\`
315
- # Send a static message every morning at 9:00
316
- 0 9 * * * curl -s -X POST http://localhost:7750/send -H "Content-Type: application/json" -d '{"chatGuid":"iMessage;-;+1234567890","text":"good morning"}'
317
- # Generate and send a daily summary every evening at 21:00
318
- 0 21 * * * curl -s -X POST http://localhost:7750/prompt -H "Content-Type: application/json" -d '{"chatGuid":"iMessage;-;+1234567890","prompt":"generate a daily summary and send it"}'
319
- \`\`\`
320
-
321
- ## Skills (Custom CLI Tools)
322
- You can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).
323
-
324
- ### Creating Skills
325
- Store in \`${workingDir}/skills/<name>/\` (global) or \`<chatDir>/skills/<name>/\` (chat-specific).
326
- Each skill directory needs a \`SKILL.md\` with YAML frontmatter:
327
-
328
- \`\`\`markdown
329
- ---
330
- name: skill-name
331
- description: What this skill does
332
- ---
333
- Usage instructions and details here.
334
- \`\`\`${customPrompt ? `\n\n${customPrompt}` : ""}`;
335
- }
336
- /** Extract concatenated text from a Message, ignoring non-text content parts. */
337
- function extractMessageText(message) {
338
- if (typeof message.content === "string")
339
- return message.content;
340
- const texts = message.content
341
- .filter((part) => part.type === "text" && "text" in part)
342
- .map((part) => part.text);
343
- const joined = texts.join("\n").trim();
344
- return joined || null;
345
- }
346
- /** Extract human-readable text from a tool result (string or content-array). */
347
- function extractToolResultText(result) {
348
- if (typeof result === "string")
349
- return result;
350
- if (result && typeof result === "object" && "content" in result) {
351
- const content = result.content;
352
- if (Array.isArray(content)) {
353
- const parts = [];
354
- for (const part of content) {
355
- if (part && typeof part === "object" && part.type === "text" && "text" in part) {
356
- parts.push(part.text);
357
- }
358
- }
359
- const joined = parts.join("\n").trim();
360
- if (joined)
361
- return joined;
362
- }
363
- }
364
- return JSON.stringify(result);
365
- }
366
- /** Pick a human-readable label for a tool invocation. */
367
- function extractToolLabel(toolName, args) {
368
- if (toolName === "bash" && typeof args.command === "string")
369
- return args.command;
370
- if (toolName === "load_memory" && Array.isArray(args.namespaces))
371
- return `memory: ${args.namespaces.join(", ")}`;
372
- if (typeof args.path === "string")
373
- return `${toolName}: ${args.path}`;
374
- if (typeof args.label === "string")
375
- return args.label;
376
- return toolName;
377
- }
378
- function createMemoryExtension(workingDir, loadedIds) {
379
- return (pi) => {
380
- pi.registerTool(defineTool({
381
- name: "load_memory",
382
- label: "Load Memory",
383
- description: "Load all active records from one or more semantically relevant namespaces. Select namespaces yourself from the conversation. Repeated calls return only records not already loaded in this session.",
384
- parameters: Type.Object({
385
- namespaces: Type.Array(Type.String(), { minItems: 1 }),
386
- }),
387
- async execute(_toolCallId, params) {
388
- const records = loadMemoryNamespaces(workingDir, params.namespaces);
389
- const delta = records.filter((item) => !loadedIds.has(item.id));
390
- for (const item of delta)
391
- loadedIds.add(item.id);
392
- return {
393
- content: [
394
- {
395
- type: "text",
396
- text: JSON.stringify({
397
- namespaces: params.namespaces,
398
- records: delta,
399
- already_loaded: records.length - delta.length,
400
- }),
401
- },
402
- ],
403
- details: { namespaces: params.namespaces, loaded: delta.length },
404
- };
405
- },
406
- }));
407
- pi.registerTool(defineTool({
408
- name: "search_memory",
409
- label: "Search Memory",
410
- description: "Find a specific active memory record, primarily to locate its ID before saving a correction.",
411
- parameters: Type.Object({
412
- query: Type.String(),
413
- namespaces: Type.Optional(Type.Array(Type.String())),
414
- limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50 })),
415
- }),
416
- async execute(_toolCallId, params) {
417
- const records = searchMemory(workingDir, params.query, {
418
- namespaces: params.namespaces,
419
- limit: params.limit,
420
- });
421
- return {
422
- content: [{ type: "text", text: JSON.stringify({ records }) }],
423
- details: { matches: records.length },
424
- };
425
- },
426
- }));
427
- pi.registerTool(defineTool({
428
- name: "save_memory",
429
- label: "Save Memory",
430
- description: "Validate and append one durable atomic record to the structured memory source of truth.",
431
- parameters: Type.Object({
432
- text: Type.String(),
433
- namespace: Type.String(),
434
- kind: Type.Union([
435
- Type.Literal("fact"),
436
- Type.Literal("event"),
437
- Type.Literal("preference"),
438
- Type.Literal("procedure"),
439
- ]),
440
- subjects: Type.Array(Type.String()),
441
- event_time: Type.Union([Type.String(), Type.Null()]),
442
- source: Type.String(),
443
- importance: Type.Number({ minimum: 0, maximum: 1 }),
444
- confidence: Type.Number({ minimum: 0, maximum: 1 }),
445
- supersedes_id: Type.Optional(Type.String()),
446
- }),
447
- async execute(_toolCallId, params) {
448
- const result = await saveMemory(workingDir, params);
449
- loadedIds.add(result.item.id);
450
- return {
451
- content: [{ type: "text", text: JSON.stringify(result) }],
452
- details: { added: result.added, id: result.item.id },
453
- };
454
- },
455
- }));
456
- };
457
- }
458
- // ── Agent Manager ─────────────────────────────────────────────────────────────
459
- export async function createAgentManager(config) {
460
- const { workingDir } = config;
461
- const sessionMap = new Map();
462
- let activePrompts = 0;
463
- let lastAgentActivityAt = null;
464
- const agentDir = getAgentDir();
465
- const modelRuntime = await ModelRuntime.create({
466
- authPath: join(agentDir, "auth.json"),
467
- modelsPath: join(agentDir, "models.json"),
468
- });
469
- /** Create a new AgentSession for a chat, persisted to context.jsonl. */
470
- async function createSession(chatGuid) {
471
- const chatDir = join(workingDir, sanitizeChatGuid(chatGuid));
472
- mkdirSync(chatDir, { recursive: true });
473
- const sessionManager = SessionManager.open(join(chatDir, "context.jsonl"), chatDir);
474
- const loadedMemoryIds = new Set();
475
- // Force SSE for pi-imessage. Large Codex contexts frequently exceed the
476
- // WebSocket frame limit or leave an auto-selected socket half-open.
477
- const settingsManager = SettingsManager.create(workingDir, agentDir);
478
- settingsManager.applyOverrides({ transport: "sse" });
479
- // Per-chat resource loader so the system prompt can reference chatDir.
480
- const resourceLoader = new DefaultResourceLoader({
481
- cwd: workingDir,
482
- agentDir,
483
- settingsManager,
484
- systemPrompt: buildSystemPrompt(workingDir, chatDir),
485
- // Keep extension discovery disabled, but install this one controlled
486
- // inline hook so Codex 5.6 fast mode still applies to iMessage.
487
- extensionFactories: [
488
- { name: "openai-codex-fast", factory: openAiCodexFastExtension },
489
- { name: "structured-memory", factory: createMemoryExtension(workingDir, loadedMemoryIds) },
490
- ],
491
- noExtensions: true,
492
- noSkills: true,
493
- noPromptTemplates: true,
494
- noThemes: true,
495
- });
496
- await resourceLoader.reload();
497
- const extensionErrors = resourceLoader.getExtensions().errors;
498
- if (extensionErrors.length > 0) {
499
- throw new Error(`Failed to enable Codex fast mode: ${extensionErrors.map((item) => item.error).join("; ")}`);
500
- }
501
- const { session } = await createAgentSession({
502
- cwd: workingDir,
503
- agentDir,
504
- modelRuntime,
505
- sessionManager,
506
- settingsManager,
507
- resourceLoader,
508
- });
509
- const modelLabel = session.model ? `${session.model.provider}/${session.model.id}` : "default";
510
- console.log(`[agent] session created: ${chatGuid} model=${modelLabel} transport=sse sol_fast=priority`);
511
- const entry = { session, chatGuid, chain: Promise.resolve() };
512
- sessionMap.set(chatGuid, entry);
513
- return entry;
514
- }
515
- /**
516
- * Send a user message through the agent and deliver replies via the handler.
517
- *
518
- * Calls are serialized per chat via a promise chain on the session entry,
519
- * so concurrent callers (queue + web) safely queue instead of colliding.
520
- */
521
- async function processMessage(msg, handler, options) {
522
- const entry = sessionMap.get(msg.chatGuid) ?? (await createSession(msg.chatGuid));
523
- const queuedAt = Date.now();
524
- const run = () => runPrompt(entry, msg, handler, queuedAt, options);
525
- entry.chain = entry.chain.then(run, run);
526
- return entry.chain;
527
- }
528
- async function runPrompt(entry, msg, handler, queuedAt, options) {
529
- const { session, chatGuid } = entry;
530
- const promptText = formatPromptText(msg);
531
- const images = msg.images.length > 0 ? msg.images : undefined;
532
- const modelLabel = session.model ? `${session.model.provider}/${session.model.id}` : "default";
533
- const promptStart = Date.now();
534
- const queueWaitMs = promptStart - queuedAt;
535
- console.log(`[agent] prompt start: ${chatGuid} model=${modelLabel} queue_ms=${queueWaitMs} ` +
536
- `chars=${promptText.length} images=${msg.images.length} "${promptText.substring(0, 60)}"`);
537
- activePrompts += 1;
538
- lastAgentActivityAt = Date.now();
539
- // Subscribe for this prompt's lifetime, routing events through handler
540
- let replyChain = Promise.resolve();
541
- let firstAssistantStartMs = null;
542
- let assistantDurationMs = null;
543
- const pendingTools = new Map();
544
- let markActivity = () => { };
545
- const queueReply = (reply) => {
546
- replyChain = replyChain
547
- .then(() => handler(reply))
548
- .catch((error) => {
549
- console.error(`[agent] reply handler error: ${chatGuid}`, error);
550
- });
551
- };
552
- const unsubscribe = session.subscribe((event) => {
553
- // Every session event proves the run is alive. This includes streamed
554
- // message updates as well as tool lifecycle events.
555
- lastAgentActivityAt = Date.now();
556
- markActivity();
557
- if (event.type === "message_start" && event.message.role === "assistant") {
558
- firstAssistantStartMs ??= Date.now() - promptStart;
559
- console.log(`[agent] message start: ${chatGuid} role=assistant first_token_ms=${Date.now() - promptStart}`);
560
- }
561
- else if (event.type === "message_end" && event.message.role === "assistant") {
562
- const assistantMsg = event.message;
563
- const text = extractMessageText(event.message);
564
- assistantDurationMs = firstAssistantStartMs === null ? null : Date.now() - promptStart - firstAssistantStartMs;
565
- console.log(`[agent] message end: ${chatGuid} stopReason=${assistantMsg.stopReason}` +
566
- `${assistantMsg.errorMessage ? ` error="${assistantMsg.errorMessage}"` : ""}` +
567
- ` first_token_ms=${firstAssistantStartMs ?? "n/a"} generation_ms=${assistantDurationMs ?? "n/a"}` +
568
- ` chars=${text?.length ?? 0} text="${(text ?? "(empty)").substring(0, 60)}"`);
569
- if (text) {
570
- queueReply({ kind: "assistant", text });
571
- }
572
- }
573
- else if (event.type === "tool_execution_start") {
574
- const toolArgs = event.args;
575
- const label = extractToolLabel(event.toolName, toolArgs);
576
- const hidden = ["load_memory", "search_memory", "save_memory"].includes(event.toolName) ||
577
- (event.toolName === "bash" && label.includes("skills/file-memory/memory_cli.py"));
578
- pendingTools.set(event.toolCallId, { toolName: event.toolName, startTime: Date.now(), hidden });
579
- console.log(`[agent] tool start: ${chatGuid} → ${label}`);
580
- if (!hidden)
581
- queueReply({ kind: "tool_start", label });
582
- }
583
- else if (event.type === "tool_execution_end") {
584
- const resultText = extractToolResultText(event.result);
585
- const pending = pendingTools.get(event.toolCallId);
586
- pendingTools.delete(event.toolCallId);
587
- const duration = ((pending ? Date.now() - pending.startTime : 0) / 1000).toFixed(1);
588
- const symbol = event.isError ? "✗" : "✓";
589
- console.log(`[agent] tool end: ${chatGuid} ${symbol} ${event.toolName} (${duration}s)` +
590
- ` result="${resultText.substring(0, 60)}"`);
591
- if (!pending?.hidden) {
592
- queueReply({ kind: "tool_end", toolName: event.toolName, symbol, duration, result: resultText });
593
- }
594
- }
595
- });
596
- try {
597
- await runWithActivityTimeout(async (activity) => {
598
- markActivity = activity;
599
- activity();
600
- await session.prompt(promptText, { images, streamingBehavior: options?.streamingBehavior });
601
- }, (kind) => {
602
- const timeoutMs = kind === "idle" ? AGENT_IDLE_TIMEOUT_MS : AGENT_MAX_PROMPT_DURATION_MS;
603
- const label = kind === "idle" ? "idle timeout" : "maximum duration exceeded";
604
- console.error(`[agent] prompt ${label}: ${chatGuid} after ${timeoutMs}ms — detaching session and aborting in background`);
605
- if (sessionMap.get(chatGuid) === entry)
606
- sessionMap.delete(chatGuid);
607
- void clearAndAbortSession(session).catch((error) => {
608
- console.error(`[agent] background abort failed: ${chatGuid}`, error);
609
- });
610
- }, AGENT_IDLE_TIMEOUT_MS, AGENT_MAX_PROMPT_DURATION_MS);
611
- const sessionEnd = Date.now();
612
- await replyChain;
613
- const replyEnd = Date.now();
614
- console.log(`[agent] prompt end: ${chatGuid} total_ms=${replyEnd - promptStart} session_ms=${sessionEnd - promptStart} ` +
615
- `handler_ms=${replyEnd - sessionEnd} first_token_ms=${firstAssistantStartMs ?? "n/a"} ` +
616
- `generation_ms=${assistantDurationMs ?? "n/a"}`);
617
- // Never make the current user wait for preventive compaction. Reply
618
- // first, mark the prompt complete, then compact before the next queued
619
- // prompt only when the model-relative threshold has been crossed.
620
- const contextUsage = session.getContextUsage();
621
- const threshold = getAutoCompactTokenThreshold(session.model?.contextWindow);
622
- if (typeof contextUsage?.tokens === "number" && contextUsage.tokens >= threshold) {
623
- console.log(`[agent] post-reply compact start: ${chatGuid} tokens=${contextUsage.tokens} threshold=${threshold} ` +
624
- `context_window=${session.model?.contextWindow ?? "unknown"}`);
625
- try {
626
- const result = await runWithTimeout(() => session.compact(), () => {
627
- console.error(`[agent] post-reply compact timeout: ${chatGuid} after ${AGENT_COMPACT_TIMEOUT_MS}ms`);
628
- if (sessionMap.get(chatGuid) === entry)
629
- sessionMap.delete(chatGuid);
630
- void clearAndAbortSession(session).catch(() => { });
631
- }, AGENT_COMPACT_TIMEOUT_MS);
632
- console.log(`[agent] post-reply compact end: ${chatGuid} tokens_before=${result.tokensBefore}`);
633
- }
634
- catch (error) {
635
- const message = error instanceof Error ? error.message : String(error);
636
- console.log(`[agent] post-reply compact skipped: ${chatGuid} ${message}`);
637
- }
638
- }
639
- }
640
- catch (error) {
641
- // Never inject a steering message here. steer("stop") queues literal
642
- // user text, which can survive a failed compaction and contaminate the
643
- // next prompt. Abort and clear pending queues instead.
644
- void clearAndAbortSession(session).catch(() => { });
645
- throw error;
646
- }
647
- finally {
648
- unsubscribe();
649
- activePrompts = Math.max(0, activePrompts - 1);
650
- }
651
- }
652
- /** Abort the in-progress agent run for a chat. No-op if no session or not running. */
653
- async function stop(chatGuid) {
654
- const entry = sessionMap.get(chatGuid);
655
- if (!entry) {
656
- console.log(`[agent] stop: no active session for ${chatGuid}`);
657
- return;
658
- }
659
- await clearAndAbortSession(entry.session);
660
- console.log(`[agent] stop aborted: ${chatGuid}`);
661
- }
662
- /** Compact the session context, reducing token usage while preserving a summary. */
663
- async function compact(chatGuid, customInstructions) {
664
- const entry = sessionMap.get(chatGuid) ?? (await createSession(chatGuid));
665
- const { session } = entry;
666
- let result;
667
- try {
668
- result = await session.compact(customInstructions);
669
- }
670
- catch (error) {
671
- const message = error instanceof Error ? error.message : String(error);
672
- if (message.includes("Already compacted")) {
673
- console.log(`[agent] compact skipped (already compacted): ${chatGuid}`);
674
- return "Already compacted — nothing to do";
675
- }
676
- throw error;
677
- }
678
- const beforeTokens = formatTokenCount(result.tokensBefore);
679
- const summary = `\u2713 Compacted ${beforeTokens} tokens`;
680
- console.log(`[agent] compact: ${chatGuid} ${summary}`);
681
- return summary;
682
- }
683
- /** Start a new session for a chat: evict in-memory session, delete context, recreate fresh. */
684
- async function newSession(chatGuid) {
685
- sessionMap.delete(chatGuid);
686
- const chatDir = join(workingDir, sanitizeChatGuid(chatGuid));
687
- const contextFile = join(chatDir, "context.jsonl");
688
- if (existsSync(contextFile)) {
689
- unlinkSync(contextFile);
690
- }
691
- await createSession(chatGuid);
692
- console.log(`[agent] new session: ${chatGuid}`);
693
- }
694
- /**
695
- * Get a formatted status string for an active chat session.
696
- *
697
- * Format (two lines):
698
- * 💬 3 msgs - ↑7.2k ↓505 1.1%/128k
699
- * 🤖 github-copilot/gpt-5-mini • 💭 minimal
700
- */
701
- async function getSessionStatus(chatGuid) {
702
- const entry = sessionMap.get(chatGuid);
703
- if (!entry)
704
- return "no active session";
705
- const { session } = entry;
706
- const stats = session.getSessionStats();
707
- const contextUsage = session.getContextUsage();
708
- const model = session.model;
709
- const thinkingLevel = session.thinkingLevel;
710
- // Line 1: message count + token counts + context usage
711
- const line1Parts = [];
712
- line1Parts.push(`💬 ${stats.userMessages} msgs`);
713
- line1Parts.push(`↑${formatTokenCount(stats.tokens.input)}`);
714
- line1Parts.push(`↓${formatTokenCount(stats.tokens.output)}`);
715
- if (contextUsage) {
716
- const percent = contextUsage.percent !== null ? `${contextUsage.percent.toFixed(1)}%` : "?%";
717
- const window = formatTokenCount(contextUsage.contextWindow);
718
- line1Parts.push(`${percent}/${window}`);
719
- }
720
- const line1 = `${line1Parts[0]} - ${line1Parts.slice(1).join(" ")}`;
721
- // Line 2: provider/model • thinking level
722
- const modelLabel = model ? `${model.provider}/${model.id}` : "default";
723
- const line2 = `🤖 ${modelLabel} • 💭 ${thinkingLevel ?? "off"}`;
724
- return `${line1}\n${line2}`;
725
- }
726
- /**
727
- * Switch the requesting session to the current default model from settings.
728
- *
729
- * Mirrors pi TUI's /model command flow:
730
- * 1. modelRuntime.refresh() — reload models from disk
731
- * 2. Resolve model from settings (TUI uses manual selection instead)
732
- * 3. session.setModel() — updates agent + context.jsonl + settings.json
733
- *
734
- * Reference: pi-coding-agent/dist/modes/interactive/interactive-mode.js
735
- * handleModelCommand() → getModelCandidates() → session.setModel()
736
- */
737
- async function reload(chatGuid) {
738
- await modelRuntime.refresh();
739
- const settings = SettingsManager.create(workingDir, agentDir);
740
- const provider = settings.getDefaultProvider();
741
- const modelId = settings.getDefaultModel();
742
- const newModel = provider && modelId ? modelRuntime.getModel(provider, modelId) : undefined;
743
- if (!newModel) {
744
- console.log("[agent] reload: no default model in settings");
745
- return;
746
- }
747
- const entry = sessionMap.get(chatGuid);
748
- if (!entry) {
749
- console.log(`[agent] reload: no active session for ${chatGuid}`);
750
- return;
751
- }
752
- const thinkingLevel = settings.getDefaultThinkingLevel();
753
- await entry.session.setModel(newModel);
754
- if (thinkingLevel) {
755
- entry.session.setThinkingLevel(thinkingLevel);
756
- }
757
- console.log(`[agent] reloaded: ${chatGuid} switched to ${provider}/${modelId} thinkingLevel=${thinkingLevel ?? "unchanged"}`);
758
- }
759
- function getRuntimeStatus() {
760
- return {
761
- activePrompts,
762
- sessions: sessionMap.size,
763
- lastAgentActivityAt: lastAgentActivityAt === null ? null : new Date(lastAgentActivityAt).toISOString(),
764
- };
765
- }
766
- return { processMessage, newSession, getSessionStatus, getRuntimeStatus, reload, stop, compact };
767
- }
768
- /** Format a token count as a compact string: 0, 1.2k, 5.9k, 12k, 1.8M, etc. */
769
- function formatTokenCount(tokens) {
770
- if (tokens === 0)
771
- return "0";
772
- if (tokens < 1_000)
773
- return String(tokens);
774
- if (tokens < 10_000)
775
- return `${(tokens / 1_000).toFixed(1)}k`;
776
- if (tokens < 1_000_000)
777
- return `${Math.round(tokens / 1_000)}k`;
778
- return `${(tokens / 1_000_000).toFixed(1)}M`;
779
- }
780
- //# sourceMappingURL=agent.js.map