@kingcrab/pi-imessage 1.0.0 → 1.1.1
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 +38 -46
- package/dist/agent/chats.js +21 -0
- package/dist/agent/commands.js +66 -38
- package/dist/agent/direct-send.js +1 -0
- package/dist/agent/error-log.js +37 -0
- package/dist/agent/health.js +1 -0
- package/dist/agent/progress.js +43 -5
- package/dist/agent/replies.js +1 -0
- package/dist/agent/run.js +2 -1
- package/dist/agent/scheduling.js +8 -7
- package/dist/extensions/browser.js +1 -1
- package/dist/extensions/memory.js +1 -1
- package/dist/extensions/message.js +1 -1
- package/dist/extensions/read-image.js +1 -1
- package/dist/extensions/restart.js +100 -0
- package/dist/extensions/schedules.js +1 -0
- package/dist/extensions/subagent.js +1 -1
- package/dist/extensions/web.js +4 -3
- package/dist/extensions/workspace.js +1 -1
- package/dist/main.js +25 -4
- package/dist/web/app.js +198 -55
- package/dist/web/page.js +3 -2
- package/dist/web/schedules.js +4 -15
- package/dist/web/server.js +48 -3
- package/dist/web/style.css +40 -0
- package/dist/web/tasks.js +183 -41
- package/docs/adr/0048-quiet-attachment-reads.md +11 -0
- package/docs/adr/0049-per-chat-progress-notices.md +21 -0
- package/docs/adr/0052-host-owned-self-restart.md +53 -0
- package/docs/adr/0053-service-log-errors.md +44 -0
- package/docs/adr/README.md +7 -2
- package/docs/api.md +16 -4
- package/ops/README.md +24 -41
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -32,18 +32,8 @@ For installation and service operations, see [ops/README.md](ops/README.md).
|
|
|
32
32
|
|
|
33
33
|
## Commands
|
|
34
34
|
|
|
35
|
-
Send
|
|
36
|
-
|
|
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 |
|
|
35
|
+
Send `/help` in a chat to list commands: `/new`, `/status`, `/thinking`,
|
|
36
|
+
`/verbose`, `/compact`, `/run`, `/stop` and `/reload`.
|
|
47
37
|
|
|
48
38
|
## Configuration
|
|
49
39
|
|
|
@@ -64,27 +54,6 @@ Edit `WORKING_DIR/settings.json`. All fields are optional.
|
|
|
64
54
|
}
|
|
65
55
|
```
|
|
66
56
|
|
|
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.
|
|
70
|
-
|
|
71
|
-
### Workspace extensions and scheduler
|
|
72
|
-
|
|
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.
|
|
78
|
-
|
|
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.
|
|
84
|
-
|
|
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`.
|
|
87
|
-
|
|
88
57
|
### Model policy
|
|
89
58
|
|
|
90
59
|
Optional `settings.json` fields, applied at startup and `/reload`:
|
|
@@ -98,11 +67,8 @@ Optional `settings.json` fields, applied at startup and `/reload`:
|
|
|
98
67
|
}
|
|
99
68
|
```
|
|
100
69
|
|
|
101
|
-
Without a fallback, an unavailable default fails explicitly.
|
|
102
|
-
|
|
103
|
-
`priority`; priority may have a different cost. Existing deployments migrate
|
|
104
|
-
their previous choices into explicit configuration rather than silently changing
|
|
105
|
-
behavior.
|
|
70
|
+
Without a fallback, an unavailable default fails explicitly. `codexServiceTier`
|
|
71
|
+
accepts `default` or `priority`; priority may have a different cost.
|
|
106
72
|
|
|
107
73
|
### Environment Variables
|
|
108
74
|
|
|
@@ -118,9 +84,7 @@ All variables are optional.
|
|
|
118
84
|
### Web and API
|
|
119
85
|
|
|
120
86
|
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`.
|
|
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.
|
|
87
|
+
The agent sends files and extra messages with `send_message`.
|
|
124
88
|
|
|
125
89
|
## How It Works
|
|
126
90
|
|
|
@@ -129,12 +93,40 @@ Messages chat.db → Watcher → Pi Durable → Messages.app
|
|
|
129
93
|
```
|
|
130
94
|
|
|
131
95
|
Each chat has its own conversation, persisted under `WORKING_DIR/durable/`.
|
|
132
|
-
Durable resumes pending work after a crash.
|
|
133
|
-
uncertain
|
|
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.
|
|
134
99
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
129
|
+
```
|
|
138
130
|
|
|
139
131
|
## Development
|
|
140
132
|
|
package/dist/agent/chats.js
CHANGED
|
@@ -6,13 +6,34 @@ export const Chats = defineDoc({
|
|
|
6
6
|
version: 1,
|
|
7
7
|
scope: "session",
|
|
8
8
|
initial: () => ({ items: [] }),
|
|
9
|
+
checkpointWhen: () => false,
|
|
9
10
|
});
|
|
10
11
|
export const WatchCursor = defineDoc({
|
|
11
12
|
kind: "imessage.watch-cursor",
|
|
12
13
|
version: 1,
|
|
13
14
|
scope: "session",
|
|
14
15
|
initial: () => ({}),
|
|
16
|
+
checkpointWhen: () => false,
|
|
15
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
|
+
}
|
|
16
37
|
export function formatInput(input) {
|
|
17
38
|
let prefix = "";
|
|
18
39
|
if (input.sender !== undefined) {
|
package/dist/agent/commands.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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";
|
|
4
7
|
import { activeRun, Runs, startRun } from "./run.js";
|
|
5
8
|
const help = [
|
|
6
9
|
"/help - list commands",
|
|
7
10
|
"/new - stop current work and start an empty context",
|
|
8
|
-
"/status - show messages,
|
|
11
|
+
"/status - show messages, context usage, model and the active run",
|
|
9
12
|
"/compact [instructions] - compress context",
|
|
10
13
|
"/thinking <level|default> - set thinking for this chat",
|
|
11
|
-
"/
|
|
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",
|
|
12
16
|
"/run <duration> [task] - keep working until done, blocked or the deadline, e.g. /run 1h",
|
|
13
17
|
"/stop - stop the current work or /run",
|
|
14
18
|
].join("\n");
|
|
@@ -23,7 +27,7 @@ export function parseDuration(value) {
|
|
|
23
27
|
return (Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0)) * 60000;
|
|
24
28
|
}
|
|
25
29
|
function tokens(count) {
|
|
26
|
-
return count >= 1000 ? `${(count / 1000)
|
|
30
|
+
return count >= 1000 ? `${Math.round(count / 1000)}k` : String(count);
|
|
27
31
|
}
|
|
28
32
|
export function compactionReply(outcome, placement) {
|
|
29
33
|
if (outcome.status === "aborted")
|
|
@@ -57,6 +61,41 @@ export async function runCommand(options) {
|
|
|
57
61
|
if (run)
|
|
58
62
|
await (await harness.conversation(run.conversationId, BACKGROUND_CONTEXT))?.configure(change, BACKGROUND_CONTEXT);
|
|
59
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
|
+
};
|
|
60
99
|
switch (name) {
|
|
61
100
|
case "/help":
|
|
62
101
|
return { reply: help };
|
|
@@ -91,38 +130,13 @@ export async function runCommand(options) {
|
|
|
91
130
|
return { reply: "Stopped." };
|
|
92
131
|
}
|
|
93
132
|
case "/new":
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
}
|
|
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) };
|
|
126
140
|
case "/compact":
|
|
127
141
|
return {
|
|
128
142
|
wait: await conversation.compact(argument || undefined, BACKGROUND_CONTEXT),
|
|
@@ -136,10 +150,24 @@ export async function runCommand(options) {
|
|
|
136
150
|
return { reply: `Usage: /thinking <${thinkingLevels.join("|")}|default>` };
|
|
137
151
|
await configure({ thinkingLevel: argument });
|
|
138
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
|
+
}
|
|
139
166
|
case "/reload": {
|
|
140
167
|
const current = await options.reload();
|
|
141
168
|
await configure({ model: current.model });
|
|
142
|
-
|
|
169
|
+
// Reply in the /status format so the applied model and run are visible at once.
|
|
170
|
+
return { reply: await status(true) };
|
|
143
171
|
}
|
|
144
172
|
case "/run": {
|
|
145
173
|
const duration = parseDuration(rest[0] ?? "");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const REASON_LIMIT = 200;
|
|
2
|
+
const failedAssistant = (message) => message.role === "assistant" && message.stopReason === "error";
|
|
3
|
+
/**
|
|
4
|
+
* Service-log error lines for failures that break normal operation (ADR 0053). Durable keeps a failed
|
|
5
|
+
* task only in its records, so without this an exhausted provider quota leaves stdout silent while the
|
|
6
|
+
* process, port and HTTP checks look healthy. Each error kind is one branch here; the first is a model
|
|
7
|
+
* call that ends failed, which leaves the chat or scheduled job without the answer it was waiting for.
|
|
8
|
+
*/
|
|
9
|
+
export function logErrors(harness) {
|
|
10
|
+
return harness.subscribeCommits(({ changes }) => {
|
|
11
|
+
for (const change of changes) {
|
|
12
|
+
if (change.type !== "task" || change.value.kind !== "pi.generation")
|
|
13
|
+
continue;
|
|
14
|
+
const task = change.value;
|
|
15
|
+
if (task.state.status !== "terminal")
|
|
16
|
+
continue;
|
|
17
|
+
const { outcome } = task.state;
|
|
18
|
+
if (outcome.status === "completed" || outcome.status === "aborted")
|
|
19
|
+
continue;
|
|
20
|
+
// The failing assistant message is committed together with the task and names provider and model.
|
|
21
|
+
const failed = changes
|
|
22
|
+
.flatMap((item) => item.type === "entry" && item.value.conversationId === task.conversationId
|
|
23
|
+
? (item.value.model ?? [])
|
|
24
|
+
: [])
|
|
25
|
+
.find(failedAssistant);
|
|
26
|
+
console.error(new Date().toISOString(), "Model call failed", {
|
|
27
|
+
kind: task.kind,
|
|
28
|
+
task: task.id,
|
|
29
|
+
conversation: task.conversationId,
|
|
30
|
+
status: outcome.status,
|
|
31
|
+
provider: failed?.provider,
|
|
32
|
+
model: failed?.model,
|
|
33
|
+
reason: (outcome.error?.message ?? outcome.reason ?? "").slice(0, REASON_LIMIT),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
package/dist/agent/health.js
CHANGED
|
@@ -6,6 +6,7 @@ export const Sessions = defineDoc({
|
|
|
6
6
|
version: 1,
|
|
7
7
|
scope: "session",
|
|
8
8
|
initial: () => ({ items: [] }),
|
|
9
|
+
checkpointWhen: () => false,
|
|
9
10
|
});
|
|
10
11
|
/** Explicit health checks reuse one tool-less conversation and never deliver to a chat. */
|
|
11
12
|
export async function submitHealth(harness, defaults) {
|
package/dist/agent/progress.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
1
5
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
6
|
import { isReplyEnabled, readSettings } from "../config/settings.js";
|
|
3
|
-
import { Chats } from "./chats.js";
|
|
7
|
+
import { Chats, progressEnabled } from "./chats.js";
|
|
4
8
|
import { takeScheduledCompaction } from "./compaction.js";
|
|
5
9
|
import { deliverDirect } from "./direct-send.js";
|
|
6
10
|
import { argumentSummary, commandPreview, formatProgress } from "./progress-format.js";
|
|
@@ -10,6 +14,9 @@ async function calledTool(harness, task) {
|
|
|
10
14
|
return;
|
|
11
15
|
// The registered native task owns this input shape and its branded assistant ID.
|
|
12
16
|
const input = task.input;
|
|
17
|
+
// Nested calls belong to a parent tool; only model-issued calls get progress notices.
|
|
18
|
+
if (input.kind !== "model")
|
|
19
|
+
return;
|
|
13
20
|
const entry = await harness.commit((tx) => tx.entry(input.assistant), BACKGROUND_CONTEXT);
|
|
14
21
|
const message = entry?.model?.[0];
|
|
15
22
|
if (message?.role !== "assistant")
|
|
@@ -17,6 +24,33 @@ async function calledTool(harness, task) {
|
|
|
17
24
|
const call = message.content.find((part) => part.type === "toolCall" && part.id === input.callId);
|
|
18
25
|
return call?.type === "toolCall" ? call : undefined;
|
|
19
26
|
}
|
|
27
|
+
async function canonicalPath(path) {
|
|
28
|
+
return realpath(path).catch(async () => resolve(await realpath(dirname(path)).catch(() => dirname(path)), basename(path)));
|
|
29
|
+
}
|
|
30
|
+
/** Classify metadata only; attachment reads still execute and keep their normal results/history. */
|
|
31
|
+
async function attachmentPath(workingDir, path) {
|
|
32
|
+
if (typeof path !== "string")
|
|
33
|
+
return false;
|
|
34
|
+
// Match the native read tool's @, Unicode-space and home-path normalization.
|
|
35
|
+
let normalized = path.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
|
36
|
+
if (normalized.startsWith("@"))
|
|
37
|
+
normalized = normalized.slice(1);
|
|
38
|
+
if (normalized === "~" || normalized.startsWith("~/"))
|
|
39
|
+
normalized = resolve(homedir(), normalized.slice(2));
|
|
40
|
+
if (normalized.startsWith("file://")) {
|
|
41
|
+
try {
|
|
42
|
+
normalized = fileURLToPath(normalized);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* Keep malformed URLs as ordinary paths, like native read. */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const root = resolve(workingDir, "attachments");
|
|
49
|
+
const candidate = resolve(workingDir, normalized);
|
|
50
|
+
const roots = [root, await canonicalPath(root)];
|
|
51
|
+
const candidates = [candidate, await canonicalPath(candidate)];
|
|
52
|
+
return roots.some((directory) => candidates.some((file) => file === directory || file.startsWith(`${directory}${sep}`)));
|
|
53
|
+
}
|
|
20
54
|
/** Observe native admission and settlement; Durable owns execution and recovery. */
|
|
21
55
|
export async function startProgress(harness, workingDir, sender) {
|
|
22
56
|
// Existing live work belongs to the previous startup; do not replay historical progress.
|
|
@@ -27,9 +61,10 @@ export async function startProgress(harness, workingDir, sender) {
|
|
|
27
61
|
if (task.kind === "pi.compaction" && (await takeScheduledCompaction(harness, task.id)))
|
|
28
62
|
return;
|
|
29
63
|
const settings = await readSettings(workingDir);
|
|
30
|
-
if (!settings.progressMessages)
|
|
31
|
-
return;
|
|
32
64
|
const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
|
|
65
|
+
// Nothing can be enabled when the global setting is off and no chat chose /verbose on.
|
|
66
|
+
if (!settings.progressMessages && !chats.some((chat) => chat.progress))
|
|
67
|
+
return;
|
|
33
68
|
const runs = (await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items ?? [];
|
|
34
69
|
let conversationId = task.conversationId;
|
|
35
70
|
let chatGuid;
|
|
@@ -46,9 +81,11 @@ export async function startProgress(harness, workingDir, sender) {
|
|
|
46
81
|
parents.unshift(call?.name ?? owner?.kind ?? "child");
|
|
47
82
|
conversationId = conversation.owner.conversationId;
|
|
48
83
|
}
|
|
49
|
-
if (!isReplyEnabled(settings, chatGuid))
|
|
84
|
+
if (!isReplyEnabled(settings, chatGuid) || !progressEnabled(settings, chats, chatGuid))
|
|
50
85
|
return;
|
|
51
86
|
const tool = await calledTool(harness, task);
|
|
87
|
+
if (tool?.name === "read" && (await attachmentPath(workingDir, tool.arguments.path)))
|
|
88
|
+
return;
|
|
52
89
|
const request = tool
|
|
53
90
|
? {
|
|
54
91
|
phase: "request",
|
|
@@ -71,7 +108,8 @@ export async function startProgress(harness, workingDir, sender) {
|
|
|
71
108
|
if (!notice.chatGuid || !notice.request || task.state.status !== "terminal")
|
|
72
109
|
return;
|
|
73
110
|
const settings = await readSettings(workingDir);
|
|
74
|
-
|
|
111
|
+
const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
|
|
112
|
+
if (!isReplyEnabled(settings, notice.chatGuid) || !progressEnabled(settings, chats, notice.chatGuid))
|
|
75
113
|
return;
|
|
76
114
|
const outcome = task.state.outcome;
|
|
77
115
|
let success = outcome.status === "completed";
|
package/dist/agent/replies.js
CHANGED
package/dist/agent/run.js
CHANGED
|
@@ -9,6 +9,7 @@ export const Runs = defineDoc({
|
|
|
9
9
|
version: 1,
|
|
10
10
|
scope: "session",
|
|
11
11
|
initial: () => ({ items: [] }),
|
|
12
|
+
checkpointWhen: () => false,
|
|
12
13
|
});
|
|
13
14
|
/** Model-visible notes in the chat conversation about what happened in a run. */
|
|
14
15
|
export const RUN_RECORD = "imessage.run-record";
|
|
@@ -103,7 +104,7 @@ export const RunExtension = defineExtension({
|
|
|
103
104
|
if (run)
|
|
104
105
|
run.endReason = args.reason;
|
|
105
106
|
}, context);
|
|
106
|
-
return {
|
|
107
|
+
return { output: [{ type: "text", text: "The run ends after this answer." }] };
|
|
107
108
|
},
|
|
108
109
|
}),
|
|
109
110
|
],
|
package/dist/agent/scheduling.js
CHANGED
|
@@ -11,12 +11,14 @@ export const Schedules = defineDoc({
|
|
|
11
11
|
version: 1,
|
|
12
12
|
scope: "session",
|
|
13
13
|
initial: () => ({ items: [] }),
|
|
14
|
+
checkpointWhen: () => false,
|
|
14
15
|
});
|
|
15
16
|
export const RunRequests = defineDoc({
|
|
16
17
|
kind: "imessage.schedule-requests",
|
|
17
18
|
version: 1,
|
|
18
19
|
scope: "session",
|
|
19
20
|
initial: () => ({ items: [] }),
|
|
21
|
+
checkpointWhen: () => false,
|
|
20
22
|
});
|
|
21
23
|
function nextAt(schedule, previous, now) {
|
|
22
24
|
if (schedule.time !== undefined)
|
|
@@ -91,13 +93,12 @@ export function schedulingExtension(harness, defaults) {
|
|
|
91
93
|
phases: {
|
|
92
94
|
async sleep(task, runtime, context) {
|
|
93
95
|
const { at } = task.state.checkpoint;
|
|
94
|
-
|
|
96
|
+
// A sleeping task resumes this phase at the same checkpoint once it is due.
|
|
97
|
+
if (runtime.now() < at)
|
|
98
|
+
return runtime.sleepUntil(at, task.state.checkpoint, context);
|
|
95
99
|
// Due-now work must not start between the configuration commit and registry publication.
|
|
96
|
-
if (!admissionReady)
|
|
97
|
-
|
|
98
|
-
await runtime.commit(() => ({ status: "running", checkpoint: task.state.checkpoint }), context);
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
100
|
+
if (!admissionReady)
|
|
101
|
+
return runtime.sleepUntil(runtime.now() + 100, task.state.checkpoint, context);
|
|
101
102
|
await runtime.commit(async (tx) => {
|
|
102
103
|
const schedule = (await tx.doc(Schedules)).items.find((item) => item.id === task.input.jobId);
|
|
103
104
|
if (!schedule)
|
|
@@ -114,7 +115,7 @@ export function schedulingExtension(harness, defaults) {
|
|
|
114
115
|
if (!task.input.requestId && beforeToday)
|
|
115
116
|
return { status: "running", checkpoint: { phase: "sleep", at: nextAt(schedule, at, now) } };
|
|
116
117
|
// All triggers of a job share its non-overlap boundary.
|
|
117
|
-
for (const status of ["pending", "running", "waiting", "completing"]) {
|
|
118
|
+
for (const status of ["pending", "running", "waiting", "sleeping", "completing"]) {
|
|
118
119
|
const existing = (await tx.scanTasks({ conversationId: schedule.conversationId, kind: EXECUTION_KIND, status }, 1)).items[0];
|
|
119
120
|
if (existing)
|
|
120
121
|
return {
|
|
@@ -312,7 +312,7 @@ export async function createBrowser(workingDir, agentDir) {
|
|
|
312
312
|
executionMode: "sequential",
|
|
313
313
|
async execute(args, api, context) {
|
|
314
314
|
return {
|
|
315
|
-
|
|
315
|
+
output: [{ type: "text", text: await run(api.conversationId, args, context.abortSignal) }],
|
|
316
316
|
};
|
|
317
317
|
},
|
|
318
318
|
}),
|
|
@@ -15,7 +15,7 @@ async function memoryCli(workingDir, args, signal) {
|
|
|
15
15
|
});
|
|
16
16
|
return stdout;
|
|
17
17
|
}
|
|
18
|
-
const text = (value) => ({
|
|
18
|
+
const text = (value) => ({ output: [{ type: "text", text: value }] });
|
|
19
19
|
/** Structured memory tools; the namespace list and core memory are read when the extension is built. */
|
|
20
20
|
export async function memoryExtension(workingDir) {
|
|
21
21
|
const namespaces = await memoryCli(workingDir, ["list-namespaces"])
|
|
@@ -25,7 +25,7 @@ export function messageExtension(messaging) {
|
|
|
25
25
|
text: args.text,
|
|
26
26
|
filePath: args.filePath,
|
|
27
27
|
});
|
|
28
|
-
return {
|
|
28
|
+
return { output: [{ type: "text", text: JSON.stringify(receipt) }] };
|
|
29
29
|
},
|
|
30
30
|
}),
|
|
31
31
|
],
|
|
@@ -19,7 +19,7 @@ export const ImageRead = defineExtension({
|
|
|
19
19
|
throw new Error("No image filesystem");
|
|
20
20
|
const path = getOrThrow(await api.env.canonicalPath(args.path, context));
|
|
21
21
|
return {
|
|
22
|
-
|
|
22
|
+
output: [{ type: "text", text: imagePrefix + JSON.stringify(path) }],
|
|
23
23
|
details: { imagePath: path },
|
|
24
24
|
};
|
|
25
25
|
},
|