@kingcrab/pi-imessage 0.0.43 → 1.1.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 (191) hide show
  1. package/README.md +93 -127
  2. package/dist/agent/behavior.js +79 -0
  3. package/dist/agent/chats.js +88 -0
  4. package/dist/agent/commands.js +191 -0
  5. package/dist/agent/compaction.js +51 -0
  6. package/dist/agent/deliver.js +139 -0
  7. package/dist/agent/direct-send.js +69 -0
  8. package/dist/agent/harness.js +79 -0
  9. package/dist/agent/health.js +33 -0
  10. package/dist/agent/models.js +69 -0
  11. package/dist/agent/progress-format.js +87 -0
  12. package/dist/agent/progress.js +173 -0
  13. package/dist/agent/prompt.js +79 -0
  14. package/dist/agent/replies.js +10 -0
  15. package/dist/agent/run.js +160 -0
  16. package/dist/agent/scheduling.js +291 -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/restart.js +100 -0
  26. package/dist/extensions/schedules.js +42 -0
  27. package/dist/extensions/subagent.js +54 -0
  28. package/dist/extensions/web.js +100 -0
  29. package/dist/extensions/workspace.js +98 -0
  30. package/dist/main.js +578 -88
  31. package/dist/transport/attachments.js +37 -0
  32. package/dist/transport/http.js +110 -0
  33. package/dist/{rich-text.js → transport/rich-text.js} +25 -13
  34. package/dist/{send.js → transport/send.js} +13 -13
  35. package/dist/transport/watch.js +124 -0
  36. package/dist/web/app.js +826 -0
  37. package/dist/web/history.js +87 -0
  38. package/dist/web/memory.js +81 -0
  39. package/dist/web/page.js +29 -0
  40. package/dist/web/schedules.js +63 -0
  41. package/dist/web/server.js +382 -0
  42. package/dist/web/style.css +577 -0
  43. package/dist/web/tasks.js +97 -0
  44. package/docs/adr/0001-durable-owns-state.md +11 -0
  45. package/docs/adr/0002-module-layering.md +11 -0
  46. package/docs/adr/0003-replies-at-most-once.md +11 -0
  47. package/docs/adr/0004-native-extensions.md +11 -0
  48. package/docs/adr/0005-history-migration.md +11 -0
  49. package/docs/adr/0006-jsonl-storage.md +11 -0
  50. package/docs/adr/0007-images-by-path.md +11 -0
  51. package/docs/adr/0008-compaction-and-run.md +11 -0
  52. package/docs/adr/0009-one-shot-cutover.md +11 -0
  53. package/docs/adr/0010-test-budget.md +11 -0
  54. package/docs/adr/0011-reconcile-unsent-answers.md +24 -0
  55. package/docs/adr/0012-run-in-owned-conversation.md +22 -0
  56. package/docs/adr/0013-thin-adapters-around-durable.md +18 -0
  57. package/docs/adr/0014-ephemeral-private-storage.md +13 -0
  58. package/docs/adr/0015-ordinary-stop-keeps-native-inbox.md +21 -0
  59. package/docs/adr/0016-process-http-transport.md +21 -0
  60. package/docs/adr/0017-browser-cli-owned-scope.md +22 -0
  61. package/docs/adr/0018-tasks-without-prompt-api.md +19 -0
  62. package/docs/adr/0019-remove-scheduled-services.md +41 -0
  63. package/docs/adr/0020-chat-task-progress.md +41 -0
  64. package/docs/adr/0021-readable-progress-without-link-previews.md +33 -0
  65. package/docs/adr/0022-progress-admission-and-settlement.md +35 -0
  66. package/docs/adr/0023-retain-progress-type-prefixes.md +18 -0
  67. package/docs/adr/0024-status-marker-before-type-prefix.md +13 -0
  68. package/docs/adr/0025-reusable-progress-and-command-preview.md +49 -0
  69. package/docs/adr/0026-inline-command-only-requests.md +16 -0
  70. package/docs/adr/0027-command-preview-width.md +10 -0
  71. package/docs/adr/0028-command-preview-120.md +8 -0
  72. package/docs/adr/0030-shared-web-read-cache.md +33 -0
  73. package/docs/adr/0031-quiet-compaction-progress.md +32 -0
  74. package/docs/adr/0032-native-background-schedules.md +58 -0
  75. package/docs/adr/0033-operator-scheduled-run.md +23 -0
  76. package/docs/adr/0034-machine-local-schedule-time.md +36 -0
  77. package/docs/adr/0035-external-business-task-extensions.md +56 -0
  78. package/docs/adr/0036-workspace-policy-and-neutral-service-id.md +37 -0
  79. package/docs/adr/0037-restart-during-in-flight-work.md +30 -0
  80. package/docs/adr/0038-restart-any-time.md +25 -0
  81. package/docs/adr/0039-rerun-interrupted-tools.md +24 -0
  82. package/docs/adr/0040-chat-progress-notices.md +44 -0
  83. package/docs/adr/0041-restart-and-recovery.md +28 -0
  84. package/docs/adr/0042-background-schedules-and-workspace-extensions.md +44 -0
  85. package/docs/adr/0043-remove-legacy-migration.md +23 -0
  86. package/docs/adr/0044-message-channels.md +32 -0
  87. package/docs/adr/0045-chat-scheduling-tools.md +51 -0
  88. package/docs/adr/0046-selective-command-redaction.md +31 -0
  89. package/docs/adr/0047-one-scheduling-path.md +158 -0
  90. package/docs/adr/0048-quiet-attachment-reads.md +11 -0
  91. package/docs/adr/0049-per-chat-progress-notices.md +21 -0
  92. package/docs/adr/0052-host-owned-self-restart.md +53 -0
  93. package/docs/adr/README.md +54 -0
  94. package/docs/api.md +104 -0
  95. package/docs/architecture.md +164 -0
  96. package/docs/browser.md +93 -0
  97. package/docs/memory.md +27 -0
  98. package/docs/screenshot.png +0 -0
  99. package/docs/workspace-extensions.md +133 -0
  100. package/examples/workspace-extensions/system-context/index.ts +69 -0
  101. package/examples/workspace-extensions/system-context/package.json +3 -0
  102. package/ops/README.md +250 -0
  103. package/ops/preflight.mjs +28 -0
  104. package/package.json +36 -37
  105. package/dist/agent.d.ts +0 -84
  106. package/dist/agent.d.ts.map +0 -1
  107. package/dist/agent.js +0 -780
  108. package/dist/agent.js.map +0 -1
  109. package/dist/cli.d.ts +0 -15
  110. package/dist/cli.d.ts.map +0 -1
  111. package/dist/cli.js.map +0 -1
  112. package/dist/imessage.d.ts +0 -33
  113. package/dist/imessage.d.ts.map +0 -1
  114. package/dist/imessage.js +0 -76
  115. package/dist/imessage.js.map +0 -1
  116. package/dist/logger.d.ts +0 -33
  117. package/dist/logger.d.ts.map +0 -1
  118. package/dist/logger.js +0 -104
  119. package/dist/logger.js.map +0 -1
  120. package/dist/main.d.ts +0 -5
  121. package/dist/main.d.ts.map +0 -1
  122. package/dist/main.js.map +0 -1
  123. package/dist/memory.d.ts +0 -53
  124. package/dist/memory.d.ts.map +0 -1
  125. package/dist/memory.js +0 -196
  126. package/dist/memory.js.map +0 -1
  127. package/dist/model-health.d.ts +0 -11
  128. package/dist/model-health.d.ts.map +0 -1
  129. package/dist/model-health.js +0 -69
  130. package/dist/model-health.js.map +0 -1
  131. package/dist/pipeline.d.ts +0 -27
  132. package/dist/pipeline.d.ts.map +0 -1
  133. package/dist/pipeline.js +0 -59
  134. package/dist/pipeline.js.map +0 -1
  135. package/dist/queue.d.ts +0 -23
  136. package/dist/queue.d.ts.map +0 -1
  137. package/dist/queue.js +0 -89
  138. package/dist/queue.js.map +0 -1
  139. package/dist/reminders.d.ts +0 -42
  140. package/dist/reminders.d.ts.map +0 -1
  141. package/dist/reminders.js +0 -207
  142. package/dist/reminders.js.map +0 -1
  143. package/dist/rich-text.d.ts +0 -26
  144. package/dist/rich-text.d.ts.map +0 -1
  145. package/dist/rich-text.js.map +0 -1
  146. package/dist/self-echo.d.ts +0 -19
  147. package/dist/self-echo.d.ts.map +0 -1
  148. package/dist/self-echo.js +0 -54
  149. package/dist/self-echo.js.map +0 -1
  150. package/dist/send.d.ts +0 -24
  151. package/dist/send.d.ts.map +0 -1
  152. package/dist/send.js.map +0 -1
  153. package/dist/settings.d.ts +0 -44
  154. package/dist/settings.d.ts.map +0 -1
  155. package/dist/settings.js +0 -80
  156. package/dist/settings.js.map +0 -1
  157. package/dist/store.d.ts +0 -57
  158. package/dist/store.d.ts.map +0 -1
  159. package/dist/store.js +0 -124
  160. package/dist/store.js.map +0 -1
  161. package/dist/tasks.d.ts +0 -95
  162. package/dist/tasks.d.ts.map +0 -1
  163. package/dist/tasks.js +0 -384
  164. package/dist/tasks.js.map +0 -1
  165. package/dist/types.d.ts +0 -93
  166. package/dist/types.d.ts.map +0 -1
  167. package/dist/types.js +0 -38
  168. package/dist/types.js.map +0 -1
  169. package/dist/watch.d.ts +0 -21
  170. package/dist/watch.d.ts.map +0 -1
  171. package/dist/watch.js +0 -219
  172. package/dist/watch.js.map +0 -1
  173. package/dist/web/data.d.ts +0 -11
  174. package/dist/web/data.d.ts.map +0 -1
  175. package/dist/web/data.js +0 -56
  176. package/dist/web/data.js.map +0 -1
  177. package/dist/web/html.d.ts +0 -5
  178. package/dist/web/html.d.ts.map +0 -1
  179. package/dist/web/html.js +0 -16
  180. package/dist/web/html.js.map +0 -1
  181. package/dist/web/index.d.ts +0 -25
  182. package/dist/web/index.d.ts.map +0 -1
  183. package/dist/web/index.js +0 -335
  184. package/dist/web/index.js.map +0 -1
  185. package/dist/web/render.d.ts +0 -11
  186. package/dist/web/render.d.ts.map +0 -1
  187. package/dist/web/render.js +0 -56
  188. package/dist/web/render.js.map +0 -1
  189. package/dist/web/templates/logs.eta +0 -75
  190. package/dist/web/templates/memory.eta +0 -91
  191. package/dist/web/templates/page.eta +0 -98
