@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,291 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { configure, defineDoc, defineExtension, defineTask, } from "@earendil-works/pi-durable";
|
|
3
|
+
import { ScheduledOutbox, validateSchedules } from "../extensions/schedules.js";
|
|
4
|
+
import { compactChats } from "./compaction.js";
|
|
5
|
+
import { dailyTime, localDate, nextDaily } from "./time.js";
|
|
6
|
+
export { ScheduledOutbox };
|
|
7
|
+
const SIX_HOURS = 6 * 3600000;
|
|
8
|
+
export const EXECUTION_KIND = "imessage.scheduled-execution";
|
|
9
|
+
export const Schedules = defineDoc({
|
|
10
|
+
kind: "imessage.schedules",
|
|
11
|
+
version: 1,
|
|
12
|
+
scope: "session",
|
|
13
|
+
initial: () => ({ items: [] }),
|
|
14
|
+
checkpointWhen: () => false,
|
|
15
|
+
});
|
|
16
|
+
export const RunRequests = defineDoc({
|
|
17
|
+
kind: "imessage.schedule-requests",
|
|
18
|
+
version: 1,
|
|
19
|
+
scope: "session",
|
|
20
|
+
initial: () => ({ items: [] }),
|
|
21
|
+
checkpointWhen: () => false,
|
|
22
|
+
});
|
|
23
|
+
function nextAt(schedule, previous, now) {
|
|
24
|
+
if (schedule.time !== undefined)
|
|
25
|
+
return nextDaily(schedule.time, now);
|
|
26
|
+
const interval = schedule.intervalMs ?? SIX_HOURS;
|
|
27
|
+
return previous + (Math.max(0, Math.floor((now - previous) / interval)) + 1) * interval;
|
|
28
|
+
}
|
|
29
|
+
/** Durable owns cadence and execution receipts; extensions own business tasks and documents. */
|
|
30
|
+
export function schedulingExtension(harness, defaults) {
|
|
31
|
+
let admissionReady = true;
|
|
32
|
+
const Execution = defineTask({
|
|
33
|
+
name: EXECUTION_KIND,
|
|
34
|
+
version: 2,
|
|
35
|
+
initial: () => ({ phase: "start" }),
|
|
36
|
+
phases: {
|
|
37
|
+
async start(task, runtime, context) {
|
|
38
|
+
const schedule = (await runtime.snapshot(Schedules, context))?.items.find((item) => item.id === task.input.jobId);
|
|
39
|
+
if (!schedule)
|
|
40
|
+
throw new Error("Scheduled job is missing");
|
|
41
|
+
if (schedule.id === "compact-chats") {
|
|
42
|
+
let admissionFailures = 0;
|
|
43
|
+
const tasks = await compactChats(harness, () => admissionFailures++);
|
|
44
|
+
await runtime.commit(() => ({ status: "running", checkpoint: { phase: "compact", tasks, admissionFailures } }), context);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const definition = schedule.execution && runtime.registry.task(schedule.execution);
|
|
48
|
+
if (!definition)
|
|
49
|
+
throw new Error(`Missing scheduled task definition: ${schedule.execution}`);
|
|
50
|
+
await runtime.commit(async (tx) => {
|
|
51
|
+
const execution = await tx.createTask(definition, { ...task.input, config: schedule.input ?? null, chatGuid: schedule.chatGuid ?? null }, { ownership: { kind: "task", taskId: task.id } });
|
|
52
|
+
return {
|
|
53
|
+
status: "waiting",
|
|
54
|
+
checkpoint: { phase: "execute", execution },
|
|
55
|
+
on: [execution],
|
|
56
|
+
policy: "allSettled",
|
|
57
|
+
};
|
|
58
|
+
}, context);
|
|
59
|
+
},
|
|
60
|
+
async execute(task, runtime, context) {
|
|
61
|
+
const receipt = await runtime.waitForTask(task.state.checkpoint.execution, context);
|
|
62
|
+
await runtime.commit(() => ({ status: "terminal", outcome: receipt.state.outcome }), context);
|
|
63
|
+
},
|
|
64
|
+
async compact(task, runtime, context) {
|
|
65
|
+
const receipts = await Promise.all(task.state.checkpoint.tasks.map((id) => runtime.waitForTask(id, context)));
|
|
66
|
+
const failed = task.state.checkpoint.admissionFailures +
|
|
67
|
+
receipts.filter((receipt) => receipt.state.outcome.status !== "completed").length;
|
|
68
|
+
await runtime.commit(() => ({
|
|
69
|
+
status: "terminal",
|
|
70
|
+
outcome: failed
|
|
71
|
+
? { status: "failed", error: { message: `${failed} scheduled compaction(s) failed` } }
|
|
72
|
+
: {
|
|
73
|
+
status: "completed",
|
|
74
|
+
result: {
|
|
75
|
+
summary: `${receipts.length} native compaction(s) completed; includes no-ops`,
|
|
76
|
+
finishedAt: runtime.now(),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}), context);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
async abort(_task, runtime, context) {
|
|
83
|
+
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const Scheduler = defineTask({
|
|
87
|
+
name: "imessage.schedule",
|
|
88
|
+
version: 2,
|
|
89
|
+
migrate(input, checkpoint) {
|
|
90
|
+
return { input: input, checkpoint: checkpoint };
|
|
91
|
+
},
|
|
92
|
+
initial: (input) => ({ phase: "sleep", at: input.firstAt }),
|
|
93
|
+
phases: {
|
|
94
|
+
async sleep(task, runtime, context) {
|
|
95
|
+
const { at } = task.state.checkpoint;
|
|
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);
|
|
99
|
+
// Due-now work must not start between the configuration commit and registry publication.
|
|
100
|
+
if (!admissionReady)
|
|
101
|
+
return runtime.sleepUntil(runtime.now() + 100, task.state.checkpoint, context);
|
|
102
|
+
await runtime.commit(async (tx) => {
|
|
103
|
+
const schedule = (await tx.doc(Schedules)).items.find((item) => item.id === task.input.jobId);
|
|
104
|
+
if (!schedule)
|
|
105
|
+
throw new Error("Scheduled job is missing");
|
|
106
|
+
if (task.input.requestId &&
|
|
107
|
+
!(await tx.doc(RunRequests)).items.find((item) => item.id === task.input.requestId)?.enabled)
|
|
108
|
+
return { status: "terminal", outcome: { status: "completed", result: null } };
|
|
109
|
+
const now = runtime.now();
|
|
110
|
+
const beforeToday = schedule.time !== undefined && now < dailyTime(schedule.time, now);
|
|
111
|
+
if (!schedule.enabled)
|
|
112
|
+
return task.input.requestId
|
|
113
|
+
? { status: "terminal", outcome: { status: "completed", result: null } }
|
|
114
|
+
: { status: "running", checkpoint: { phase: "sleep", at: nextAt(schedule, at, now) } };
|
|
115
|
+
if (!task.input.requestId && beforeToday)
|
|
116
|
+
return { status: "running", checkpoint: { phase: "sleep", at: nextAt(schedule, at, now) } };
|
|
117
|
+
// All triggers of a job share its non-overlap boundary.
|
|
118
|
+
for (const status of ["pending", "running", "waiting", "sleeping", "completing"]) {
|
|
119
|
+
const existing = (await tx.scanTasks({ conversationId: schedule.conversationId, kind: EXECUTION_KIND, status }, 1)).items[0];
|
|
120
|
+
if (existing)
|
|
121
|
+
return {
|
|
122
|
+
status: "waiting",
|
|
123
|
+
checkpoint: {
|
|
124
|
+
phase: task.input.requestId ? "retry" : "advance",
|
|
125
|
+
at,
|
|
126
|
+
execution: existing.id,
|
|
127
|
+
},
|
|
128
|
+
on: [existing.id],
|
|
129
|
+
policy: "allSettled",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const execution = await tx.createTask(Execution, {
|
|
133
|
+
jobId: schedule.id,
|
|
134
|
+
dueAt: at,
|
|
135
|
+
startedAt: now,
|
|
136
|
+
date: localDate(now),
|
|
137
|
+
...(task.input.requestId ? { requestId: task.input.requestId } : {}),
|
|
138
|
+
}, { ownership: { kind: "task", taskId: task.id } });
|
|
139
|
+
return {
|
|
140
|
+
status: "waiting",
|
|
141
|
+
checkpoint: { phase: "advance", at, execution },
|
|
142
|
+
on: [execution],
|
|
143
|
+
policy: "allSettled",
|
|
144
|
+
};
|
|
145
|
+
}, context);
|
|
146
|
+
},
|
|
147
|
+
async retry(task, runtime, context) {
|
|
148
|
+
await runtime.commit(() => ({ status: "running", checkpoint: { phase: "sleep", at: task.state.checkpoint.at } }), context);
|
|
149
|
+
},
|
|
150
|
+
async advance(task, runtime, context) {
|
|
151
|
+
if (task.input.requestId) {
|
|
152
|
+
await runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: null } }), context);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await runtime.commit(async (tx) => {
|
|
156
|
+
const schedule = (await tx.doc(Schedules)).items.find((item) => item.id === task.input.jobId);
|
|
157
|
+
if (!schedule)
|
|
158
|
+
throw new Error("Scheduled job is missing");
|
|
159
|
+
const execution = await tx.task(task.state.checkpoint.execution);
|
|
160
|
+
const outcome = execution?.state.outcome;
|
|
161
|
+
const result = outcome?.status === "completed" ? outcome.result : undefined;
|
|
162
|
+
const finishedAt = result &&
|
|
163
|
+
typeof result === "object" &&
|
|
164
|
+
!Array.isArray(result) &&
|
|
165
|
+
typeof result.finishedAt === "number"
|
|
166
|
+
? result.finishedAt
|
|
167
|
+
: runtime.now();
|
|
168
|
+
return {
|
|
169
|
+
status: "running",
|
|
170
|
+
checkpoint: { phase: "sleep", at: nextAt(schedule, task.state.checkpoint.at, finishedAt) },
|
|
171
|
+
};
|
|
172
|
+
}, context);
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
async abort(_task, runtime, context) {
|
|
176
|
+
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
extension: defineExtension({ name: "scheduling", tasks: [Scheduler, Execution] }),
|
|
181
|
+
/** May join a host reload commit. Existing task IDs and absolute deadlines are never reset. */
|
|
182
|
+
async initialize(extensions, transaction, publish) {
|
|
183
|
+
validateSchedules(extensions);
|
|
184
|
+
const timezone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
185
|
+
const initialize = async (tx) => {
|
|
186
|
+
const state = await tx.doc(Schedules);
|
|
187
|
+
const definitions = [
|
|
188
|
+
{
|
|
189
|
+
id: "compact-chats",
|
|
190
|
+
name: "Chat compaction",
|
|
191
|
+
kind: "compaction",
|
|
192
|
+
enabled: true,
|
|
193
|
+
intervalMs: SIX_HOURS,
|
|
194
|
+
timezone,
|
|
195
|
+
extension: "scheduling",
|
|
196
|
+
},
|
|
197
|
+
...extensions.flatMap((extension) => (extension.schedules ?? []).map(({ task, initialize, ...schedule }) => ({
|
|
198
|
+
...schedule,
|
|
199
|
+
kind: schedule.kind ?? "custom",
|
|
200
|
+
extension: extension.name,
|
|
201
|
+
execution: task,
|
|
202
|
+
timezone,
|
|
203
|
+
initialize,
|
|
204
|
+
}))),
|
|
205
|
+
];
|
|
206
|
+
for (const { initialize: initializeBusiness, ...definition } of definitions) {
|
|
207
|
+
const found = state.items.find((item) => item.id === definition.id);
|
|
208
|
+
if (found) {
|
|
209
|
+
// Clear old cadence when an extension switches between daily and interval scheduling.
|
|
210
|
+
delete found.time;
|
|
211
|
+
delete found.intervalMs;
|
|
212
|
+
delete found.input;
|
|
213
|
+
delete found.chatGuid;
|
|
214
|
+
Object.assign(found, definition);
|
|
215
|
+
await initializeBusiness?.(tx, found.conversationId);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
|
219
|
+
await configure(tx, conversation.id, { ...defaults, extensions: [], tools: [] });
|
|
220
|
+
await initializeBusiness?.(tx, conversation.id);
|
|
221
|
+
const firstAt = definition.time !== undefined
|
|
222
|
+
? nextDaily(definition.time, Date.now())
|
|
223
|
+
: Date.now() + (definition.intervalMs ?? SIX_HOURS);
|
|
224
|
+
const taskId = await tx.createTask(Scheduler, { jobId: definition.id, firstAt }, { conversationId: conversation.id, ownership: { kind: "conversation" }, background: true });
|
|
225
|
+
state.items.push({ ...definition, conversationId: conversation.id, taskId });
|
|
226
|
+
}
|
|
227
|
+
const ids = new Set(definitions.map((definition) => definition.id));
|
|
228
|
+
for (const item of state.items)
|
|
229
|
+
if (!ids.has(item.id))
|
|
230
|
+
item.enabled = false;
|
|
231
|
+
const requests = await tx.doc(RunRequests);
|
|
232
|
+
for (const extension of extensions)
|
|
233
|
+
for (const request of extension.runRequests ?? []) {
|
|
234
|
+
const existing = requests.items.find((item) => item.id === request.id);
|
|
235
|
+
if (existing) {
|
|
236
|
+
if (existing.jobId !== request.scheduleId || existing.requestedAt !== request.at)
|
|
237
|
+
throw new Error(`Run request ID already used with different configuration: ${request.id}`);
|
|
238
|
+
existing.enabled = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const schedule = state.items.find((item) => item.id === request.scheduleId);
|
|
242
|
+
if (!schedule?.enabled)
|
|
243
|
+
throw new Error("Run request target is unavailable");
|
|
244
|
+
if (schedule.manualRuns?.[request.id])
|
|
245
|
+
throw new Error("Run request ID belongs to a historical operator run");
|
|
246
|
+
const at = request.at === "now" ? Date.now() : request.at;
|
|
247
|
+
const taskId = await tx.createTask(Scheduler, { jobId: schedule.id, firstAt: at, requestId: request.id }, {
|
|
248
|
+
conversationId: schedule.conversationId,
|
|
249
|
+
ownership: { kind: "conversation" },
|
|
250
|
+
background: true,
|
|
251
|
+
});
|
|
252
|
+
requests.items.push({
|
|
253
|
+
id: request.id,
|
|
254
|
+
jobId: schedule.id,
|
|
255
|
+
requestedAt: request.at,
|
|
256
|
+
at,
|
|
257
|
+
taskId,
|
|
258
|
+
enabled: true,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const declaredRequests = new Set(extensions.flatMap((extension) => (extension.runRequests ?? []).map((request) => request.id)));
|
|
262
|
+
for (const request of requests.items)
|
|
263
|
+
if (!declaredRequests.has(request.id))
|
|
264
|
+
request.enabled = false;
|
|
265
|
+
};
|
|
266
|
+
admissionReady = false;
|
|
267
|
+
try {
|
|
268
|
+
if (transaction)
|
|
269
|
+
await initialize(transaction);
|
|
270
|
+
else
|
|
271
|
+
await harness.commit(initialize, BACKGROUND_CONTEXT);
|
|
272
|
+
publish?.();
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
admissionReady = true;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** Reuse at-most-once direct-send receipts; never retry unknown external effects. */
|
|
281
|
+
export async function deliverScheduled(harness, send, enabled) {
|
|
282
|
+
for (const item of (await harness.snapshot(ScheduledOutbox, BACKGROUND_CONTEXT))?.items ?? []) {
|
|
283
|
+
if (!enabled(item.chatGuid))
|
|
284
|
+
continue;
|
|
285
|
+
await send(item);
|
|
286
|
+
await harness.commit(async (tx) => {
|
|
287
|
+
const outbox = await tx.doc(ScheduledOutbox);
|
|
288
|
+
outbox.items = outbox.items.filter((queued) => queued.requestId !== item.requestId);
|
|
289
|
+
}, BACKGROUND_CONTEXT);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Calendar dates follow the service process timezone, including an explicit TZ override. */
|
|
2
|
+
export function localDate(timestamp) {
|
|
3
|
+
const date = new Date(timestamp);
|
|
4
|
+
return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
|
|
5
|
+
.map((value, index) => String(value).padStart(index === 0 ? 4 : 2, "0"))
|
|
6
|
+
.join("-");
|
|
7
|
+
}
|
|
8
|
+
/** Date applies local daylight-saving rules: shift gaps forward and choose the first repeated time. */
|
|
9
|
+
export function dailyTime(time, timestamp, dayOffset = 0) {
|
|
10
|
+
const date = new Date(timestamp);
|
|
11
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
12
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + dayOffset, hours, minutes).getTime();
|
|
13
|
+
}
|
|
14
|
+
/** Advance by a calendar day, which need not be 24 elapsed hours. */
|
|
15
|
+
export function nextDaily(time, after) {
|
|
16
|
+
const today = dailyTime(time, after);
|
|
17
|
+
return today > after ? today : dailyTime(time, after, 1);
|
|
18
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,199 +1,96 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
* Usage:
|
|
6
|
-
* pi-imessage Run in foreground
|
|
7
|
-
* pi-imessage install Install + start launchd service (auto-starts on boot)
|
|
8
|
-
* pi-imessage uninstall Stop + remove launchd service
|
|
9
|
-
* pi-imessage start Start the service
|
|
10
|
-
* pi-imessage stop Stop the service
|
|
11
|
-
* pi-imessage restart Restart the service
|
|
12
|
-
* pi-imessage logs Tail service logs
|
|
13
|
-
*/
|
|
14
|
-
import { execSync, fork } from "node:child_process";
|
|
15
|
-
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { createRequire } from "node:module";
|
|
1
|
+
#!/usr/bin/env -S node --use-env-proxy --experimental-strip-types
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
4
|
import { homedir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
function getNodePath() {
|
|
29
|
-
try {
|
|
30
|
-
return execSync("which node", { encoding: "utf-8" }).trim();
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return "/usr/local/bin/node";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { parseEnv } from "node:util";
|
|
8
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { startApplication } from "./main.js";
|
|
10
|
+
import { createMessageSender } from "./transport/send.js";
|
|
11
|
+
export async function main(args = process.argv.slice(2)) {
|
|
12
|
+
if (args.includes("--help") || args[0] === "help") {
|
|
13
|
+
console.log("pi-imessage [serve]\npi-imessage install (write launchd job only)\nSee ops/README.md for operator-only installation, deployment and restart.");
|
|
14
|
+
return;
|
|
34
15
|
}
|
|
35
|
-
}
|
|
36
|
-
function buildPlist() {
|
|
37
|
-
const nodePath = getNodePath();
|
|
38
|
-
const stdoutLog = join(LOG_DIR, "stdout.log");
|
|
39
|
-
const stderrLog = join(LOG_DIR, "stderr.log");
|
|
40
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
41
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
42
|
-
<plist version="1.0">
|
|
43
|
-
<dict>
|
|
44
|
-
<key>Label</key>
|
|
45
|
-
<string>${LABEL}</string>
|
|
46
|
-
<key>ProgramArguments</key>
|
|
47
|
-
<array>
|
|
48
|
-
<string>${nodePath}</string>
|
|
49
|
-
<string>${MAIN_JS}</string>
|
|
50
|
-
</array>
|
|
51
|
-
<key>RunAtLoad</key>
|
|
52
|
-
<true/>
|
|
53
|
-
<key>KeepAlive</key>
|
|
54
|
-
<true/>
|
|
55
|
-
<key>StandardOutPath</key>
|
|
56
|
-
<string>${stdoutLog}</string>
|
|
57
|
-
<key>StandardErrorPath</key>
|
|
58
|
-
<string>${stderrLog}</string>
|
|
59
|
-
<key>EnvironmentVariables</key>
|
|
60
|
-
<dict>
|
|
61
|
-
<key>PATH</key>
|
|
62
|
-
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
63
|
-
<key>WORKING_DIR</key>
|
|
64
|
-
<string>${WORKING_DIR}</string>
|
|
65
|
-
<key>WEB_HOST</key>
|
|
66
|
-
<string>${WEB_HOST}</string>
|
|
67
|
-
<key>WEB_PORT</key>
|
|
68
|
-
<string>${WEB_PORT}</string>
|
|
69
|
-
<key>WEB_ENABLED</key>
|
|
70
|
-
<string>${WEB_ENABLED}</string>
|
|
71
|
-
</dict>
|
|
72
|
-
</dict>
|
|
73
|
-
</plist>`;
|
|
74
|
-
}
|
|
75
|
-
function isLoaded() {
|
|
76
16
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
17
|
+
// Like the old dotenv entry point: cwd .env, with existing environment values taking priority.
|
|
18
|
+
for (const [name, value] of Object.entries(parseEnv(await readFile(process.env.DOTENV_CONFIG_PATH ?? ".env", "utf8"))))
|
|
19
|
+
if (process.env[name] === undefined && value !== undefined)
|
|
20
|
+
process.env[name] = value;
|
|
79
21
|
}
|
|
80
|
-
catch {
|
|
81
|
-
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code !== "ENOENT")
|
|
24
|
+
throw new Error("Unable to load environment configuration", { cause: error });
|
|
82
25
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
execSync(`launchctl start ${LABEL}`, { stdio: "inherit" });
|
|
114
|
-
console.log("[pi-imessage] Service started");
|
|
115
|
-
}
|
|
116
|
-
function stop() {
|
|
117
|
-
if (!isLoaded()) {
|
|
118
|
-
console.log("[pi-imessage] Service is not running");
|
|
26
|
+
const workingDir = resolve(process.env.WORKING_DIR ?? join(homedir(), ".pi", "imessage"));
|
|
27
|
+
const agentDir = resolve(getAgentDir());
|
|
28
|
+
if (args[0] === "install") {
|
|
29
|
+
const directory = join(homedir(), "Library", "LaunchAgents");
|
|
30
|
+
await mkdir(directory, { recursive: true });
|
|
31
|
+
await mkdir(workingDir, { recursive: true, mode: 0o700 });
|
|
32
|
+
const xml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
33
|
+
const path = join(directory, "org.pi-imessage.service.plist");
|
|
34
|
+
const environment = [
|
|
35
|
+
"WEB_HOST",
|
|
36
|
+
"WEB_PORT",
|
|
37
|
+
"WEB_ENABLED",
|
|
38
|
+
"MESSAGES_DB_PATH",
|
|
39
|
+
"BRAVE_API_KEY",
|
|
40
|
+
"BRAVE_SEARCH_API_KEY",
|
|
41
|
+
"HTTP_PROXY",
|
|
42
|
+
"HTTPS_PROXY",
|
|
43
|
+
"NO_PROXY",
|
|
44
|
+
"http_proxy",
|
|
45
|
+
"https_proxy",
|
|
46
|
+
"no_proxy",
|
|
47
|
+
"NODE_OPTIONS",
|
|
48
|
+
"TZ",
|
|
49
|
+
"DOTENV_CONFIG_PATH",
|
|
50
|
+
]
|
|
51
|
+
.filter((key) => process.env[key] !== undefined)
|
|
52
|
+
.map((key) => `<key>${key}</key><string>${xml(process.env[key] ?? "")}</string>`)
|
|
53
|
+
.join("");
|
|
54
|
+
await writeFile(path, `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>org.pi-imessage.service</string><key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>--use-env-proxy</string><string>--experimental-strip-types</string><string>${xml(fileURLToPath(import.meta.url))}</string><string>serve</string></array><key>WorkingDirectory</key><string>${xml(process.cwd())}</string><key>EnvironmentVariables</key><dict><key>PI_CODING_AGENT_DIR</key><string>${xml(agentDir)}</string><key>WORKING_DIR</key><string>${xml(workingDir)}</string><key>PATH</key><string>${xml(process.env.PATH ?? "/usr/bin:/bin")}</string>${environment}</dict><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>${xml(join(workingDir, "service.log"))}</string><key>StandardErrorPath</key><string>${xml(join(workingDir, "service.log"))}</string></dict></plist>`, { flag: "wx", mode: 0o600 });
|
|
55
|
+
console.log(`Wrote ${path}. Not loaded. Follow ops/README.md for controller handover; never run two services.`);
|
|
119
56
|
return;
|
|
120
57
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
Usage: pi-imessage [command]
|
|
155
|
-
|
|
156
|
-
Commands:
|
|
157
|
-
(none) Run in foreground
|
|
158
|
-
install Install + start launchd service (auto-starts on boot)
|
|
159
|
-
uninstall Stop + remove launchd service
|
|
160
|
-
start Start the service
|
|
161
|
-
stop Stop the service
|
|
162
|
-
restart Restart the service
|
|
163
|
-
logs Tail service logs
|
|
164
|
-
help Show this help message`);
|
|
165
|
-
}
|
|
166
|
-
const command = process.argv[2];
|
|
167
|
-
switch (command) {
|
|
168
|
-
case "install":
|
|
169
|
-
install();
|
|
170
|
-
break;
|
|
171
|
-
case "uninstall":
|
|
172
|
-
uninstall();
|
|
173
|
-
break;
|
|
174
|
-
case "start":
|
|
175
|
-
start();
|
|
176
|
-
break;
|
|
177
|
-
case "stop":
|
|
178
|
-
stop();
|
|
179
|
-
break;
|
|
180
|
-
case "restart":
|
|
181
|
-
restart();
|
|
182
|
-
break;
|
|
183
|
-
case "logs":
|
|
184
|
-
logs();
|
|
185
|
-
break;
|
|
186
|
-
case "help":
|
|
187
|
-
case "--help":
|
|
188
|
-
case "-h":
|
|
189
|
-
help();
|
|
190
|
-
break;
|
|
191
|
-
case undefined:
|
|
192
|
-
runForeground();
|
|
193
|
-
break;
|
|
194
|
-
default:
|
|
195
|
-
console.error(`Unknown command: ${command}\n`);
|
|
196
|
-
help();
|
|
197
|
-
process.exit(1);
|
|
58
|
+
if (args.length && args[0] !== "serve")
|
|
59
|
+
throw new Error("Unknown command; see --help");
|
|
60
|
+
if (args.length > 1)
|
|
61
|
+
throw new Error("Unknown serve arguments; use workspace configuration and reload_extensions");
|
|
62
|
+
const dbPath = process.env.MESSAGES_DB_PATH ?? join(homedir(), "Library", "Messages", "chat.db");
|
|
63
|
+
// Incoming archives stay in the workspace; outgoing staging uses Messages' readable root.
|
|
64
|
+
const sender = createMessageSender({ dbPath });
|
|
65
|
+
const app = await startApplication({
|
|
66
|
+
workingDir,
|
|
67
|
+
agentDir,
|
|
68
|
+
dbPath,
|
|
69
|
+
send: sender.sendMessage,
|
|
70
|
+
sendAttachment: sender.sendAttachment,
|
|
71
|
+
onError: (error) => console.error(new Date().toISOString(), "Messaging poll failed", error),
|
|
72
|
+
web: process.env.WEB_ENABLED === "false"
|
|
73
|
+
? false
|
|
74
|
+
: { host: process.env.WEB_HOST ?? "localhost", port: Number(process.env.WEB_PORT ?? 7750) },
|
|
75
|
+
});
|
|
76
|
+
let closing = false;
|
|
77
|
+
const stop = (signal) => {
|
|
78
|
+
if (closing)
|
|
79
|
+
return;
|
|
80
|
+
closing = true;
|
|
81
|
+
console.log(new Date().toISOString(), "Shutdown requested", { signal });
|
|
82
|
+
void app.close().then(() => {
|
|
83
|
+
console.log(new Date().toISOString(), "pi-imessage stopped");
|
|
84
|
+
}, (error) => {
|
|
85
|
+
console.error(new Date().toISOString(), "Shutdown failed", error);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
process.once("SIGINT", () => stop("SIGINT"));
|
|
90
|
+
process.once("SIGTERM", () => stop("SIGTERM"));
|
|
198
91
|
}
|
|
199
|
-
|
|
92
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href)
|
|
93
|
+
void main().catch((error) => {
|
|
94
|
+
console.error(new Date().toISOString(), "Command failed", error instanceof Error ? error.message : error);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
});
|