@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.
- package/README.md +101 -108
- package/dist/agent/behavior.js +79 -0
- package/dist/agent/chats.js +67 -0
- package/dist/agent/commands.js +163 -0
- package/dist/agent/compaction.js +51 -0
- package/dist/agent/deliver.js +139 -0
- package/dist/agent/direct-send.js +68 -0
- package/dist/agent/harness.js +79 -0
- package/dist/agent/health.js +32 -0
- package/dist/agent/models.js +69 -0
- package/dist/agent/progress-format.js +87 -0
- package/dist/agent/progress.js +135 -0
- package/dist/agent/prompt.js +79 -0
- package/dist/agent/replies.js +9 -0
- package/dist/agent/run.js +159 -0
- package/dist/agent/scheduling.js +290 -0
- package/dist/agent/time.js +18 -0
- package/dist/cli.js +88 -191
- package/dist/config/settings.js +60 -0
- package/dist/extensions/browser.js +326 -0
- package/dist/extensions/final-text.js +23 -0
- package/dist/extensions/memory.js +109 -0
- package/dist/extensions/message.js +33 -0
- package/dist/extensions/read-image.js +72 -0
- package/dist/extensions/schedules.js +41 -0
- package/dist/extensions/subagent.js +54 -0
- package/dist/extensions/web.js +99 -0
- package/dist/extensions/workspace.js +98 -0
- package/dist/main.js +571 -74
- package/dist/transport/attachments.js +37 -0
- package/dist/transport/http.js +110 -0
- package/dist/{rich-text.js → transport/rich-text.js} +25 -13
- package/dist/{send.js → transport/send.js} +13 -13
- package/dist/transport/watch.js +124 -0
- package/dist/web/app.js +748 -0
- package/dist/web/history.js +87 -0
- package/dist/web/memory.js +81 -0
- package/dist/web/page.js +28 -0
- package/dist/web/schedules.js +74 -0
- package/dist/web/server.js +348 -0
- package/dist/web/style.css +543 -0
- package/dist/web/tasks.js +63 -0
- package/docs/adr/0001-durable-owns-state.md +11 -0
- package/docs/adr/0002-module-layering.md +11 -0
- package/docs/adr/0003-replies-at-most-once.md +11 -0
- package/docs/adr/0004-native-extensions.md +11 -0
- package/docs/adr/0005-history-migration.md +11 -0
- package/docs/adr/0006-jsonl-storage.md +11 -0
- package/docs/adr/0007-images-by-path.md +11 -0
- package/docs/adr/0008-compaction-and-run.md +11 -0
- package/docs/adr/0009-one-shot-cutover.md +11 -0
- package/docs/adr/0010-test-budget.md +11 -0
- package/docs/adr/0011-reconcile-unsent-answers.md +24 -0
- package/docs/adr/0012-run-in-owned-conversation.md +22 -0
- package/docs/adr/0013-thin-adapters-around-durable.md +18 -0
- package/docs/adr/0014-ephemeral-private-storage.md +13 -0
- package/docs/adr/0015-ordinary-stop-keeps-native-inbox.md +21 -0
- package/docs/adr/0016-process-http-transport.md +21 -0
- package/docs/adr/0017-browser-cli-owned-scope.md +22 -0
- package/docs/adr/0018-tasks-without-prompt-api.md +19 -0
- package/docs/adr/0019-remove-scheduled-services.md +41 -0
- package/docs/adr/0020-chat-task-progress.md +41 -0
- package/docs/adr/0021-readable-progress-without-link-previews.md +33 -0
- package/docs/adr/0022-progress-admission-and-settlement.md +35 -0
- package/docs/adr/0023-retain-progress-type-prefixes.md +18 -0
- package/docs/adr/0024-status-marker-before-type-prefix.md +13 -0
- package/docs/adr/0025-reusable-progress-and-command-preview.md +49 -0
- package/docs/adr/0026-inline-command-only-requests.md +16 -0
- package/docs/adr/0027-command-preview-width.md +10 -0
- package/docs/adr/0028-command-preview-120.md +8 -0
- package/docs/adr/0030-shared-web-read-cache.md +33 -0
- package/docs/adr/0031-quiet-compaction-progress.md +32 -0
- package/docs/adr/0032-native-background-schedules.md +58 -0
- package/docs/adr/0033-operator-scheduled-run.md +23 -0
- package/docs/adr/0034-machine-local-schedule-time.md +36 -0
- package/docs/adr/0035-external-business-task-extensions.md +56 -0
- package/docs/adr/0036-workspace-policy-and-neutral-service-id.md +37 -0
- package/docs/adr/0037-restart-during-in-flight-work.md +30 -0
- package/docs/adr/0038-restart-any-time.md +25 -0
- package/docs/adr/0039-rerun-interrupted-tools.md +24 -0
- package/docs/adr/0040-chat-progress-notices.md +44 -0
- package/docs/adr/0041-restart-and-recovery.md +28 -0
- package/docs/adr/0042-background-schedules-and-workspace-extensions.md +44 -0
- package/docs/adr/0043-remove-legacy-migration.md +23 -0
- package/docs/adr/0044-message-channels.md +32 -0
- package/docs/adr/0045-chat-scheduling-tools.md +51 -0
- package/docs/adr/0046-selective-command-redaction.md +31 -0
- package/docs/adr/0047-one-scheduling-path.md +158 -0
- package/docs/adr/README.md +50 -0
- package/docs/api.md +92 -0
- package/docs/architecture.md +164 -0
- package/docs/browser.md +93 -0
- package/docs/memory.md +27 -0
- package/docs/screenshot.png +0 -0
- package/docs/workspace-extensions.md +133 -0
- package/examples/workspace-extensions/system-context/index.ts +69 -0
- package/examples/workspace-extensions/system-context/package.json +3 -0
- package/ops/README.md +267 -0
- package/ops/preflight.mjs +28 -0
- package/package.json +33 -45
- package/dist/agent.d.ts +0 -60
- package/dist/agent.d.ts.map +0 -1
- package/dist/agent.js +0 -715
- package/dist/agent.js.map +0 -1
- package/dist/cli.d.ts +0 -15
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/imessage.d.ts +0 -33
- package/dist/imessage.d.ts.map +0 -1
- package/dist/imessage.js +0 -76
- package/dist/imessage.js.map +0 -1
- package/dist/logger.d.ts +0 -33
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -104
- package/dist/logger.js.map +0 -1
- package/dist/main.d.ts +0 -5
- package/dist/main.d.ts.map +0 -1
- package/dist/main.js.map +0 -1
- package/dist/memory.d.ts +0 -53
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js +0 -196
- package/dist/memory.js.map +0 -1
- package/dist/model-health.d.ts +0 -11
- package/dist/model-health.d.ts.map +0 -1
- package/dist/model-health.js +0 -67
- package/dist/model-health.js.map +0 -1
- package/dist/pipeline.d.ts +0 -27
- package/dist/pipeline.d.ts.map +0 -1
- package/dist/pipeline.js +0 -59
- package/dist/pipeline.js.map +0 -1
- package/dist/queue.d.ts +0 -23
- package/dist/queue.d.ts.map +0 -1
- package/dist/queue.js +0 -89
- package/dist/queue.js.map +0 -1
- package/dist/rich-text.d.ts +0 -26
- package/dist/rich-text.d.ts.map +0 -1
- package/dist/rich-text.js.map +0 -1
- package/dist/self-echo.d.ts +0 -19
- package/dist/self-echo.d.ts.map +0 -1
- package/dist/self-echo.js +0 -54
- package/dist/self-echo.js.map +0 -1
- package/dist/send.d.ts +0 -24
- package/dist/send.d.ts.map +0 -1
- package/dist/send.js.map +0 -1
- package/dist/settings.d.ts +0 -44
- package/dist/settings.d.ts.map +0 -1
- package/dist/settings.js +0 -80
- package/dist/settings.js.map +0 -1
- package/dist/store.d.ts +0 -57
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js +0 -124
- package/dist/store.js.map +0 -1
- package/dist/tasks.d.ts +0 -95
- package/dist/tasks.d.ts.map +0 -1
- package/dist/tasks.js +0 -384
- package/dist/tasks.js.map +0 -1
- package/dist/types.d.ts +0 -93
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -38
- package/dist/types.js.map +0 -1
- package/dist/watch.d.ts +0 -21
- package/dist/watch.d.ts.map +0 -1
- package/dist/watch.js +0 -219
- package/dist/watch.js.map +0 -1
- package/dist/web/data.d.ts +0 -11
- package/dist/web/data.d.ts.map +0 -1
- package/dist/web/data.js +0 -56
- package/dist/web/data.js.map +0 -1
- package/dist/web/html.d.ts +0 -5
- package/dist/web/html.d.ts.map +0 -1
- package/dist/web/html.js +0 -16
- package/dist/web/html.js.map +0 -1
- package/dist/web/index.d.ts +0 -23
- package/dist/web/index.d.ts.map +0 -1
- package/dist/web/index.js +0 -279
- package/dist/web/index.js.map +0 -1
- package/dist/web/render.d.ts +0 -11
- package/dist/web/render.d.ts.map +0 -1
- package/dist/web/render.js +0 -56
- package/dist/web/render.js.map +0 -1
- package/dist/web/templates/logs.eta +0 -75
- package/dist/web/templates/memory.eta +0 -91
- package/dist/web/templates/page.eta +0 -98
package/README.md
CHANGED
|
@@ -1,70 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-imessage
|
|
2
2
|
|
|
3
|
-
A minimal
|
|
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="
|
|
5
|
+
<img height="420" src="docs/screenshot.png" />
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
- **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
|
|
13
8
|
|
|
14
|
-
|
|
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).
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
> - Replies are **off** for all chats by default (`blacklist: ["*"]`) — only explicitly whitelisted chats get a response
|
|
18
|
-
> - The agent runs with Full Disk Access and can read/write your filesystem as part of its tool use
|
|
19
|
-
> - 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
|
|
20
16
|
|
|
21
|
-
|
|
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).
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
npm install -g @kingcrab/pi-imessage
|
|
25
25
|
|
|
26
26
|
pi-imessage # run in foreground
|
|
27
|
-
pi-imessage install #
|
|
27
|
+
pi-imessage install # write a launchd job; loading it is a separate operator action
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
## Web UI
|
|
33
|
-
|
|
34
|
-
Available at `http://localhost:7750` (configurable via `WEB_HOST` and `WEB_PORT`).
|
|
35
|
-
|
|
36
|
-
- Chat history with live updates
|
|
37
|
-
- Logs (tail -f style)
|
|
38
|
-
- Memory (global & per-chat)
|
|
39
|
-
|
|
40
|
-
P.S. Disable with `WEB_ENABLED=false` and let the agent build your own web UI
|
|
41
|
-
|
|
42
|
-
## API
|
|
43
|
-
|
|
44
|
-
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`).
|
|
45
|
-
|
|
46
|
-
| Endpoint | Description | Example |
|
|
47
|
-
|---|---|---|
|
|
48
|
-
| `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}` |
|
|
49
|
-
| `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}` |
|
|
50
|
-
| `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).
|
|
51
32
|
|
|
52
33
|
## Commands
|
|
53
34
|
|
|
54
|
-
Send these as
|
|
35
|
+
Send these commands as iMessages:
|
|
55
36
|
|
|
56
|
-
| Command | Description |
|
|
57
|
-
|
|
58
|
-
| `/help` | List
|
|
59
|
-
| `/new` |
|
|
60
|
-
| `/status` | Show
|
|
61
|
-
| `/
|
|
62
|
-
| `/
|
|
63
|
-
| `/
|
|
37
|
+
| Command | Description |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `/help` | List commands |
|
|
40
|
+
| `/new` | Cancel current work and start a fresh conversation |
|
|
41
|
+
| `/status` | Show tokens, context usage, and model |
|
|
42
|
+
| `/thinking <level\|default>` | Set thinking for this chat or follow the default |
|
|
43
|
+
| `/compact` | Compress context without replaying prior work |
|
|
44
|
+
| `/run <duration> [task]` | Keep working for up to the given time, e.g. `/run 1h`; `/stop` ends it early |
|
|
45
|
+
| `/stop` | Abort current work and stop `/run`; later messages are still answered |
|
|
46
|
+
| `/reload` | Refresh models/auth, AGENTS, skills and extensions without cancelling work |
|
|
64
47
|
|
|
65
|
-
##
|
|
48
|
+
## Configuration
|
|
66
49
|
|
|
67
|
-
|
|
50
|
+
### Settings
|
|
51
|
+
|
|
52
|
+
Edit `WORKING_DIR/settings.json`. All fields are optional.
|
|
68
53
|
|
|
69
54
|
```json
|
|
70
55
|
{
|
|
@@ -79,72 +64,80 @@ All fields are optional.
|
|
|
79
64
|
}
|
|
80
65
|
```
|
|
81
66
|
|
|
82
|
-
|
|
67
|
+
Add chat GUIDs to `whitelist` to enable replies; `blacklist: ["*"]` blocks replies
|
|
68
|
+
elsewhere. An explicit blacklist entry takes priority. Messages are always logged.
|
|
69
|
+
Rich text is optional; `markdown: true` renders bold spans in direct iMessage chats.
|
|
83
70
|
|
|
84
|
-
|
|
71
|
+
### Workspace extensions and scheduler
|
|
85
72
|
|
|
86
|
-
|
|
73
|
+
Context compaction runs every six hours as a native Durable background task.
|
|
74
|
+
Personal tools, tasks and optional daily/interval schedules live in
|
|
75
|
+
`WORKING_DIR/extensions/<name>/index.ts` with `config.json`, not framework
|
|
76
|
+
settings. Startup and `/reload` load them; the assistant can use
|
|
77
|
+
`reload_extensions` to apply its own changes.
|
|
87
78
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
| `WORKING_DIR` | no | `~/.pi/imessage` | Workspace directory |
|
|
94
|
-
| `AGENT_IDLE_TIMEOUT_MS` | no | `120000` | Abort only after this much continuous agent inactivity; model and tool events reset the timer |
|
|
95
|
-
| `AGENT_MAX_PROMPT_DURATION_MS` | no | `1800000` | Absolute ceiling for one prompt, independent of activity |
|
|
79
|
+
See [workspace extensions](docs/workspace-extensions.md) for the factory contract
|
|
80
|
+
and scheduling rules.
|
|
81
|
+
One-off runs use the same scheduler: declare a stable `runRequests` ID, target
|
|
82
|
+
schedule and `at: "now"` (or epoch milliseconds) in the extension configuration,
|
|
83
|
+
then call `reload_extensions`. No restart or separate execution API is needed.
|
|
96
84
|
|
|
97
|
-
|
|
85
|
+
Times use the service machine's local timezone. Saved deadlines survive reload
|
|
86
|
+
and restart; view schedules and latest-ten execution history at `/scheduled`.
|
|
98
87
|
|
|
99
|
-
|
|
100
|
-
npm run check # typecheck + lint (run after code changes)
|
|
101
|
-
npm test # run tests
|
|
102
|
-
```
|
|
88
|
+
### Model policy
|
|
103
89
|
|
|
104
|
-
|
|
90
|
+
Optional `settings.json` fields, applied at startup and `/reload`:
|
|
105
91
|
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"modelPolicy": {
|
|
95
|
+
"fallback": { "provider": "your-provider", "modelId": "your-model" },
|
|
96
|
+
"codexServiceTier": "default"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
106
99
|
```
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
│ │ │ │ steering messages │ │ │
|
|
137
|
-
│ │ │ └───────────────────────────┘ │ │
|
|
138
|
-
│ │ └────────────────────────────────┘ │
|
|
139
|
-
│ │ │
|
|
140
|
-
│ ▼ │
|
|
141
|
-
│ Collect assistant reply text │
|
|
142
|
-
│ │ │
|
|
143
|
-
│ ├─ sendMessage (AppleScript → Messages.app) │
|
|
144
|
-
│ └─ save logs (messages, digests) │
|
|
145
|
-
│ │
|
|
146
|
-
└──────────────────────────────────────────────────┘
|
|
147
|
-
│
|
|
148
|
-
▼
|
|
149
|
-
iMessage (user receives reply via Messages.app)
|
|
100
|
+
|
|
101
|
+
Without a fallback, an unavailable default fails explicitly. Without a tier,
|
|
102
|
+
provider requests are unchanged. `codexServiceTier` accepts `default` or
|
|
103
|
+
`priority`; priority may have a different cost. Existing deployments migrate
|
|
104
|
+
their previous choices into explicit configuration rather than silently changing
|
|
105
|
+
behavior.
|
|
106
|
+
|
|
107
|
+
### Environment Variables
|
|
108
|
+
|
|
109
|
+
All variables are optional.
|
|
110
|
+
|
|
111
|
+
| Variable | Default | Description |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `WEB_ENABLED` | `true` | Set to `false` to disable the built-in web UI |
|
|
114
|
+
| `WEB_HOST` | `localhost` | Web UI host |
|
|
115
|
+
| `WEB_PORT` | `7750` | Web UI port |
|
|
116
|
+
| `WORKING_DIR` | `~/.pi/imessage` | Workspace directory |
|
|
117
|
+
|
|
118
|
+
### Web and API
|
|
119
|
+
|
|
120
|
+
See the [HTTP API](docs/api.md) for read-only state and model health checks.
|
|
121
|
+
The agent sends files and extra messages with `send_message`. The scheduler
|
|
122
|
+
provides daily/interval cadence and one-off triggers for workspace tasks, plus built-in six-hour compaction.
|
|
123
|
+
It does not revive legacy cron files or HTTP prompt/send endpoints.
|
|
124
|
+
|
|
125
|
+
## How It Works
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
Messages chat.db → Watcher → Pi Durable → Messages.app
|
|
150
129
|
```
|
|
130
|
+
|
|
131
|
+
Each chat has its own conversation, persisted under `WORKING_DIR/durable/`.
|
|
132
|
+
Durable resumes pending work after a crash. Agent replies are sent at most once;
|
|
133
|
+
uncertain send results are flagged, never automatically resent.
|
|
134
|
+
|
|
135
|
+
Context is compacted silently every 6 hours and near the model's limit. Failed
|
|
136
|
+
compaction keeps the old context. `/new` starts fresh without deleting searchable
|
|
137
|
+
history.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
See [AGENTS.md](AGENTS.md) for development rules,
|
|
142
|
+
[architecture](docs/architecture.md) for the module layout and execution flow,
|
|
143
|
+
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,67 @@
|
|
|
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
|
+
});
|
|
10
|
+
export const WatchCursor = defineDoc({
|
|
11
|
+
kind: "imessage.watch-cursor",
|
|
12
|
+
version: 1,
|
|
13
|
+
scope: "session",
|
|
14
|
+
initial: () => ({}),
|
|
15
|
+
});
|
|
16
|
+
export function formatInput(input) {
|
|
17
|
+
let prefix = "";
|
|
18
|
+
if (input.sender !== undefined) {
|
|
19
|
+
if (input.chatGuid.includes(";+;"))
|
|
20
|
+
prefix = `[Group '${input.groupName || "unnamed"}' from ${input.sender}] `;
|
|
21
|
+
else
|
|
22
|
+
prefix = `[${input.service === "SMS" ? "SMS" : "DM"} from ${input.sender}] `;
|
|
23
|
+
}
|
|
24
|
+
const reply = input.replyTo ? `[replying to: "${input.replyTo}"] ` : "";
|
|
25
|
+
const files = (input.attachments ?? []).map((path) => `\n[Attachment: ${path}]`).join("");
|
|
26
|
+
return `${prefix}${reply}${input.text}${files}`;
|
|
27
|
+
}
|
|
28
|
+
export async function chatConversation(harness, defaults, chatGuid) {
|
|
29
|
+
const conversationId = await harness.commit(async (tx) => {
|
|
30
|
+
const chats = await tx.doc(Chats);
|
|
31
|
+
const existing = chats.items.find((chat) => chat.chatGuid === chatGuid);
|
|
32
|
+
if (existing)
|
|
33
|
+
return existing.conversationId;
|
|
34
|
+
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
|
35
|
+
await configure(tx, conversation.id, defaults);
|
|
36
|
+
chats.items.push({ chatGuid, conversationId: conversation.id });
|
|
37
|
+
return conversation.id;
|
|
38
|
+
}, BACKGROUND_CONTEXT);
|
|
39
|
+
const conversation = await harness.conversation(conversationId, BACKGROUND_CONTEXT);
|
|
40
|
+
if (!conversation)
|
|
41
|
+
throw new Error("Chat conversation is missing");
|
|
42
|
+
return conversation;
|
|
43
|
+
}
|
|
44
|
+
export async function submitMessage(harness, defaults, input, reply = true) {
|
|
45
|
+
// Snapshot before awaiting: callers may reuse their input object.
|
|
46
|
+
const guid = input.guid;
|
|
47
|
+
const content = formatInput(input);
|
|
48
|
+
if (!input.chatGuid.trim() || !guid.trim() || (!input.text.trim() && !input.attachments?.length))
|
|
49
|
+
throw new Error("Chat GUID, message GUID and content are required");
|
|
50
|
+
const conversation = await chatConversation(harness, defaults, input.chatGuid);
|
|
51
|
+
const log = {
|
|
52
|
+
type: "write",
|
|
53
|
+
requestId: guid,
|
|
54
|
+
entry: { kind: UserEntry.kind, model: [{ role: "user", content, timestamp: Date.now() }] },
|
|
55
|
+
};
|
|
56
|
+
if (!reply)
|
|
57
|
+
return conversation.submit(log, BACKGROUND_CONTEXT);
|
|
58
|
+
const message = { type: "input", content, requestId: guid, whenBusy: "steer" };
|
|
59
|
+
const run = activeRun((await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items, conversation.id);
|
|
60
|
+
const target = run && (await harness.conversation(run.conversationId, BACKGROUND_CONTEXT));
|
|
61
|
+
if (!target)
|
|
62
|
+
return conversation.submit(message, BACKGROUND_CONTEXT);
|
|
63
|
+
// During a run, the chat has one place where work happens; its own conversation keeps the record.
|
|
64
|
+
const submission = await target.submit(message, BACKGROUND_CONTEXT);
|
|
65
|
+
await conversation.submit(log, BACKGROUND_CONTEXT);
|
|
66
|
+
return submission;
|
|
67
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { LiveDoc, UsageDoc, UserEntry, } from "@earendil-works/pi-durable";
|
|
3
|
+
import { chatConversation } from "./chats.js";
|
|
4
|
+
import { activeRun, Runs, startRun } from "./run.js";
|
|
5
|
+
const help = [
|
|
6
|
+
"/help - list commands",
|
|
7
|
+
"/new - stop current work and start an empty context",
|
|
8
|
+
"/status - show messages, tokens, context usage, model and run",
|
|
9
|
+
"/compact [instructions] - compress context",
|
|
10
|
+
"/thinking <level|default> - set thinking for this chat",
|
|
11
|
+
"/reload - reload models, instructions and skills; apply the default model to this chat",
|
|
12
|
+
"/run <duration> [task] - keep working until done, blocked or the deadline, e.g. /run 1h",
|
|
13
|
+
"/stop - stop the current work or /run",
|
|
14
|
+
].join("\n");
|
|
15
|
+
const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
16
|
+
export function isCommand(text) {
|
|
17
|
+
return text.trimStart().startsWith("/");
|
|
18
|
+
}
|
|
19
|
+
export function parseDuration(value) {
|
|
20
|
+
const match = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(value);
|
|
21
|
+
if (!match || (!match[1] && !match[2]))
|
|
22
|
+
return;
|
|
23
|
+
return (Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0)) * 60000;
|
|
24
|
+
}
|
|
25
|
+
function tokens(count) {
|
|
26
|
+
return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
|
|
27
|
+
}
|
|
28
|
+
export function compactionReply(outcome, placement) {
|
|
29
|
+
if (outcome.status === "aborted")
|
|
30
|
+
return "Compaction cancelled.";
|
|
31
|
+
if (outcome.status !== "completed")
|
|
32
|
+
return "Compaction failed.";
|
|
33
|
+
if (outcome.result.entryId !== undefined)
|
|
34
|
+
return "Compacted.";
|
|
35
|
+
if (outcome.result.submissionId === undefined)
|
|
36
|
+
return "Nothing to compact.";
|
|
37
|
+
if (placement?.type !== "write")
|
|
38
|
+
return "Compaction result unavailable.";
|
|
39
|
+
if (placement.status === "done")
|
|
40
|
+
return "Compacted.";
|
|
41
|
+
if (placement.status === "queued")
|
|
42
|
+
return "Compaction summary queued for the next turn boundary.";
|
|
43
|
+
return placement.reason === "stale"
|
|
44
|
+
? "Compaction summary discarded because the context changed."
|
|
45
|
+
: "Compaction summary was not applied.";
|
|
46
|
+
}
|
|
47
|
+
/** Runs a slash command through Durable APIs; command replies bypass the model. */
|
|
48
|
+
export async function runCommand(options) {
|
|
49
|
+
const { harness, chatGuid } = options;
|
|
50
|
+
const [name, ...rest] = options.text.trim().split(/\s+/);
|
|
51
|
+
const argument = rest.join(" ");
|
|
52
|
+
const conversation = await chatConversation(harness, options.defaults, chatGuid);
|
|
53
|
+
const run = activeRun((await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items, conversation.id);
|
|
54
|
+
// Agent settings apply to the chat and to its active run.
|
|
55
|
+
const configure = async (change) => {
|
|
56
|
+
await conversation.configure(change, BACKGROUND_CONTEXT);
|
|
57
|
+
if (run)
|
|
58
|
+
await (await harness.conversation(run.conversationId, BACKGROUND_CONTEXT))?.configure(change, BACKGROUND_CONTEXT);
|
|
59
|
+
};
|
|
60
|
+
switch (name) {
|
|
61
|
+
case "/help":
|
|
62
|
+
return { reply: help };
|
|
63
|
+
case "/stop": {
|
|
64
|
+
if (run) {
|
|
65
|
+
await harness.abortTask(run.taskId, BACKGROUND_CONTEXT);
|
|
66
|
+
await harness.waitForTask(run.taskId, BACKGROUND_CONTEXT);
|
|
67
|
+
await options.closeBrowsers?.(conversation.id);
|
|
68
|
+
return { reply: "Stopped." };
|
|
69
|
+
}
|
|
70
|
+
// Abort the generation that owns the current inputs, not the conversation, so later queued
|
|
71
|
+
// messages stay in the inbox (ADR 0015). Retry while generation hands the same inputs over.
|
|
72
|
+
const live = async () => (await harness.snapshot(LiveDoc, conversation.id, BACKGROUND_CONTEXT))?.run;
|
|
73
|
+
const stopping = await live();
|
|
74
|
+
if (!stopping) {
|
|
75
|
+
await options.closeBrowsers?.(conversation.id);
|
|
76
|
+
return { reply: "Nothing is running." };
|
|
77
|
+
}
|
|
78
|
+
for (let current = stopping; current?.inputs[0] === stopping.inputs[0]; current = await live()) {
|
|
79
|
+
await harness.abortTask(current.taskId, BACKGROUND_CONTEXT);
|
|
80
|
+
await harness.waitForTask(current.taskId, BACKGROUND_CONTEXT);
|
|
81
|
+
}
|
|
82
|
+
await options.closeBrowsers?.(conversation.id);
|
|
83
|
+
// A passive note gives Durable a boundary to place the queued messages.
|
|
84
|
+
await conversation.submit({
|
|
85
|
+
type: "write",
|
|
86
|
+
entry: {
|
|
87
|
+
kind: UserEntry.kind,
|
|
88
|
+
model: [{ role: "user", content: "[/stop]", timestamp: Date.now() }],
|
|
89
|
+
},
|
|
90
|
+
}, BACKGROUND_CONTEXT);
|
|
91
|
+
return { reply: "Stopped." };
|
|
92
|
+
}
|
|
93
|
+
case "/new":
|
|
94
|
+
case "/status": {
|
|
95
|
+
if (name === "/new") {
|
|
96
|
+
// The run task belongs to the chat conversation, so this abort stops it too.
|
|
97
|
+
await conversation.abort(BACKGROUND_CONTEXT);
|
|
98
|
+
await options.closeBrowsers?.(conversation.id);
|
|
99
|
+
await conversation.reset(undefined, BACKGROUND_CONTEXT);
|
|
100
|
+
}
|
|
101
|
+
const agent = await conversation.agent(BACKGROUND_CONTEXT);
|
|
102
|
+
const context = await conversation.context(BACKGROUND_CONTEXT);
|
|
103
|
+
const usage = Object.values((await harness.snapshot(UsageDoc, conversation.id, BACKGROUND_CONTEXT))?.models ?? {});
|
|
104
|
+
const last = context.messages.findLast((message) => message.role === "assistant" && message.usage.totalTokens > 0);
|
|
105
|
+
const window = agent.model && options.models.getModel(agent.model.provider, agent.model.modelId)?.contextWindow;
|
|
106
|
+
const used = last?.role === "assistant" ? last.usage.totalTokens : undefined;
|
|
107
|
+
return {
|
|
108
|
+
reply: [
|
|
109
|
+
[
|
|
110
|
+
`${context.messages.filter((message) => message.role === "user").length} msgs -`,
|
|
111
|
+
`in ${tokens(usage.reduce((sum, item) => sum + item.input, 0))}`,
|
|
112
|
+
`out ${tokens(usage.reduce((sum, item) => sum + item.output, 0))}`,
|
|
113
|
+
window
|
|
114
|
+
? `${used === undefined ? "?" : `${((used / window) * 100).toFixed(1)}%`}/${tokens(window)}`
|
|
115
|
+
: "",
|
|
116
|
+
]
|
|
117
|
+
.join(" ")
|
|
118
|
+
.trim(),
|
|
119
|
+
`Model: ${agent.model ? `${agent.model.provider}/${agent.model.modelId}` : "unset"}, thinking: ${agent.thinkingLevel}`,
|
|
120
|
+
...(name === "/status"
|
|
121
|
+
? [`Run: ${run ? `until ${new Date(run.deadline).toISOString()}` : "none"}`]
|
|
122
|
+
: []),
|
|
123
|
+
].join("\n"),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case "/compact":
|
|
127
|
+
return {
|
|
128
|
+
wait: await conversation.compact(argument || undefined, BACKGROUND_CONTEXT),
|
|
129
|
+
};
|
|
130
|
+
case "/thinking":
|
|
131
|
+
if (argument === "default") {
|
|
132
|
+
await configure({ thinkingLevel: options.defaults.thinkingLevel ?? null });
|
|
133
|
+
return { reply: "Thinking follows the default." };
|
|
134
|
+
}
|
|
135
|
+
if (!thinkingLevels.includes(argument))
|
|
136
|
+
return { reply: `Usage: /thinking <${thinkingLevels.join("|")}|default>` };
|
|
137
|
+
await configure({ thinkingLevel: argument });
|
|
138
|
+
return { reply: `Thinking: ${argument} (this chat only)` };
|
|
139
|
+
case "/reload": {
|
|
140
|
+
const current = await options.reload();
|
|
141
|
+
await configure({ model: current.model });
|
|
142
|
+
return { reply: `Reloaded. Model: ${current.model.provider}/${current.model.modelId}` };
|
|
143
|
+
}
|
|
144
|
+
case "/run": {
|
|
145
|
+
const duration = parseDuration(rest[0] ?? "");
|
|
146
|
+
if (!duration)
|
|
147
|
+
return { reply: "Usage: /run <duration, e.g. 30m or 2h> [task]" };
|
|
148
|
+
const task = rest.slice(1).join(" ");
|
|
149
|
+
const started = await startRun(conversation, chatGuid, Date.now() + duration, task || "Continue the current task.");
|
|
150
|
+
if (started.status === "busy")
|
|
151
|
+
return { reply: "Busy. Send /run again when the current work is done." };
|
|
152
|
+
if (started.status === "started")
|
|
153
|
+
return {};
|
|
154
|
+
if (task) {
|
|
155
|
+
const target = await harness.conversation(started.run.conversationId, BACKGROUND_CONTEXT);
|
|
156
|
+
await target?.submit({ type: "input", content: task, requestId: options.guid, whenBusy: "steer" }, BACKGROUND_CONTEXT);
|
|
157
|
+
}
|
|
158
|
+
return { reply: `Run extended until ${new Date(started.run.deadline).toISOString()}.` };
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
return { reply: `Unknown command.\n${help}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { CompactionEntry, LiveDoc, ResetEntry } from "@earendil-works/pi-durable";
|
|
3
|
+
import { Chats } from "./chats.js";
|
|
4
|
+
// Native compact() uses reason "manual" for both a command and a maintenance tick.
|
|
5
|
+
// Record exact timer task IDs, not conversation IDs, so concurrent manual work stays visible.
|
|
6
|
+
const scheduled = new WeakMap();
|
|
7
|
+
/** Resolve admission before classifying: the commit may be observed before compact() returns its ID. */
|
|
8
|
+
export async function takeScheduledCompaction(harness, taskId) {
|
|
9
|
+
const state = scheduled.get(harness);
|
|
10
|
+
if (!state)
|
|
11
|
+
return false;
|
|
12
|
+
await Promise.allSettled([...state.admissions]);
|
|
13
|
+
return state.tasks.delete(taskId);
|
|
14
|
+
}
|
|
15
|
+
/** Admit native compactions, not another summarization loop; Durable decides whether a cut is needed. */
|
|
16
|
+
export async function compactChats(harness, admissionFailed) {
|
|
17
|
+
const tasks = [];
|
|
18
|
+
const chats = await harness.snapshot(Chats, BACKGROUND_CONTEXT);
|
|
19
|
+
for (const chat of chats?.items ?? []) {
|
|
20
|
+
try {
|
|
21
|
+
if ((await harness.snapshot(LiveDoc, chat.conversationId, BACKGROUND_CONTEXT))?.compactions?.length)
|
|
22
|
+
continue;
|
|
23
|
+
const conversation = await harness.conversation(chat.conversationId, BACKGROUND_CONTEXT);
|
|
24
|
+
const newest = (await conversation?.entries({}, 1, undefined, BACKGROUND_CONTEXT))?.items[0];
|
|
25
|
+
if (!conversation || !newest || CompactionEntry.is(newest) || ResetEntry.is(newest))
|
|
26
|
+
continue;
|
|
27
|
+
let state = scheduled.get(harness);
|
|
28
|
+
if (!state) {
|
|
29
|
+
state = { admissions: new Set(), tasks: new Set() };
|
|
30
|
+
scheduled.set(harness, state);
|
|
31
|
+
}
|
|
32
|
+
const quiet = state;
|
|
33
|
+
const admission = conversation.compact(undefined, BACKGROUND_CONTEXT).then((taskId) => {
|
|
34
|
+
quiet.tasks.add(taskId);
|
|
35
|
+
return taskId;
|
|
36
|
+
});
|
|
37
|
+
quiet.admissions.add(admission);
|
|
38
|
+
try {
|
|
39
|
+
tasks.push(await admission);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
quiet.admissions.delete(admission);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
admissionFailed?.();
|
|
47
|
+
console.warn("Scheduled compaction admission failed", chat.chatGuid, error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return tasks;
|
|
51
|
+
}
|