package/README.md CHANGED
@@ -1,89 +1,45 @@
1
- # @kingcrab/pi-imessage
1
+ # pi-imessage
2
2
 
3
- A minimal and self-managing iMessage bot — powered by [pi](https://github.com/badlogic/pi-mono).
3
+ A minimal, self-managing iMessage bot powered by [Pi Durable](https://github.com/earendil-works/pi/tree/main/packages/durable).
4
4
 
5
- <img height="420" src="https://raw.githubusercontent.com/daya0576/pi-imessage/main/docs/screenshot.png" />
5
+ <img height="420" src="docs/screenshot.png" />
6
6
 
7
- # Features
8
- - **Minimal**: No BlueBubble, no webhooks, no extra dependencies
9
- - **Self-managing**: Turn the agent into whatever you need. He builds his own tools without pre-built assumptions
10
- - **Transparent**: tool calls and reasoning are sent to your iMessage chat, so you can see exactly what it's doing and why
11
- - **iMessage Integration**: Responds to DMs, SMS, and group chats; identifies who sent each message; understands quoted/reply-to messages
12
- - **Persistent Reminders**: Schedule one-time messages without per-reminder cron jobs; reminders survive restarts and retry transient failures
13
- - **Web UI**: browse chat history, toggle replies on/off per chat, live updates — disable with WEB_ENABLED=false and let the agent build your own web UI
7
+ ## Features
14
8
 
15
- # Get Started
9
+ - **Minimal**: No BlueBubbles or webhook setup. Runs directly on your Mac alongside Messages.app.
10
+ - **Messages**: DMs, SMS, and group chats, with sender identification and reply context.
11
+ - **Self-managing**: The agent builds its own tools, with native web search, page fetch, and a [conversation-owned browser](docs/browser.md).
12
+ - **Web UI**: Chat history, native task state, scheduled jobs with their latest ten executions, logs, memory, and configuration.
13
+ - **Memory**: Searchable, traceable records; corrections preserve history. See [structured memory](docs/memory.md).
16
14
 
17
- > ⚠️ **Security note**
18
- > - Replies are **off** for all chats by default (`blacklist: ["*"]`) — only explicitly whitelisted chats get a response
19
- > - The agent runs with Full Disk Access and can read/write your filesystem as part of its tool use
20
- > - The web UI has no authentication and is accessible to anyone on your local network; set `WEB_ENABLED=false` if that's a concern
15
+ ## Quick Start
21
16
 
22
- Prerequisites: macOS with Messages.app, Full Disk Access for the terminal, [Pi Coding Agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#quick-start) authenticated
17
+ > Just point your Pi coding agent at this README and ask it to set up pi-imessage.
18
+
19
+ Prerequisites: macOS with Messages.app, Node.js 22.22.2 or newer, Python 3
20
+ (shared memory writer), Full Disk Access for the terminal, and an authenticated
21
+ [Pi Coding Agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#quick-start).
23
22
 
24
23
  ```bash
25
24
  npm install -g @kingcrab/pi-imessage
26
25
 
27
26
  pi-imessage # run in foreground
28
- pi-imessage install # install as launchd service (auto-start on boot, restart on crash)
27
+ pi-imessage install # write a launchd job; loading it is a separate operator action
29
28
  ```
30
29
 
31
- # Usage
32
-
33
- ## Web UI
34
-
35
- Available at `http://localhost:7750` (configurable via `WEB_HOST` and `WEB_PORT`).
36
-
37
- - Chat history with live updates
38
- - Logs (tail -f style)
39
- - Memory (global & per-chat)
40
-
41
- P.S. Disable with `WEB_ENABLED=false` and let the agent build your own web UI
42
-
43
- ## Structured Memory
44
-
45
- Durable memories are stored as namespaced JSONL records under
46
- `WORKING_DIR/skills/file-memory/namespaces/`. Each record includes a stable ID,
47
- creation timestamp, factual event date when known, source, kind, subjects,
48
- importance, confidence, and status. This keeps every memory traceable without
49
- inventing dates for older facts.
50
-
51
- The agent uses typed tools to load complete namespaces, search for specific
52
- records, and append validated memories. Corrections append a new record that
53
- supersedes the old ID, preserving an auditable history instead of silently
54
- rewriting it. Legacy global and per-chat `MEMORY.md` files remain read-only
55
- archives. See [the structured memory flow](docs/structured-memory-sequence.md)
56
- for migration, retrieval, and write behavior.
57
-
58
- ## API
59
-
60
- The agent is aware of these endpoints via its system prompt and can use them as tools (e.g., scheduling a cron job that calls `/prompt`).
61
-
62
- | Endpoint | Description | Example |
63
- |---|---|---|
64
- | `POST /send` | Send text and/or a local file attachment to a chat (bypasses the agent) | `curl -X POST localhost:7750/send -d '{"chatGuid": "iMessage;-;+11234567890", "text": "hello"}'`<br>→ `{"ok": true}` |
65
- | `POST /prompt` | Feed a prompt to the agent asynchronously; replies are sent to the chat when ready | `curl -X POST localhost:7750/prompt -d '{"chatGuid": "iMessage;-;+11234567890", "prompt": "say hello"}'`<br>→ `{"ok": true}` |
66
- | `POST /reminders` | Schedule a persistent one-time reminder; `scheduledAt` requires an explicit timezone | `curl -X POST localhost:7750/reminders -d '{"chatGuid":"iMessage;-;+11234567890","text":"check the oven","scheduledAt":"2026-08-08T21:30:00+08:00"}'` |
67
- | `GET /reminders` | List reminders; optionally filter with `?status=pending` | `curl 'localhost:7750/reminders?status=pending'` |
68
- | `DELETE /reminders/:id` | Cancel a pending reminder | `curl -X DELETE localhost:7750/reminders/<id>` |
69
- | `GET /health/model` | Make a live request to the configured default AI model; returns HTTP 200 when healthy or 503 on failure | `curl localhost:7750/health/model`<br>→ `{"ok":true,"model":"openai/gpt-5","latencyMs":842,"checkedAt":"..."}` |
30
+ Enable replies for your chats by adding their GUIDs to the [settings allowlist](#settings).
31
+ For installation and service operations, see [ops/README.md](ops/README.md).
70
32
 
71
33
  ## Commands
72
34
 
73
- Send these as iMessage to interact with the bot:
35
+ Send `/help` in a chat to list commands: `/new`, `/status`, `/thinking`,
36
+ `/verbose`, `/compact`, `/run`, `/stop` and `/reload`.
74
37
 
75
- | Command | Description | Example Reply |
76
- |---|---|---|
77
- | `/help` | List available slash commands | `Commands:`<br>`/help — list commands` |
78
- | `/new` | Reset the session, starting a fresh conversation | `✓ New session started` |
79
- | `/status` | Show session stats: tokens, context, model | `💬 3 msgs - ↑7.2k ↓505 1.1%/128k`<br>`🤖 anthropic/claude-sonnet-4 • 💭 minimal` |
80
- | `/compact` | Compress session context to free up token space | `✓ Compacted: 15.2k → 2.1k tokens` |
81
- | `/stop` | Steer the agent to stop after current tool calls finish, then process the next queued message | |
82
- | `/reload` | Reload models and clear all sessions | `✓ Models reloaded` |
38
+ ## Configuration
83
39
 
84
- ## Settings (`WORKING_DIR/settings.json`)
40
+ ### Settings
85
41
 
86
- All fields are optional.
42
+ Edit `WORKING_DIR/settings.json`. All fields are optional.
87
43
 
88
44
  ```json
89
45
  {
@@ -98,72 +54,82 @@ All fields are optional.
98
54
  }
99
55
  ```
100
56
 
101
- **Chat allowlist** controls which chats receive replies (messages are always logged). By default, replies are **off** for all chats (`blacklist: ["*"]`) — opt in specific chats via the web UI or by adding their guid to `whitelist`. Resolution priority: `blacklist[guid]` > `whitelist[guid]` > `blacklist["*"]` > `whitelist["*"]`.
57
+ ### Model policy
102
58
 
103
- **Rich text** is optional and disabled by default. When enabled, pi-imessage uses a UI automation fallback to open the target conversation, paste an RTF payload, and send it. Currently this is intended for direct-message iMessage chats. With `markdown: true`, pi-imessage interprets `**bold**` spans and renders them as actual bold text in Messages.
59
+ Optional `settings.json` fields, applied at startup and `/reload`:
104
60
 
105
- ## Environment Variables
61
+ ```json
62
+ {
63
+ "modelPolicy": {
64
+ "fallback": { "provider": "your-provider", "modelId": "your-model" },
65
+ "codexServiceTier": "default"
66
+ }
67
+ }
68
+ ```
106
69
 
107
- | Variable | Required | Default | Description |
108
- |---|---|---|---|
109
- | `WEB_ENABLED` | no | `true` | Set to `false` to disable the built-in web UI |
110
- | `WEB_HOST` | no | `localhost` | Web UI host |
111
- | `WEB_PORT` | no | `7750` | Web UI port |
112
- | `WORKING_DIR` | no | `~/.pi/imessage` | Workspace directory |
113
- | `AGENT_IDLE_TIMEOUT_MS` | no | `120000` | Abort only after this much continuous agent inactivity; model and tool events reset the timer |
114
- | `AGENT_MAX_PROMPT_DURATION_MS` | no | `1800000` | Absolute ceiling for one prompt, independent of activity |
70
+ Without a fallback, an unavailable default fails explicitly. `codexServiceTier`
71
+ accepts `default` or `priority`; priority may have a different cost.
115
72
 
116
- # Development
73
+ ### Environment Variables
117
74
 
118
- ```bash
119
- npm run check # typecheck + lint (run after code changes)
120
- npm test # run tests
121
- ```
75
+ All variables are optional.
122
76
 
123
- # How It Works
77
+ | Variable | Default | Description |
78
+ |---|---|---|
79
+ | `WEB_ENABLED` | `true` | Set to `false` to disable the built-in web UI |
80
+ | `WEB_HOST` | `localhost` | Web UI host |
81
+ | `WEB_PORT` | `7750` | Web UI port |
82
+ | `WORKING_DIR` | `~/.pi/imessage` | Workspace directory |
124
83
 
84
+ ### Web and API
85
+
86
+ See the [HTTP API](docs/api.md) for read-only state and model health checks.
87
+ The agent sends files and extra messages with `send_message`.
88
+
89
+ ## How It Works
90
+
91
+ ```text
92
+ Messages chat.db → Watcher → Pi Durable → Messages.app
125
93
  ```
126
- ~/Library/Messages/chat.db
127
- │
128
- (poll every 2s for new rows)
129
- │
130
- ▼
131
- ┌──────────────────────────────────────────────────┐
132
- │ pi-imessage │
133
- │ │
134
- │ Watcher (chat.db polling) │
135
- │ │ │
136
- │ ├─ Filter: is_from_me=0, no reactions │
137
- │ ├─ Deduplicate via seenRowIds │
138
- │ ├─ Read attachments from local disk │
139
- │ │ │
140
- │ ▼ │
141
- │ AsyncQueue<IncomingMessage> │
142
- │ │ │
143
- │ ▼ │
144
- │ SessionManager (pi-coding-agent) │
145
- │ │ per chatGuid, persistent on disk │
146
- │ │ └─ data/<chatGuid>/ │
147
- │ │ ├─ log.jsonl (full history) │
148
- │ │ └─ context.jsonl (LLM context) │
149
- │ │ │
150
- │ ▼ │
151
- │ Agent loop (pi-agent-core) │
152
- │ │ │
153
- │ │ ┌─ outer: follow-up messages ────┐ │
154
- │ │ │ ┌─ inner: tool calls + ┐ │ │
155
- │ │ │ │ steering messages │ │ │
156
- │ │ │ └───────────────────────────┘ │ │
157
- │ │ └────────────────────────────────┘ │
158
- │ │ │
159
- │ ▼ │
160
- │ Collect assistant reply text │
161
- │ │ │
162
- │ ├─ sendMessage (AppleScript → Messages.app) │
163
- │ └─ save logs (messages, digests) │
164
- │ │
165
- └──────────────────────────────────────────────────┘
166
- │
167
- ▼
168
- iMessage (user receives reply via Messages.app)
94
+
95
+ Each chat has its own conversation, persisted under `WORKING_DIR/durable/`.
96
+ Durable resumes pending work after a crash. Replies are sent at most once;
97
+ uncertain sends are flagged, never resent. Context is compacted silently every
98
+ six hours and near the model's limit.
99
+
100
+ ## Layout
101
+
102
+ This repository is the framework; `WORKING_DIR` is one deployment's data and code.
103
+ Startup, `/reload` and the assistant's `reload_extensions` load the extensions.
104
+ See [workspace extensions](docs/workspace-extensions.md),
105
+ [ADR 0036](docs/adr/0036-workspace-policy-and-neutral-service-id.md) and
106
+ [ADR 0042](docs/adr/0042-background-schedules-and-workspace-extensions.md).
107
+
108
+ ```text
109
+ pi-imessage/ framework, shared by every deployment
110
+ ├── src/
111
+ │ ├── main.ts, cli.ts startup, wiring, launchd install
112
+ │ ├── web/ read-only HTTP API and pages
113
+ │ ├── agent/ Durable harness, models, prompt, delivery, scheduler
114
+ │ ├── extensions/ built-in tools, workspace extension loader
115
+ │ ├── transport/ chat.db polling, Messages.app sending
116
+ │ └── config/ settings, environment, chat allowlist
117
+ ├── docs/ architecture and ADRs (the design ground truth)
118
+ ├── examples/workspace-extensions/ reference extensions to copy into a workspace
119
+ ├── ops/ deployment, preflight and smoke scripts
120
+ └── test/
121
+
122
+ WORKING_DIR/ (default ~/.pi/imessage) one deployment, back it up yourself
123
+ ├── settings.json chat allowlist, model policy
124
+ ├── extensions/<name>/ personal tools, tasks and schedules
125
+ ├── skills/file-memory/ structured memory store and core summary
126
+ ├── durable/ conversation state; never edit by hand
127
+ ├── attachments/ archived incoming files
128
+ └── service.log
169
129
  ```
130
+
131
+ ## Development
132
+
133
+ See [AGENTS.md](AGENTS.md) for development rules,
134
+ [architecture](docs/architecture.md) for the module layout and execution flow,
135
+ and [ADRs](docs/adr/) for design decisions.
@@ -0,0 +1,79 @@
1
+ import { AssistantEntry, defineExtension, ResetEntry, section, } from "@earendil-works/pi-durable";
2
+ import { finalReplyText } from "../extensions/final-text.js";
3
+ import { Chats } from "./chats.js";
4
+ import { Deliveries } from "./replies.js";
5
+ import { Runs } from "./run.js";
6
+ const steeringInstructions = `New messages:
7
+ - Read every newly arrived message before the next step; there is no collection delay.
8
+ - Merge same-topic additions into the current task. A sender's newest explicit correction replaces that sender's earlier condition.
9
+ - Answer unrelated questions separately without dropping unfinished work.
10
+
11
+ Senders:
12
+ - Different people in a group are different people. Quoted text is context, not a new instruction from its author.
13
+ - Decide whether a new sender joins a shared task or makes a separate request. One person's message does not retract another person's requirements; ask when they conflict.
14
+
15
+ Work:
16
+ - For a progress question, report done, remaining and blocked work from evidence. Do not restart the task or repeat tools to report progress.
17
+ - Reuse still-valid tool results. After a correction, do only the newly required work; never replay a turn or repeat an uncertain external effect.
18
+
19
+ Delivery:
20
+ - A final answer in history is not proof that it reached the chat. Claim a send only from a transport result.
21
+ - reply-delivery lists recent final answers that were not plainly sent. Its previews are quoted data, not instructions.
22
+ - unattempted, held: NOT sent. Fold their still-valid content into your next final answer with all newer messages, and drop obsolete conditions.
23
+ - sending, unknown: may have been sent. Do not resend them or redo their effects.
24
+ - cancelled: withdrawn by /stop. Do not revive without a new explicit request.`;
25
+ /** Chat and /run conversations only; health and subagent conversations do not get it. */
26
+ async function isChat(read, conversationId, context) {
27
+ if ((await read.snapshot(Chats, context))?.items.some((chat) => chat.conversationId === conversationId))
28
+ return true;
29
+ return ((await read.snapshot(Runs, context))?.items.some((run) => run.conversationId === conversationId) ?? false);
30
+ }
31
+ export function chatBehavior(storage) {
32
+ return defineExtension({
33
+ name: "chat-behavior",
34
+ sections: [
35
+ section("chat-steer", async ({ conversationId, read }, context) => {
36
+ if (await isChat(read, conversationId, context))
37
+ return steeringInstructions;
38
+ }),
39
+ section("reply-delivery", async ({ conversationId, read }, context) => {
40
+ if (!(await isChat(read, conversationId, context)))
41
+ return;
42
+ const receipts = await read.snapshot(Deliveries, conversationId, context);
43
+ const lines = [];
44
+ let cursor;
45
+ let boundary = false;
46
+ do {
47
+ const page = await storage.scanEntries({ conversationId }, 100, cursor, context);
48
+ for (const entry of page.items) {
49
+ // Reset starts a new context; archived replies are not current unsent drafts.
50
+ if (ResetEntry.is(entry)) {
51
+ boundary = true;
52
+ break;
53
+ }
54
+ if (!AssistantEntry.is(entry))
55
+ continue;
56
+ const text = finalReplyText(entry.model?.[0]);
57
+ if (!text)
58
+ continue;
59
+ const receipt = receipts?.answers[String(entry.id)];
60
+ const status = receipt ?? receipts?.drafts?.[String(entry.id)]?.status ?? "unattempted";
61
+ // Sent answers need no reminder.
62
+ if (status !== "sent")
63
+ lines.push(`- #${entry.id} ${status}: ${JSON.stringify(text.slice(0, 300))}`);
64
+ if (receipt) {
65
+ boundary = true;
66
+ break;
67
+ }
68
+ }
69
+ cursor = page.next;
70
+ } while (cursor && !boundary);
71
+ // Keep the section present: re-adding a removed section moves it to the end, which makes
72
+ // Durable re-send every section. A constant value also leaves the prompt unchanged.
73
+ if (lines.length === 0)
74
+ return "None. Recent final answers were sent.";
75
+ return ["Recent final answers not plainly sent, oldest first:", ...lines.reverse()].join("\n");
76
+ }),
77
+ ],
78
+ });
79
+ }
@@ -0,0 +1,88 @@
1
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { configure, defineDoc, UserEntry, } from "@earendil-works/pi-durable";
3
+ import { activeRun, Runs } from "./run.js";
4
+ export const Chats = defineDoc({
5
+ kind: "imessage.chats",
6
+ version: 1,
7
+ scope: "session",
8
+ initial: () => ({ items: [] }),
9
+ checkpointWhen: () => false,
10
+ });
11
+ export const WatchCursor = defineDoc({
12
+ kind: "imessage.watch-cursor",
13
+ version: 1,
14
+ scope: "session",
15
+ initial: () => ({}),
16
+ checkpointWhen: () => false,
17
+ });
18
+ export function progressEnabled(settings, chats, chatGuid) {
19
+ return chats.find((chat) => chat.chatGuid === chatGuid)?.progress ?? settings.progressMessages;
20
+ }
21
+ /** Stores the chat's /verbose choice; `undefined` returns the chat to the global setting. */
22
+ export async function setChatProgress(harness, chatGuid, progress) {
23
+ await harness.commit(async (tx) => {
24
+ const chats = await tx.doc(Chats);
25
+ const index = chats.items.findIndex((item) => item.chatGuid === chatGuid);
26
+ if (index < 0)
27
+ throw new Error("Chat conversation is missing");
28
+ const { conversationId } = chats.items[index];
29
+ // Replace the record so the stored delta never carries an undefined value.
30
+ chats.items.splice(index, 1, {
31
+ chatGuid,
32
+ conversationId,
33
+ ...(progress === undefined ? {} : { progress }),
34
+ });
35
+ }, BACKGROUND_CONTEXT);
36
+ }
37
+ export function formatInput(input) {
38
+ let prefix = "";
39
+ if (input.sender !== undefined) {
40
+ if (input.chatGuid.includes(";+;"))
41
+ prefix = `[Group '${input.groupName || "unnamed"}' from ${input.sender}] `;
42
+ else
43
+ prefix = `[${input.service === "SMS" ? "SMS" : "DM"} from ${input.sender}] `;
44
+ }
45
+ const reply = input.replyTo ? `[replying to: "${input.replyTo}"] ` : "";
46
+ const files = (input.attachments ?? []).map((path) => `\n[Attachment: ${path}]`).join("");
47
+ return `${prefix}${reply}${input.text}${files}`;
48
+ }
49
+ export async function chatConversation(harness, defaults, chatGuid) {
50
+ const conversationId = await harness.commit(async (tx) => {
51
+ const chats = await tx.doc(Chats);
52
+ const existing = chats.items.find((chat) => chat.chatGuid === chatGuid);
53
+ if (existing)
54
+ return existing.conversationId;
55
+ const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
56
+ await configure(tx, conversation.id, defaults);
57
+ chats.items.push({ chatGuid, conversationId: conversation.id });
58
+ return conversation.id;
59
+ }, BACKGROUND_CONTEXT);
60
+ const conversation = await harness.conversation(conversationId, BACKGROUND_CONTEXT);
61
+ if (!conversation)
62
+ throw new Error("Chat conversation is missing");
63
+ return conversation;
64
+ }
65
+ export async function submitMessage(harness, defaults, input, reply = true) {
66
+ // Snapshot before awaiting: callers may reuse their input object.
67
+ const guid = input.guid;
68
+ const content = formatInput(input);
69
+ if (!input.chatGuid.trim() || !guid.trim() || (!input.text.trim() && !input.attachments?.length))
70
+ throw new Error("Chat GUID, message GUID and content are required");
71
+ const conversation = await chatConversation(harness, defaults, input.chatGuid);
72
+ const log = {
73
+ type: "write",
74
+ requestId: guid,
75
+ entry: { kind: UserEntry.kind, model: [{ role: "user", content, timestamp: Date.now() }] },
76
+ };
77
+ if (!reply)
78
+ return conversation.submit(log, BACKGROUND_CONTEXT);
79
+ const message = { type: "input", content, requestId: guid, whenBusy: "steer" };
80
+ const run = activeRun((await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items, conversation.id);
81
+ const target = run && (await harness.conversation(run.conversationId, BACKGROUND_CONTEXT));
82
+ if (!target)
83
+ return conversation.submit(message, BACKGROUND_CONTEXT);
84
+ // During a run, the chat has one place where work happens; its own conversation keeps the record.
85
+ const submission = await target.submit(message, BACKGROUND_CONTEXT);
86
+ await conversation.submit(log, BACKGROUND_CONTEXT);
87
+ return submission;
88
+ }
@@ -0,0 +1,191 @@
1
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { getCurrentSystemMessage } from "@earendil-works/pi-ai/utils/transcript";
3
+ import { calculateContextTokens, estimateTokens } from "@earendil-works/pi-coding-agent";
4
+ import { LiveDoc, UserEntry, } from "@earendil-works/pi-durable";
5
+ import { readSettings } from "../config/settings.js";
6
+ import { Chats, chatConversation, progressEnabled, setChatProgress } from "./chats.js";
7
+ import { activeRun, Runs, startRun } from "./run.js";
8
+ const help = [
9
+ "/help - list commands",
10
+ "/new - stop current work and start an empty context",
11
+ "/status - show messages, context usage, model and the active run",
12
+ "/compact [instructions] - compress context",
13
+ "/thinking <level|default> - set thinking for this chat",
14
+ "/verbose [on|off|default] - show or set tool progress notices for this chat",
15
+ "/reload - reload models, instructions and skills; apply the default model to this chat and show /status",
16
+ "/run <duration> [task] - keep working until done, blocked or the deadline, e.g. /run 1h",
17
+ "/stop - stop the current work or /run",
18
+ ].join("\n");
19
+ const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh"];
20
+ export function isCommand(text) {
21
+ return text.trimStart().startsWith("/");
22
+ }
23
+ export function parseDuration(value) {
24
+ const match = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(value);
25
+ if (!match || (!match[1] && !match[2]))
26
+ return;
27
+ return (Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0)) * 60000;
28
+ }
29
+ function tokens(count) {
30
+ return count >= 1000 ? `${Math.round(count / 1000)}k` : String(count);
31
+ }
32
+ export function compactionReply(outcome, placement) {
33
+ if (outcome.status === "aborted")
34
+ return "Compaction cancelled.";
35
+ if (outcome.status !== "completed")
36
+ return "Compaction failed.";
37
+ if (outcome.result.entryId !== undefined)
38
+ return "Compacted.";
39
+ if (outcome.result.submissionId === undefined)
40
+ return "Nothing to compact.";
41
+ if (placement?.type !== "write")
42
+ return "Compaction result unavailable.";
43
+ if (placement.status === "done")
44
+ return "Compacted.";
45
+ if (placement.status === "queued")
46
+ return "Compaction summary queued for the next turn boundary.";
47
+ return placement.reason === "stale"
48
+ ? "Compaction summary discarded because the context changed."
49
+ : "Compaction summary was not applied.";
50
+ }
51
+ /** Runs a slash command through Durable APIs; command replies bypass the model. */
52
+ export async function runCommand(options) {
53
+ const { harness, chatGuid } = options;
54
+ const [name, ...rest] = options.text.trim().split(/\s+/);
55
+ const argument = rest.join(" ");
56
+ const conversation = await chatConversation(harness, options.defaults, chatGuid);
57
+ const run = activeRun((await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items, conversation.id);
58
+ // Agent settings apply to the chat and to its active run.
59
+ const configure = async (change) => {
60
+ await conversation.configure(change, BACKGROUND_CONTEXT);
61
+ if (run)
62
+ await (await harness.conversation(run.conversationId, BACKGROUND_CONTEXT))?.configure(change, BACKGROUND_CONTEXT);
63
+ };
64
+ // The /status report: user messages with context usage, then model with thinking (ADR 0013).
65
+ // A third line appears only while a /run is active. Cumulative token totals are deliberately omitted.
66
+ const status = async (withRun) => {
67
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
68
+ const context = await conversation.context(BACKGROUND_CONTEXT);
69
+ const window = agent.model && options.models.getModel(agent.model.provider, agent.model.modelId)?.contextWindow;
70
+ // Context size: the last reported usage, or before the first reply a local estimate of about four characters
71
+ // per token. After /new the next request restates the system prompt and tools, so the estimate replays them
72
+ // from the context just before the reset marker.
73
+ const last = context.messages.findLast((message) => message.role === "assistant" && message.usage.totalTokens > 0);
74
+ const estimate = async () => {
75
+ let system = getCurrentSystemMessage(context.messages);
76
+ if (!system && context.head) {
77
+ const head = context.head.id;
78
+ const before = (await conversation.entries({ maxEntryId: head }, 2, undefined, BACKGROUND_CONTEXT)).items.find((entry) => entry.id !== head);
79
+ if (before)
80
+ system = getCurrentSystemMessage((await conversation.context(BACKGROUND_CONTEXT, { at: before.id })).messages);
81
+ }
82
+ return context.messages
83
+ .filter((message) => message.role !== "system")
84
+ .reduce((sum, message) => sum + estimateTokens(message), system ? estimateTokens(system) : 0);
85
+ };
86
+ const used = last?.role === "assistant" ? calculateContextTokens(last.usage) : await estimate();
87
+ const usage = `${last ? "" : "~"}${((used / (window || 1)) * 100).toFixed(1)}%`;
88
+ return [
89
+ [
90
+ `${context.messages.filter((message) => message.role === "user").length} msgs`,
91
+ window ? `${usage} / ${tokens(window)}` : "",
92
+ ]
93
+ .filter(Boolean)
94
+ .join(" · "),
95
+ `${agent.model ? `${agent.model.provider}/${agent.model.modelId}` : "unset"} · ${agent.thinkingLevel}`,
96
+ ...(withRun && run ? [`run · until ${new Date(run.deadline).toISOString()}`] : []),
97
+ ].join("\n");
98
+ };
99
+ switch (name) {
100
+ case "/help":
101
+ return { reply: help };
102
+ case "/stop": {
103
+ if (run) {
104
+ await harness.abortTask(run.taskId, BACKGROUND_CONTEXT);
105
+ await harness.waitForTask(run.taskId, BACKGROUND_CONTEXT);
106
+ await options.closeBrowsers?.(conversation.id);
107
+ return { reply: "Stopped." };
108
+ }
109
+ // Abort the generation that owns the current inputs, not the conversation, so later queued
110
+ // messages stay in the inbox (ADR 0015). Retry while generation hands the same inputs over.
111
+ const live = async () => (await harness.snapshot(LiveDoc, conversation.id, BACKGROUND_CONTEXT))?.run;
112
+ const stopping = await live();
113
+ if (!stopping) {
114
+ await options.closeBrowsers?.(conversation.id);
115
+ return { reply: "Nothing is running." };
116
+ }
117
+ for (let current = stopping; current?.inputs[0] === stopping.inputs[0]; current = await live()) {
118
+ await harness.abortTask(current.taskId, BACKGROUND_CONTEXT);
119
+ await harness.waitForTask(current.taskId, BACKGROUND_CONTEXT);
120
+ }
121
+ await options.closeBrowsers?.(conversation.id);
122
+ // A passive note gives Durable a boundary to place the queued messages.
123
+ await conversation.submit({
124
+ type: "write",
125
+ entry: {
126
+ kind: UserEntry.kind,
127
+ model: [{ role: "user", content: "[/stop]", timestamp: Date.now() }],
128
+ },
129
+ }, BACKGROUND_CONTEXT);
130
+ return { reply: "Stopped." };
131
+ }
132
+ case "/new":
133
+ // The run task belongs to the chat conversation, so this abort stops it too.
134
+ await conversation.abort(BACKGROUND_CONTEXT);
135
+ await options.closeBrowsers?.(conversation.id);
136
+ await conversation.reset(undefined, BACKGROUND_CONTEXT);
137
+ return { reply: await status(false) };
138
+ case "/status":
139
+ return { reply: await status(true) };
140
+ case "/compact":
141
+ return {
142
+ wait: await conversation.compact(argument || undefined, BACKGROUND_CONTEXT),
143
+ };
144
+ case "/thinking":
145
+ if (argument === "default") {
146
+ await configure({ thinkingLevel: options.defaults.thinkingLevel ?? null });
147
+ return { reply: "Thinking follows the default." };
148
+ }
149
+ if (!thinkingLevels.includes(argument))
150
+ return { reply: `Usage: /thinking <${thinkingLevels.join("|")}|default>` };
151
+ await configure({ thinkingLevel: argument });
152
+ return { reply: `Thinking: ${argument} (this chat only)` };
153
+ case "/verbose": {
154
+ if (argument && argument !== "on" && argument !== "off" && argument !== "default")
155
+ return { reply: "Usage: /verbose [on|off|default]" };
156
+ if (argument)
157
+ await setChatProgress(harness, chatGuid, argument === "default" ? undefined : argument === "on");
158
+ const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
159
+ const chat = chats.find((item) => item.chatGuid === chatGuid);
160
+ const settings = await readSettings(options.workingDir);
161
+ const state = progressEnabled(settings, chats, chatGuid) ? "on" : "off";
162
+ if (chat?.progress === undefined)
163
+ return { reply: `Verbose: ${state} (default)` };
164
+ return { reply: `Verbose: ${state} (this chat only)` };
165
+ }
166
+ case "/reload": {
167
+ const current = await options.reload();
168
+ await configure({ model: current.model });
169
+ // Reply in the /status format so the applied model and run are visible at once.
170
+ return { reply: await status(true) };
171
+ }
172
+ case "/run": {
173
+ const duration = parseDuration(rest[0] ?? "");
174
+ if (!duration)
175
+ return { reply: "Usage: /run <duration, e.g. 30m or 2h> [task]" };
176
+ const task = rest.slice(1).join(" ");
177
+ const started = await startRun(conversation, chatGuid, Date.now() + duration, task || "Continue the current task.");
178
+ if (started.status === "busy")
179
+ return { reply: "Busy. Send /run again when the current work is done." };
180
+ if (started.status === "started")
181
+ return {};
182
+ if (task) {
183
+ const target = await harness.conversation(started.run.conversationId, BACKGROUND_CONTEXT);
184
+ await target?.submit({ type: "input", content: task, requestId: options.guid, whenBusy: "steer" }, BACKGROUND_CONTEXT);
185
+ }
186
+ return { reply: `Run extended until ${new Date(started.run.deadline).toISOString()}.` };
187
+ }
188
+ default:
189
+ return { reply: `Unknown command.\n${help}` };
190
+ }
191
+ }