@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.
- package/README.md +93 -127
- package/dist/agent/behavior.js +79 -0
- package/dist/agent/chats.js +88 -0
- package/dist/agent/commands.js +191 -0
- package/dist/agent/compaction.js +51 -0
- package/dist/agent/deliver.js +139 -0
- package/dist/agent/direct-send.js +69 -0
- package/dist/agent/harness.js +79 -0
- package/dist/agent/health.js +33 -0
- package/dist/agent/models.js +69 -0
- package/dist/agent/progress-format.js +87 -0
- package/dist/agent/progress.js +173 -0
- package/dist/agent/prompt.js +79 -0
- package/dist/agent/replies.js +10 -0
- package/dist/agent/run.js +160 -0
- package/dist/agent/scheduling.js +291 -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/restart.js +100 -0
- package/dist/extensions/schedules.js +42 -0
- package/dist/extensions/subagent.js +54 -0
- package/dist/extensions/web.js +100 -0
- package/dist/extensions/workspace.js +98 -0
- package/dist/main.js +578 -88
- 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 +826 -0
- package/dist/web/history.js +87 -0
- package/dist/web/memory.js +81 -0
- package/dist/web/page.js +29 -0
- package/dist/web/schedules.js +63 -0
- package/dist/web/server.js +382 -0
- package/dist/web/style.css +577 -0
- package/dist/web/tasks.js +97 -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/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/README.md +54 -0
- package/docs/api.md +104 -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 +250 -0
- package/ops/preflight.mjs +28 -0
- package/package.json +36 -37
- package/dist/agent.d.ts +0 -84
- package/dist/agent.d.ts.map +0 -1
- package/dist/agent.js +0 -780
- 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 -69
- 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/reminders.d.ts +0 -42
- package/dist/reminders.d.ts.map +0 -1
- package/dist/reminders.js +0 -207
- package/dist/reminders.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 -25
- package/dist/web/index.d.ts.map +0 -1
- package/dist/web/index.js +0 -335
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { AssistantEntry, } from "@earendil-works/pi-durable";
|
|
3
|
+
import { finalReplyText } from "../extensions/final-text.js";
|
|
4
|
+
import { Chats } from "./chats.js";
|
|
5
|
+
import { DirectSends } from "./direct-send.js";
|
|
6
|
+
import { Deliveries } from "./replies.js";
|
|
7
|
+
import { RUN_RECORD, Runs, runRecord } from "./run.js";
|
|
8
|
+
async function targets(harness) {
|
|
9
|
+
const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
|
|
10
|
+
const runs = (await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items ?? [];
|
|
11
|
+
return [
|
|
12
|
+
...chats,
|
|
13
|
+
...runs.map((run) => ({
|
|
14
|
+
conversationId: run.conversationId,
|
|
15
|
+
chatGuid: run.chatGuid,
|
|
16
|
+
chat: run.chat,
|
|
17
|
+
label: "/run reply",
|
|
18
|
+
})),
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
export async function recoverSending(harness) {
|
|
22
|
+
const directUnknown = await harness.commit(async (tx) => {
|
|
23
|
+
const sends = await tx.doc(DirectSends);
|
|
24
|
+
const changed = [];
|
|
25
|
+
for (const receipt of sends.requests) {
|
|
26
|
+
for (const part of ["textStatus", "fileStatus"]) {
|
|
27
|
+
if (receipt[part] !== "sending")
|
|
28
|
+
continue;
|
|
29
|
+
receipt[part] = "unknown";
|
|
30
|
+
changed.push({ chatGuid: receipt.chatGuid, requestId: receipt.requestId, part });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return changed;
|
|
34
|
+
}, BACKGROUND_CONTEXT);
|
|
35
|
+
for (const { chatGuid, requestId, part } of directUnknown)
|
|
36
|
+
console.warn("Direct delivery unknown", chatGuid, requestId, part);
|
|
37
|
+
for (const { conversationId } of await targets(harness)) {
|
|
38
|
+
const unknown = await harness.commit(async (tx) => {
|
|
39
|
+
const deliveries = await tx.doc(Deliveries, conversationId);
|
|
40
|
+
const changed = [];
|
|
41
|
+
for (const [answerId, status] of Object.entries(deliveries.answers)) {
|
|
42
|
+
if (status === "sending") {
|
|
43
|
+
deliveries.answers[answerId] = "unknown";
|
|
44
|
+
changed.push(answerId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return changed;
|
|
48
|
+
}, BACKGROUND_CONTEXT);
|
|
49
|
+
for (const answerId of unknown)
|
|
50
|
+
console.warn("Reply delivery unknown", conversationId, answerId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Returns false when the answer must wait for an earlier in-flight send. */
|
|
54
|
+
async function deliverAnswer(harness, storage, target, answerId, send) {
|
|
55
|
+
const { conversationId } = target;
|
|
56
|
+
const claim = await harness.commit(async (tx) => {
|
|
57
|
+
const entry = await tx.entry(AssistantEntry, answerId);
|
|
58
|
+
const text = finalReplyText(entry?.model?.[0]);
|
|
59
|
+
const deliveries = await tx.doc(Deliveries, conversationId);
|
|
60
|
+
const key = String(answerId);
|
|
61
|
+
const decided = () => {
|
|
62
|
+
if (deliveries.scanned === undefined || deliveries.scanned < answerId)
|
|
63
|
+
deliveries.scanned = answerId;
|
|
64
|
+
return true;
|
|
65
|
+
};
|
|
66
|
+
if (!text || deliveries.answers[key] || deliveries.drafts?.[key])
|
|
67
|
+
return { decided: decided() };
|
|
68
|
+
// The owning Session serializes admission with this claim. Read committed native submissions,
|
|
69
|
+
// not arbitrary user entries: passive logs and /run continuation text are not new human input.
|
|
70
|
+
let intervening = false;
|
|
71
|
+
let cursor;
|
|
72
|
+
do {
|
|
73
|
+
const page = await storage.scanSubmissions({ conversationId }, 100, cursor, BACKGROUND_CONTEXT);
|
|
74
|
+
intervening = page.items.some((input) => input.type === "input" &&
|
|
75
|
+
(input.status === "queued" || (input.entry !== undefined && input.entry > answerId)));
|
|
76
|
+
cursor = page.next;
|
|
77
|
+
} while (cursor && !intervening);
|
|
78
|
+
if (intervening) {
|
|
79
|
+
deliveries.drafts ??= {};
|
|
80
|
+
deliveries.drafts[key] = { status: "held", reason: "newer_input" };
|
|
81
|
+
return { decided: decided() };
|
|
82
|
+
}
|
|
83
|
+
// Do not overtake an earlier in-flight send in this chat.
|
|
84
|
+
if (Object.values(deliveries.answers).includes("sending"))
|
|
85
|
+
return { decided: false };
|
|
86
|
+
// Finals are claimed in order, so the only earlier unsent ones are held drafts; this answer replaces them.
|
|
87
|
+
const drafts = deliveries.drafts ?? {};
|
|
88
|
+
for (const [previous, draft] of Object.entries(drafts))
|
|
89
|
+
if (draft.status === "held" && Number(previous) < answerId)
|
|
90
|
+
drafts[previous] = { status: "superseded", replacement: answerId };
|
|
91
|
+
deliveries.answers[key] = "sending";
|
|
92
|
+
return { decided: decided(), text };
|
|
93
|
+
}, BACKGROUND_CONTEXT);
|
|
94
|
+
if (!claim.text)
|
|
95
|
+
return claim.decided;
|
|
96
|
+
let status = "sent";
|
|
97
|
+
try {
|
|
98
|
+
await send(target.chatGuid, claim.text);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// A throwing transport may already have sent. Never retry it automatically.
|
|
102
|
+
status = "unknown";
|
|
103
|
+
}
|
|
104
|
+
await harness.commit(async (tx) => {
|
|
105
|
+
(await tx.doc(Deliveries, conversationId)).answers[String(answerId)] = status;
|
|
106
|
+
}, BACKGROUND_CONTEXT);
|
|
107
|
+
if (status === "unknown")
|
|
108
|
+
console.warn("Reply delivery unknown", conversationId, String(answerId));
|
|
109
|
+
if (target.chat !== undefined) {
|
|
110
|
+
// A queued write never splits a turn that is running in the chat conversation.
|
|
111
|
+
const chat = await harness.conversation(target.chat, BACKGROUND_CONTEXT);
|
|
112
|
+
await chat?.submit({
|
|
113
|
+
type: "write",
|
|
114
|
+
requestId: `${RUN_RECORD}:${answerId}`,
|
|
115
|
+
entry: runRecord(`[${target.label}, ${status}]\n${claim.text}`, Date.now()),
|
|
116
|
+
}, BACKGROUND_CONTEXT);
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/** Sends committed final answers in order, including those a /run writes between continuations. */
|
|
121
|
+
export async function deliverReplies(harness, storage, send, enabled) {
|
|
122
|
+
for (const target of await targets(harness)) {
|
|
123
|
+
if (!enabled(target.chatGuid))
|
|
124
|
+
continue;
|
|
125
|
+
const scanned = (await harness.snapshot(Deliveries, target.conversationId, BACKGROUND_CONTEXT))?.scanned;
|
|
126
|
+
const answers = [];
|
|
127
|
+
let cursor;
|
|
128
|
+
do {
|
|
129
|
+
const page = await storage.scanEntries({ conversationId: target.conversationId, ...(scanned === undefined ? {} : { minEntryId: scanned }) }, 100, cursor, BACKGROUND_CONTEXT);
|
|
130
|
+
for (const entry of page.items)
|
|
131
|
+
if (AssistantEntry.is(entry) && finalReplyText(entry.model?.[0]))
|
|
132
|
+
answers.push(entry.id);
|
|
133
|
+
cursor = page.next;
|
|
134
|
+
} while (cursor);
|
|
135
|
+
for (const answerId of answers.reverse())
|
|
136
|
+
if (!(await deliverAnswer(harness, storage, target, answerId, send)))
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { defineDoc } from "@earendil-works/pi-durable";
|
|
3
|
+
/** Receipts only: no background worker or automatic retry of explicit sends. */
|
|
4
|
+
export const DirectSends = defineDoc({
|
|
5
|
+
kind: "imessage.direct-sends",
|
|
6
|
+
version: 1,
|
|
7
|
+
scope: "session",
|
|
8
|
+
initial: () => ({ requests: [] }),
|
|
9
|
+
checkpointWhen: () => false,
|
|
10
|
+
});
|
|
11
|
+
export async function deliverDirect(harness, input, sender) {
|
|
12
|
+
const { chatGuid, requestId } = input;
|
|
13
|
+
const text = input.text || null;
|
|
14
|
+
const filePath = input.filePath || null;
|
|
15
|
+
if (!chatGuid.trim() || !requestId.trim() || (!text && !filePath))
|
|
16
|
+
throw new Error("Chat GUID, request ID and text or file path are required");
|
|
17
|
+
const accepted = await harness.commit(async (tx) => {
|
|
18
|
+
const sends = await tx.doc(DirectSends);
|
|
19
|
+
const existing = sends.requests.find((send) => send.chatGuid === chatGuid && send.requestId === requestId);
|
|
20
|
+
if (existing) {
|
|
21
|
+
if (existing.text !== text || existing.filePath !== filePath)
|
|
22
|
+
throw new Error("Send request ID was already used with different content");
|
|
23
|
+
return { claimed: false, receipt: { ...existing } };
|
|
24
|
+
}
|
|
25
|
+
const receipt = {
|
|
26
|
+
chatGuid,
|
|
27
|
+
requestId,
|
|
28
|
+
text,
|
|
29
|
+
filePath,
|
|
30
|
+
textStatus: text ? "not_attempted" : null,
|
|
31
|
+
fileStatus: filePath ? "not_attempted" : null,
|
|
32
|
+
};
|
|
33
|
+
sends.requests.push(receipt);
|
|
34
|
+
return { claimed: true, receipt: { ...receipt } };
|
|
35
|
+
}, BACKGROUND_CONTEXT);
|
|
36
|
+
if (!accepted.claimed)
|
|
37
|
+
return accepted.receipt;
|
|
38
|
+
let receipt = accepted.receipt;
|
|
39
|
+
async function setStatus(part, status) {
|
|
40
|
+
receipt = await harness.commit(async (tx) => {
|
|
41
|
+
const sends = await tx.doc(DirectSends);
|
|
42
|
+
const record = sends.requests.find((send) => send.chatGuid === chatGuid && send.requestId === requestId);
|
|
43
|
+
if (!record)
|
|
44
|
+
throw new Error("Direct send receipt is missing");
|
|
45
|
+
record[part] = status;
|
|
46
|
+
return { ...record };
|
|
47
|
+
}, BACKGROUND_CONTEXT);
|
|
48
|
+
}
|
|
49
|
+
async function attempt(part, send) {
|
|
50
|
+
await setStatus(part, "sending");
|
|
51
|
+
let status = "sent";
|
|
52
|
+
try {
|
|
53
|
+
await send();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
status = "unknown";
|
|
57
|
+
}
|
|
58
|
+
await setStatus(part, status);
|
|
59
|
+
if (status === "unknown")
|
|
60
|
+
console.warn("Direct delivery unknown", chatGuid, requestId, part);
|
|
61
|
+
return status;
|
|
62
|
+
}
|
|
63
|
+
// Explicit sends are plain text, independent of automatic reply settings.
|
|
64
|
+
if (text && (await attempt("textStatus", () => sender.sendMessage(chatGuid, text))) !== "sent")
|
|
65
|
+
return receipt;
|
|
66
|
+
if (filePath)
|
|
67
|
+
await attempt("fileStatus", () => sender.sendAttachment(chatGuid, filePath));
|
|
68
|
+
return receipt;
|
|
69
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { mkdir, open, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
4
|
+
import { createRegistry, Harness } from "@earendil-works/pi-durable";
|
|
5
|
+
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
|
6
|
+
import { openNodeJsonlStorage } from "@earendil-works/pi-durable/storage/jsonl/node";
|
|
7
|
+
import { chatBehavior } from "./behavior.js";
|
|
8
|
+
import { RunExtension } from "./run.js";
|
|
9
|
+
export async function openHarness(workingDir, models, extensions, policy) {
|
|
10
|
+
const directory = join(workingDir, "durable");
|
|
11
|
+
await mkdir(directory, { recursive: true });
|
|
12
|
+
const lockPath = join(directory, "owner.lock");
|
|
13
|
+
// Never steal a lock: after an unclean exit the operator must confirm its owner is dead.
|
|
14
|
+
const lock = await open(lockPath, "wx");
|
|
15
|
+
try {
|
|
16
|
+
await lock.writeFile(`${process.pid}\n`);
|
|
17
|
+
const storage = await openNodeJsonlStorage(directory, BACKGROUND_CONTEXT, { fsync: true });
|
|
18
|
+
try {
|
|
19
|
+
const registry = createRegistry();
|
|
20
|
+
registry.install(chatBehavior(storage));
|
|
21
|
+
registry.install(RunExtension);
|
|
22
|
+
for (const extension of extensions) {
|
|
23
|
+
if (registry.snapshot().extension(extension.name))
|
|
24
|
+
throw new Error(`Duplicate extension name: ${extension.name}`);
|
|
25
|
+
registry.install(extension);
|
|
26
|
+
}
|
|
27
|
+
const env = new NodeExecutionEnv({ cwd: workingDir });
|
|
28
|
+
const harness = await Harness.open(storage,
|
|
29
|
+
// Large Codex contexts exceed WebSocket frame limits; force SSE.
|
|
30
|
+
{
|
|
31
|
+
models,
|
|
32
|
+
registry,
|
|
33
|
+
env: () => env,
|
|
34
|
+
// Consume all admitted corrections at the next native boundary, not one per turn.
|
|
35
|
+
settings: {
|
|
36
|
+
get stream() {
|
|
37
|
+
return { ...policy.stream, transport: "sse" };
|
|
38
|
+
},
|
|
39
|
+
get retry() {
|
|
40
|
+
return policy.retry;
|
|
41
|
+
},
|
|
42
|
+
steeringMode: "all",
|
|
43
|
+
// Only run conversations select the run extension; reread so reinstalls apply.
|
|
44
|
+
get extensions() {
|
|
45
|
+
return registry
|
|
46
|
+
.snapshot()
|
|
47
|
+
.installed()
|
|
48
|
+
.filter((extension) => extension.name !== RunExtension.name);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}, BACKGROUND_CONTEXT);
|
|
52
|
+
let closing;
|
|
53
|
+
return {
|
|
54
|
+
harness,
|
|
55
|
+
storage,
|
|
56
|
+
registry,
|
|
57
|
+
close(beforeUnlock) {
|
|
58
|
+
closing ??= (async () => {
|
|
59
|
+
await harness.close(BACKGROUND_CONTEXT);
|
|
60
|
+
// External browser resources must be closed before another owner can resume this workspace.
|
|
61
|
+
await beforeUnlock?.();
|
|
62
|
+
await lock.close();
|
|
63
|
+
await unlink(lockPath);
|
|
64
|
+
})();
|
|
65
|
+
return closing;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
await storage.close(BACKGROUND_CONTEXT);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
await lock.close();
|
|
76
|
+
await unlink(lockPath);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { configure, defineDoc } from "@earendil-works/pi-durable";
|
|
3
|
+
/** Retained metadata for read-only inspection of historical isolated conversations. */
|
|
4
|
+
export const Sessions = defineDoc({
|
|
5
|
+
kind: "imessage.sessions",
|
|
6
|
+
version: 1,
|
|
7
|
+
scope: "session",
|
|
8
|
+
initial: () => ({ items: [] }),
|
|
9
|
+
checkpointWhen: () => false,
|
|
10
|
+
});
|
|
11
|
+
/** Explicit health checks reuse one tool-less conversation and never deliver to a chat. */
|
|
12
|
+
export async function submitHealth(harness, defaults) {
|
|
13
|
+
const key = JSON.stringify(["health", "model-health"]);
|
|
14
|
+
const id = await harness.commit(async (tx) => {
|
|
15
|
+
const sessions = await tx.doc(Sessions);
|
|
16
|
+
const found = sessions.items.find((item) => item.key === key);
|
|
17
|
+
const id = found?.conversationId ?? (await tx.createConversation({ ownership: { kind: "ownerless" } })).id;
|
|
18
|
+
await configure(tx, id, { ...defaults, tools: [], instructions: "Reply OK. Do not use tools." });
|
|
19
|
+
if (!found)
|
|
20
|
+
sessions.items.push({
|
|
21
|
+
key,
|
|
22
|
+
label: "model-health",
|
|
23
|
+
conversationId: id,
|
|
24
|
+
chatGuid: "health",
|
|
25
|
+
deliver: false,
|
|
26
|
+
});
|
|
27
|
+
return id;
|
|
28
|
+
}, BACKGROUND_CONTEXT);
|
|
29
|
+
const conversation = await harness.conversation(id, BACKGROUND_CONTEXT);
|
|
30
|
+
if (!conversation)
|
|
31
|
+
throw new Error("Model health conversation missing");
|
|
32
|
+
return conversation.submit({ type: "input", content: "Reply OK.", requestId: crypto.randomUUID(), whenBusy: "followUp" }, BACKGROUND_CONTEXT);
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
export function openModels(agentDir) {
|
|
4
|
+
return ModelRuntime.create({
|
|
5
|
+
authPath: join(agentDir, "auth.json"),
|
|
6
|
+
modelsPath: join(agentDir, "models.json"),
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
/** Read defaults and use only an explicitly configured, available fallback. */
|
|
10
|
+
export async function readDefaults(runtime, workingDir, agentDir, policy = {}) {
|
|
11
|
+
const settings = SettingsManager.create(workingDir, agentDir);
|
|
12
|
+
const provider = settings.getDefaultProvider();
|
|
13
|
+
const modelId = settings.getDefaultModel();
|
|
14
|
+
const thinkingLevel = settings.getDefaultThinkingLevel();
|
|
15
|
+
await runtime.refresh();
|
|
16
|
+
if (provider && modelId && runtime.getModel(provider, modelId) && runtime.hasConfiguredAuth(provider))
|
|
17
|
+
return { model: { provider, modelId }, thinkingLevel };
|
|
18
|
+
const fallback = policy.fallback;
|
|
19
|
+
if (!fallback ||
|
|
20
|
+
!runtime.getModel(fallback.provider, fallback.modelId) ||
|
|
21
|
+
!runtime.hasConfiguredAuth(fallback.provider))
|
|
22
|
+
throw new Error(`Default model ${provider}/${modelId} is unavailable; no available configured fallback`);
|
|
23
|
+
console.warn(`Default model ${provider}/${modelId} unavailable; using ${fallback.provider}/${fallback.modelId}`);
|
|
24
|
+
return { model: { ...fallback }, thinkingLevel };
|
|
25
|
+
}
|
|
26
|
+
/** Match Pi's experimental Durable request policy; execution and retries remain native. */
|
|
27
|
+
export function requestSettings(settings) {
|
|
28
|
+
return {
|
|
29
|
+
get stream() {
|
|
30
|
+
const provider = settings.getProviderRetrySettings();
|
|
31
|
+
const idle = settings.getHttpIdleTimeoutMs();
|
|
32
|
+
return {
|
|
33
|
+
timeoutMs: provider.timeoutMs ?? (idle === 0 ? 2_147_483_647 : idle),
|
|
34
|
+
maxRetryDelayMs: provider.maxRetryDelayMs,
|
|
35
|
+
...(provider.maxRetries === undefined ? {} : { maxRetries: provider.maxRetries }),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
get retry() {
|
|
39
|
+
return settings.getRetrySettings();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** A live configuration getter applies tier changes to subsequent requests only. */
|
|
44
|
+
export function withModelPolicy(models, policy) {
|
|
45
|
+
return new Proxy(models, {
|
|
46
|
+
get(target, key) {
|
|
47
|
+
if (key === "streamSimple") {
|
|
48
|
+
const streamSimple = (model, context, options) => {
|
|
49
|
+
const tier = policy().codexServiceTier;
|
|
50
|
+
if (!tier || model.provider !== "openai-codex" || !/^gpt-/.test(model.id))
|
|
51
|
+
return target.streamSimple(model, context, options);
|
|
52
|
+
const configured = {
|
|
53
|
+
...options,
|
|
54
|
+
async onPayload(payload, payloadModel) {
|
|
55
|
+
const current = (await options?.onPayload?.(payload, payloadModel)) ?? payload;
|
|
56
|
+
if (typeof current !== "object" || current === null || Array.isArray(current))
|
|
57
|
+
return current;
|
|
58
|
+
return { ...current, service_tier: tier };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return target.streamSimple(model, context, configured);
|
|
62
|
+
};
|
|
63
|
+
return streamSimple;
|
|
64
|
+
}
|
|
65
|
+
const value = Reflect.get(target, key, target);
|
|
66
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** The plain-text adapter; apps can render the same fields without parsing this text. */
|
|
2
|
+
export function formatProgress(event) {
|
|
3
|
+
const prefix = event.parents.length ? `${event.parents.join("/")}/` : "";
|
|
4
|
+
const header = `[${prefix}${event.type}]`;
|
|
5
|
+
if (event.phase === "response") {
|
|
6
|
+
return `${event.success ? "✓" : "×"} ${header} ${event.name} (${(event.elapsedMs / 1000).toFixed(1)}s)`;
|
|
7
|
+
}
|
|
8
|
+
const summary = event.type === "tool" ? (event.name === "bash" ? event.commandPreview : event.summary) : undefined;
|
|
9
|
+
return `→ ${header} ${event.name}${summary ? `: ${summary}` : ""}`;
|
|
10
|
+
}
|
|
11
|
+
/** Screen the whole command before showing a bounded, non-clickable preview. */
|
|
12
|
+
export function commandPreview(command) {
|
|
13
|
+
if (typeof command !== "string")
|
|
14
|
+
return "[command hidden]";
|
|
15
|
+
// Match a complete shell word, including concatenated quotes and escaped spaces.
|
|
16
|
+
const word = String.raw `(?:"(?:\\[\s\S]|[^"\\])*(?:"|$)|'[^']*(?:'|$)|\$\([\s\S]*\)|\x60(?:\\[\s\S]|[^\x60\\])*\x60|\\[\s\S]|[^\s;|&<>"'\\\x60])+`;
|
|
17
|
+
const key = String.raw `[\w.-]*(?:secret|token|password|passwd|credential|authorization|api[._-]?key|private[._-]?key|bearer)[\w.-]*`;
|
|
18
|
+
let maskNext = false;
|
|
19
|
+
const screened = command
|
|
20
|
+
.replace(/\\\r?\n/g, "")
|
|
21
|
+
.replace(/-----BEGIN[^\r\n]*PRIVATE KEY-----[\s\S]*?(?:-----END[^\r\n]*PRIVATE KEY-----|$)/g, "***")
|
|
22
|
+
.replace(/\b(?:sk-[a-z0-9_-]+|gh[pousr]_[a-z0-9_]+|github_pat_[a-z0-9_]+)\b/gi, "***")
|
|
23
|
+
.replace(/(https?:\/\/)[^\s/<>]+@/gi, "$1***@")
|
|
24
|
+
.replace(new RegExp(word, "g"), (argument) => {
|
|
25
|
+
if (maskNext) {
|
|
26
|
+
maskNext = false;
|
|
27
|
+
return "***";
|
|
28
|
+
}
|
|
29
|
+
const option = argument.replace(/["']/g, "");
|
|
30
|
+
if (new RegExp(`^(?:--${key}|--user|-u|bearer)$`, "i").test(option)) {
|
|
31
|
+
maskNext = true;
|
|
32
|
+
return argument;
|
|
33
|
+
}
|
|
34
|
+
// Headers can concatenate quoted/unquoted fragments in one shell word.
|
|
35
|
+
if (/\b(?:proxy-)?authorization\s*:/i.test(argument)) {
|
|
36
|
+
const quote = /^["']/.test(argument) && argument.at(-1) === argument[0] ? argument[0] : "";
|
|
37
|
+
return argument.replace(/(\b(?:proxy-)?authorization\s*:\s*)[\s\S]*/i, "$1***") + quote;
|
|
38
|
+
}
|
|
39
|
+
// Escaped JSON is an opaque shell argument; don't expose partial values.
|
|
40
|
+
if (new RegExp(String.raw `\\["']${key}\\["']\s*:`, "i").test(argument))
|
|
41
|
+
return "***";
|
|
42
|
+
return argument
|
|
43
|
+
.replace(new RegExp(String.raw `(["']${key}["']\s*:\s*)("(?:\\[\s\S]|[^"\\])*"|'[^']*'|[^\s,}\]]+)`, "gi"), (_match, prefix, value) => `${prefix}${/^["']/.test(value) ? `${value[0]}***${value[0]}` : "***"}`)
|
|
44
|
+
.replace(new RegExp(String.raw `((?:\b${key}|--user)\s*[=:]\s*)[\s\S]*`, "gi"), "$1***")
|
|
45
|
+
.replace(/(\bbearer\s+)[\s\S]*/gi, "$1***");
|
|
46
|
+
})
|
|
47
|
+
.replace(/\s+/g, " ")
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/https?:\/\/[^\s"'`<>]+/gi, (url) => url.replaceAll(":", ":").replaceAll("/", "/").replaceAll(".", "."))
|
|
50
|
+
.replace(/\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|org|net|cn|io|dev|app|co|edu|gov|uk|us)\b/gi, (host) => host.replaceAll(".", "."));
|
|
51
|
+
const characters = Array.from(screened);
|
|
52
|
+
return characters.length > 120 ? `${characters.slice(0, 119).join("")}…` : screened;
|
|
53
|
+
}
|
|
54
|
+
/** Argument previews for non-bash tools; bash requests show only `commandPreview`. */
|
|
55
|
+
export function argumentSummary(arguments_) {
|
|
56
|
+
return Object.entries(arguments_)
|
|
57
|
+
.filter(([key]) => key !== "timeout")
|
|
58
|
+
.map(([key, value]) => {
|
|
59
|
+
if (/secret|token|password|credential|authorization|api.?key/i.test(key))
|
|
60
|
+
return `${key}=[redacted]`;
|
|
61
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
62
|
+
return `${key}=${value}`;
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
if (["path", "filePath", "cwd", "query"].includes(key)) {
|
|
65
|
+
if (/secret|token|password|credential|authorization|api.?key|sk-[a-z0-9]|gh[pousr]_|github_pat_|bearer\s|https?:\/\/\S+@/i.test(value))
|
|
66
|
+
return `${key}=[redacted]`;
|
|
67
|
+
return `${key}=${value
|
|
68
|
+
.replace(/https?:\/\/\S+/gi, "[link omitted]")
|
|
69
|
+
.replace(/[\r\n\t]/g, " ")
|
|
70
|
+
.slice(0, 160)}`;
|
|
71
|
+
}
|
|
72
|
+
if (key === "url") {
|
|
73
|
+
try {
|
|
74
|
+
const url = new URL(value);
|
|
75
|
+
return `${key}=${url.hostname.replaceAll(".", ".")}`;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return `${key}=[${value.length} chars]`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return `${key}=[${value.length} chars]`;
|
|
82
|
+
}
|
|
83
|
+
return `${key}=[${Array.isArray(value) ? `${value.length} items` : "object"}]`;
|
|
84
|
+
})
|
|
85
|
+
.join(", ")
|
|
86
|
+
.slice(0, 400);
|
|
87
|
+
}
|