@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,87 @@
|
|
|
1
|
+
/** Shared conversation ordering uses message activity, not conversation creation order. */
|
|
2
|
+
export function historyActivity(history) {
|
|
3
|
+
let latest = 0;
|
|
4
|
+
for (const entry of history.items) {
|
|
5
|
+
for (const message of entry.model ?? []) {
|
|
6
|
+
if ("timestamp" in message && Number.isFinite(message.timestamp))
|
|
7
|
+
latest = Math.max(latest, message.timestamp);
|
|
8
|
+
}
|
|
9
|
+
const data = entry.data;
|
|
10
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
11
|
+
continue;
|
|
12
|
+
const original = data.original;
|
|
13
|
+
if (!original || typeof original !== "object" || Array.isArray(original))
|
|
14
|
+
continue;
|
|
15
|
+
const date = typeof original.date === "string" ? Date.parse(original.date) : 0;
|
|
16
|
+
if (Number.isFinite(date))
|
|
17
|
+
latest = Math.max(latest, date);
|
|
18
|
+
}
|
|
19
|
+
return latest;
|
|
20
|
+
}
|
|
21
|
+
/** Preserve displayed messages and pagination without shipping model-only payloads. */
|
|
22
|
+
export function displayHistory(history, limit) {
|
|
23
|
+
const items = history.items.flatMap((entry) => {
|
|
24
|
+
const model = (entry.model ?? []).flatMap((message) => {
|
|
25
|
+
if (message.role !== "user" && message.role !== "assistant")
|
|
26
|
+
return [];
|
|
27
|
+
if (message.role === "assistant" && message.stopReason === "toolUse")
|
|
28
|
+
return [];
|
|
29
|
+
const content = typeof message.content === "string"
|
|
30
|
+
? message.content
|
|
31
|
+
: message.content
|
|
32
|
+
.map((block) => block.type === "text"
|
|
33
|
+
? block.text
|
|
34
|
+
: block.type === "image"
|
|
35
|
+
? "[Image attachment]"
|
|
36
|
+
: block.type === "toolCall"
|
|
37
|
+
? `[Tool: ${block.name}]`
|
|
38
|
+
: "")
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.join("\n");
|
|
41
|
+
return content ? [{ role: message.role, content, timestamp: message.timestamp }] : [];
|
|
42
|
+
});
|
|
43
|
+
if (!model.length)
|
|
44
|
+
return [];
|
|
45
|
+
const data = entry.data && typeof entry.data === "object" && !Array.isArray(entry.data) ? entry.data : {};
|
|
46
|
+
const original = data.original && typeof data.original === "object" && !Array.isArray(data.original)
|
|
47
|
+
? data.original
|
|
48
|
+
: {};
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
id: entry.id,
|
|
52
|
+
model,
|
|
53
|
+
data: {
|
|
54
|
+
legacy: data.legacy,
|
|
55
|
+
original: {
|
|
56
|
+
date: original.date,
|
|
57
|
+
sender: original.sender,
|
|
58
|
+
senderName: original.senderName,
|
|
59
|
+
groupName: original.groupName,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
});
|
|
65
|
+
if (limit === undefined)
|
|
66
|
+
return { items, next: history.next, groupName: undefined };
|
|
67
|
+
// Limit messages, not entries; order matches the browser, including imported dates.
|
|
68
|
+
const rows = items
|
|
69
|
+
.flatMap((entry) => entry.model
|
|
70
|
+
.filter((message) => message.content.replace(/^\[(?:Group '[^']*'|DM|SMS) from [^\]]+\] /, ""))
|
|
71
|
+
.map((message) => ({
|
|
72
|
+
message,
|
|
73
|
+
time: Number(message.timestamp) || Date.parse(String(entry.data.original.date ?? "")) || 0,
|
|
74
|
+
groupName: entry.data.original.groupName || message.content.match(/^\[Group '([^']*)' from [^\]]+\] /)?.[1],
|
|
75
|
+
})))
|
|
76
|
+
.sort((left, right) => left.time - right.time);
|
|
77
|
+
const groupName = rows.findLast((row) => row.groupName)?.groupName;
|
|
78
|
+
const selected = new Set(rows.slice(-limit).map((row) => row.message));
|
|
79
|
+
return {
|
|
80
|
+
items: items.flatMap((entry) => {
|
|
81
|
+
const model = entry.model.filter((message) => selected.has(message));
|
|
82
|
+
return model.length ? [{ ...entry, model }] : [];
|
|
83
|
+
}),
|
|
84
|
+
next: history.next,
|
|
85
|
+
groupName,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Read-only UI projection; never changes records or implements memory writes. */
|
|
2
|
+
export function parseMemoryFile(contents, namespace) {
|
|
3
|
+
const records = [];
|
|
4
|
+
let invalidRecords = 0;
|
|
5
|
+
for (const line of contents.split("\n").filter((line) => line.trim())) {
|
|
6
|
+
try {
|
|
7
|
+
const record = JSON.parse(line);
|
|
8
|
+
if (!record ||
|
|
9
|
+
typeof record !== "object" ||
|
|
10
|
+
Array.isArray(record) ||
|
|
11
|
+
!("text" in record) ||
|
|
12
|
+
typeof record.text !== "string") {
|
|
13
|
+
invalidRecords++;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
records.push({
|
|
17
|
+
...record,
|
|
18
|
+
text: record.text,
|
|
19
|
+
namespace: "namespace" in record && typeof record.namespace === "string" && record.namespace
|
|
20
|
+
? record.namespace
|
|
21
|
+
: namespace,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
invalidRecords++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { namespace, records, invalidRecords };
|
|
29
|
+
}
|
|
30
|
+
/** Filter complete records before returning one page, with supersession resolved across namespaces. */
|
|
31
|
+
export function memoryPage(files, parameters) {
|
|
32
|
+
const requestedPage = parameters.get("page") ?? "1";
|
|
33
|
+
if (!/^[1-9]\d*$/.test(requestedPage) || !Number.isSafeInteger(Number(requestedPage)))
|
|
34
|
+
throw new Error("Invalid memory page");
|
|
35
|
+
const query = {
|
|
36
|
+
q: parameters.get("q") ?? "",
|
|
37
|
+
namespace: parameters.get("namespace") ?? "",
|
|
38
|
+
kind: parameters.get("kind") ?? "",
|
|
39
|
+
status: parameters.get("status") ?? "active",
|
|
40
|
+
};
|
|
41
|
+
const records = files.flatMap((file) => file.records);
|
|
42
|
+
const superseded = new Set(records.flatMap((record) => typeof record.supersedes === "string"
|
|
43
|
+
? [record.supersedes]
|
|
44
|
+
: Array.isArray(record.supersedes)
|
|
45
|
+
? record.supersedes.filter((id) => typeof id === "string")
|
|
46
|
+
: []));
|
|
47
|
+
const search = query.q.toLowerCase();
|
|
48
|
+
const filtered = records
|
|
49
|
+
.map((record) => ({
|
|
50
|
+
...record,
|
|
51
|
+
effectiveStatus: typeof record.id === "string" && superseded.has(record.id)
|
|
52
|
+
? "superseded"
|
|
53
|
+
: typeof record.status === "string" && record.status
|
|
54
|
+
? record.status
|
|
55
|
+
: "unknown",
|
|
56
|
+
}))
|
|
57
|
+
.filter((record) => (!query.namespace || record.namespace === query.namespace) &&
|
|
58
|
+
(!query.kind || record.kind === query.kind) &&
|
|
59
|
+
(!query.status || record.effectiveStatus === query.status) &&
|
|
60
|
+
(!search || JSON.stringify(record).toLowerCase().includes(search)))
|
|
61
|
+
.sort((left, right) => String(right.created_at || "").localeCompare(String(left.created_at || "")));
|
|
62
|
+
const pageSize = 20;
|
|
63
|
+
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
|
64
|
+
const page = Math.min(Number(requestedPage), pages);
|
|
65
|
+
return {
|
|
66
|
+
records: filtered.slice((page - 1) * pageSize, page * pageSize),
|
|
67
|
+
total: filtered.length,
|
|
68
|
+
recordCount: records.length,
|
|
69
|
+
page,
|
|
70
|
+
pages,
|
|
71
|
+
pageSize,
|
|
72
|
+
query,
|
|
73
|
+
namespaces: [
|
|
74
|
+
...new Set(files.map((file) => file.namespace).concat(records.map((record) => record.namespace))),
|
|
75
|
+
].sort(),
|
|
76
|
+
kinds: [
|
|
77
|
+
...new Set(records.flatMap((record) => (typeof record.kind === "string" && record.kind ? [record.kind] : []))),
|
|
78
|
+
].sort(),
|
|
79
|
+
invalidRecords: files.reduce((total, file) => total + file.invalidRecords, 0),
|
|
80
|
+
};
|
|
81
|
+
}
|
package/dist/web/page.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function escapeHtml(text) {
|
|
2
|
+
return text.replace(/[&<>"']/g, (value) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[value] ?? value);
|
|
3
|
+
}
|
|
4
|
+
/** The legacy shell, backed by native read-only APIs rather than legacy host state. */
|
|
5
|
+
export function renderPage(page, state) {
|
|
6
|
+
const active = page === "/" || page === "/chat" ? "/" : page;
|
|
7
|
+
const navigation = [
|
|
8
|
+
["/", "Messages"],
|
|
9
|
+
["/chats", "Chats"],
|
|
10
|
+
["/tasks", "Tasks"],
|
|
11
|
+
["/scheduled", "Scheduled"],
|
|
12
|
+
["/logs", "Logs"],
|
|
13
|
+
["/memory", "Memory"],
|
|
14
|
+
["/settings", "Settings"],
|
|
15
|
+
]
|
|
16
|
+
.map(([href, label]) => `<a href="${href}"${href === active ? ' class="active" aria-current="page"' : ""}>${label}</a>`)
|
|
17
|
+
.join("");
|
|
18
|
+
return `<!doctype html>
|
|
19
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
20
|
+
<title>blue — pi-imessage</title><link rel="stylesheet" href="/assets/style.css">
|
|
21
|
+
<script src="/assets/app.js" defer></script></head>
|
|
22
|
+
<body><nav class="app-nav" aria-label="Main navigation">${navigation}</nav>
|
|
23
|
+
<main class="content" id="main"><header class="page-header"><h1 id="title">Messages</h1>
|
|
24
|
+
<div class="connection"><button id="refresh" type="button">Refresh</button></div></header>
|
|
25
|
+
<div id="toolbar"></div><p id="notice" role="status" hidden></p><div id="view" aria-busy="true"></div>
|
|
26
|
+
<details class="native-state"><summary>Native state</summary><pre id="state">${escapeHtml(state)}</pre></details></main>
|
|
27
|
+
<aside class="chat-list" id="chat-list" aria-label="Conversations" hidden></aside>
|
|
28
|
+
</body></html>`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { DirectSends } from "../agent/direct-send.js";
|
|
3
|
+
import { EXECUTION_KIND, Schedules } from "../agent/scheduling.js";
|
|
4
|
+
/** Native occurrence tasks are the execution history; this view keeps no parallel log. */
|
|
5
|
+
export function readSchedules(harness) {
|
|
6
|
+
return harness.commit(async (tx) => {
|
|
7
|
+
const schedules = await tx.doc(Schedules);
|
|
8
|
+
const sends = await tx.doc(DirectSends);
|
|
9
|
+
const jobs = [];
|
|
10
|
+
for (const schedule of schedules.items) {
|
|
11
|
+
const task = await tx.task(schedule.taskId);
|
|
12
|
+
const checkpoint = task?.state.checkpoint;
|
|
13
|
+
const state = checkpoint && typeof checkpoint === "object" && !Array.isArray(checkpoint) ? checkpoint : undefined;
|
|
14
|
+
// Occurrence IDs grow with admission time; the newest ten are the history shown.
|
|
15
|
+
const executions = await tx.scanTasks({ conversationId: schedule.conversationId, kind: EXECUTION_KIND, order: "descending" }, 10);
|
|
16
|
+
const runs = executions.items.map((run) => {
|
|
17
|
+
const outcome = run.state.outcome;
|
|
18
|
+
const result = outcome?.status === "completed" &&
|
|
19
|
+
outcome.result &&
|
|
20
|
+
typeof outcome.result === "object" &&
|
|
21
|
+
!Array.isArray(outcome.result)
|
|
22
|
+
? outcome.result
|
|
23
|
+
: undefined;
|
|
24
|
+
const receipt = typeof result?.requestId === "string"
|
|
25
|
+
? sends.requests.find((item) => item.requestId === result.requestId)
|
|
26
|
+
: undefined;
|
|
27
|
+
return {
|
|
28
|
+
id: run.id,
|
|
29
|
+
input: run.input,
|
|
30
|
+
status: outcome?.status ?? run.state.status,
|
|
31
|
+
result,
|
|
32
|
+
error: outcome?.status === "failed" || outcome?.status === "faulted" ? outcome.error.message : undefined,
|
|
33
|
+
delivery: receipt?.textStatus ?? (result?.requestId ? "queued" : undefined),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
jobs.push({
|
|
37
|
+
id: schedule.id,
|
|
38
|
+
name: schedule.name,
|
|
39
|
+
kind: schedule.kind,
|
|
40
|
+
enabled: schedule.enabled,
|
|
41
|
+
time: schedule.time,
|
|
42
|
+
intervalMs: schedule.intervalMs,
|
|
43
|
+
timezone: schedule.timezone,
|
|
44
|
+
chatGuid: schedule.chatGuid,
|
|
45
|
+
extension: schedule.extension,
|
|
46
|
+
execution: schedule.execution,
|
|
47
|
+
conversationId: schedule.conversationId,
|
|
48
|
+
taskId: schedule.taskId,
|
|
49
|
+
status: task?.state.outcome?.status ?? task?.state.status ?? "missing",
|
|
50
|
+
phase: state?.phase,
|
|
51
|
+
nextAt: state?.phase === "sleep" ? state.at : undefined,
|
|
52
|
+
runs,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
jobs,
|
|
57
|
+
recent: jobs
|
|
58
|
+
.flatMap((job) => job.runs.map((run) => ({ ...run, jobId: job.id, name: job.name })))
|
|
59
|
+
.sort((a, b) => b.id - a.id)
|
|
60
|
+
.slice(0, 10),
|
|
61
|
+
};
|
|
62
|
+
}, BACKGROUND_CONTEXT);
|
|
63
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { open, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { gzip } from "node:zlib";
|
|
7
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
8
|
+
import { LiveDoc, } from "@earendil-works/pi-durable";
|
|
9
|
+
import { Chats, progressEnabled } from "../agent/chats.js";
|
|
10
|
+
import { DirectSends } from "../agent/direct-send.js";
|
|
11
|
+
import { Sessions } from "../agent/health.js";
|
|
12
|
+
import { Deliveries } from "../agent/replies.js";
|
|
13
|
+
import { Schedules } from "../agent/scheduling.js";
|
|
14
|
+
import { isReplyEnabled, readSettings } from "../config/settings.js";
|
|
15
|
+
import { displayHistory, historyActivity } from "./history.js";
|
|
16
|
+
import { memoryPage, parseMemoryFile } from "./memory.js";
|
|
17
|
+
import { renderPage } from "./page.js";
|
|
18
|
+
import { readSchedules } from "./schedules.js";
|
|
19
|
+
import { readTaskTree } from "./tasks.js";
|
|
20
|
+
function readCursor(value) {
|
|
21
|
+
if (!value)
|
|
22
|
+
return;
|
|
23
|
+
if (value.length > 2048)
|
|
24
|
+
throw new Error("Invalid cursor");
|
|
25
|
+
const parsed = JSON.parse(Buffer.from(value, "base64url").toString());
|
|
26
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
27
|
+
throw new Error("Invalid cursor");
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
function redact(value, compact = false, truncateStrings = true) {
|
|
31
|
+
return JSON.stringify(value, (key, item) => /^(authorization|password|api[_-]?key|(?:(?:access|refresh)[_-]?)?token|secret|credentials?)$/i.test(key)
|
|
32
|
+
? "[redacted]"
|
|
33
|
+
: truncateStrings && typeof item === "string" && item.length > 50000
|
|
34
|
+
? `${item.slice(0, 50000)}\n[truncated]`
|
|
35
|
+
: item, compact ? undefined : 2)
|
|
36
|
+
.replace(/\b(?:sk-[\w-]{8,}|gh[pousr]_[\w]{8,})\b/g, "[redacted]")
|
|
37
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [redacted]");
|
|
38
|
+
}
|
|
39
|
+
const compress = promisify(gzip);
|
|
40
|
+
async function send(response, status, contentType, text) {
|
|
41
|
+
const encodings = new Map((response.req.headers["accept-encoding"] ?? "").split(",").map((item) => {
|
|
42
|
+
const [name, ...parameters] = item.trim().toLowerCase().split(";");
|
|
43
|
+
const quality = parameters
|
|
44
|
+
.map((parameter) => parameter.trim())
|
|
45
|
+
.find((parameter) => parameter.startsWith("q="));
|
|
46
|
+
return [name.trim(), quality ? Number(quality.slice(2)) : 1];
|
|
47
|
+
}));
|
|
48
|
+
const body = Buffer.from(text);
|
|
49
|
+
const useGzip = body.length >= 1024 && (encodings.get("gzip") ?? encodings.get("*") ?? 0) > 0;
|
|
50
|
+
const payload = useGzip ? await compress(body) : body;
|
|
51
|
+
response.writeHead(status, {
|
|
52
|
+
"Content-Type": contentType,
|
|
53
|
+
"Cache-Control": "no-store",
|
|
54
|
+
"X-Content-Type-Options": "nosniff",
|
|
55
|
+
Vary: "Accept-Encoding",
|
|
56
|
+
"Content-Length": payload.length,
|
|
57
|
+
...(useGzip ? { "Content-Encoding": "gzip" } : {}),
|
|
58
|
+
});
|
|
59
|
+
response.end(payload);
|
|
60
|
+
}
|
|
61
|
+
function json(response, status, value, compact = false, truncateStrings = true) {
|
|
62
|
+
return send(response, status, "application/json; charset=utf-8", redact(value, compact, truncateStrings));
|
|
63
|
+
}
|
|
64
|
+
/** Read-only pages and an explicit model health check. Viewing pages never starts work. */
|
|
65
|
+
export async function startWeb(options) {
|
|
66
|
+
const streams = new Map();
|
|
67
|
+
let revision = 0;
|
|
68
|
+
const instance = randomUUID();
|
|
69
|
+
function viewRevision() {
|
|
70
|
+
return `${instance}:${revision}`;
|
|
71
|
+
}
|
|
72
|
+
function settings() {
|
|
73
|
+
return readSettings(options.workingDir);
|
|
74
|
+
}
|
|
75
|
+
async function history(id, cursor) {
|
|
76
|
+
const conversation = await options.agent.harness.conversation(id, BACKGROUND_CONTEXT);
|
|
77
|
+
if (!conversation)
|
|
78
|
+
throw new Error("Conversation not found");
|
|
79
|
+
return conversation.entries({}, 100, cursor, BACKGROUND_CONTEXT);
|
|
80
|
+
}
|
|
81
|
+
function delivery(id) {
|
|
82
|
+
return options.agent.harness.snapshot(Deliveries, id, BACKGROUND_CONTEXT);
|
|
83
|
+
}
|
|
84
|
+
async function overview() {
|
|
85
|
+
const [chats, configuration] = await Promise.all([
|
|
86
|
+
options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
|
|
87
|
+
settings(),
|
|
88
|
+
]);
|
|
89
|
+
const conversations = await Promise.all((chats?.items ?? []).map(async (chat) => {
|
|
90
|
+
try {
|
|
91
|
+
const [entries, receipt] = await Promise.all([
|
|
92
|
+
history(chat.conversationId),
|
|
93
|
+
delivery(chat.conversationId),
|
|
94
|
+
]);
|
|
95
|
+
const { items, groupName } = displayHistory(entries, 15);
|
|
96
|
+
return {
|
|
97
|
+
...chat,
|
|
98
|
+
updatedAt: historyActivity(entries),
|
|
99
|
+
history: { items, groupName },
|
|
100
|
+
delivery: {
|
|
101
|
+
answers: Object.fromEntries(items.map((entry) => [entry.id, receipt?.answers[entry.id]])),
|
|
102
|
+
drafts: Object.fromEntries(items.map((entry) => [entry.id, receipt?.drafts?.[entry.id]])),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return { ...chat, error: error instanceof Error ? error.message : String(error) };
|
|
108
|
+
}
|
|
109
|
+
}));
|
|
110
|
+
return {
|
|
111
|
+
conversations,
|
|
112
|
+
revision: viewRevision(),
|
|
113
|
+
settings: { chatAllowlist: configuration.chatAllowlist },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** One row per chat: reply eligibility, the /verbose choice and the conversation's agent settings. */
|
|
117
|
+
async function chatSettings() {
|
|
118
|
+
const [chats, configuration] = await Promise.all([
|
|
119
|
+
options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
|
|
120
|
+
settings(),
|
|
121
|
+
]);
|
|
122
|
+
const records = chats?.items ?? [];
|
|
123
|
+
const rows = await Promise.all(records.map(async (chat) => {
|
|
124
|
+
const conversation = await options.agent.harness.conversation(chat.conversationId, BACKGROUND_CONTEXT);
|
|
125
|
+
const [agent, entries] = conversation
|
|
126
|
+
? await Promise.all([conversation.agent(BACKGROUND_CONTEXT), history(chat.conversationId)])
|
|
127
|
+
: [undefined, undefined];
|
|
128
|
+
return {
|
|
129
|
+
chatGuid: chat.chatGuid,
|
|
130
|
+
conversationId: chat.conversationId,
|
|
131
|
+
groupName: entries ? displayHistory(entries, 15).groupName : undefined,
|
|
132
|
+
updatedAt: entries ? historyActivity(entries) : undefined,
|
|
133
|
+
replies: isReplyEnabled(configuration, chat.chatGuid),
|
|
134
|
+
verbose: { chat: chat.progress, effective: progressEnabled(configuration, records, chat.chatGuid) },
|
|
135
|
+
model: agent?.model ? `${agent.model.provider}/${agent.model.modelId}` : undefined,
|
|
136
|
+
thinking: agent?.thinkingLevel,
|
|
137
|
+
};
|
|
138
|
+
}));
|
|
139
|
+
rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || Number(b.conversationId) - Number(a.conversationId));
|
|
140
|
+
return {
|
|
141
|
+
chats: rows,
|
|
142
|
+
defaults: { progressMessages: configuration.progressMessages },
|
|
143
|
+
revision: viewRevision(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const server = createServer((request, response) => {
|
|
147
|
+
void handle(request, response).catch((error) => {
|
|
148
|
+
if (!response.headersSent)
|
|
149
|
+
void json(response, 400, {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: error instanceof Error ? error.message : String(error),
|
|
152
|
+
}).catch(() => response.destroy());
|
|
153
|
+
else
|
|
154
|
+
response.end();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
async function handle(request, response) {
|
|
158
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
159
|
+
if (request.method === "GET" && url.pathname === "/favicon.ico") {
|
|
160
|
+
response.writeHead(204);
|
|
161
|
+
response.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (request.headers["sec-fetch-site"] === "cross-site" ||
|
|
165
|
+
(request.headers.origin && new URL(request.headers.origin).host !== request.headers.host)) {
|
|
166
|
+
await json(response, 403, { error: "Cross-origin requests are not allowed" });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (request.method === "GET" && url.pathname === "/health/model") {
|
|
170
|
+
try {
|
|
171
|
+
await json(response, 200, await options.agent.health());
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
await json(response, 503, {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: error instanceof Error ? error.message : String(error),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (request.method !== "GET") {
|
|
182
|
+
await json(response, 404, { error: "Not found" });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (url.pathname === "/events") {
|
|
186
|
+
response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store" });
|
|
187
|
+
response.write(`event: ready\ndata: ${JSON.stringify({ revision: viewRevision() })}\n\n`);
|
|
188
|
+
const unsubscribe = options.agent.harness.subscribeCommits(() => {
|
|
189
|
+
if (!response.write('data: {"updated":true}\n\n')) {
|
|
190
|
+
unsubscribe();
|
|
191
|
+
response.destroy();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
streams.set(response, unsubscribe);
|
|
195
|
+
response.on("error", () => {
|
|
196
|
+
unsubscribe();
|
|
197
|
+
streams.delete(response);
|
|
198
|
+
});
|
|
199
|
+
response.on("close", () => {
|
|
200
|
+
unsubscribe();
|
|
201
|
+
streams.delete(response);
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const asset = url.pathname === "/assets/style.css"
|
|
206
|
+
? "style.css"
|
|
207
|
+
: url.pathname === "/assets/app.js"
|
|
208
|
+
? "app.js"
|
|
209
|
+
: undefined;
|
|
210
|
+
if (asset) {
|
|
211
|
+
const path = new URL(asset, import.meta.url);
|
|
212
|
+
await send(response, 200, asset.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8", await readFile(path, "utf8"));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let value;
|
|
216
|
+
let compact = false;
|
|
217
|
+
const page = url.pathname.replace(/\/data$/, "");
|
|
218
|
+
if (page === "/" || page === "/chat") {
|
|
219
|
+
const id = url.searchParams.get("conversationId");
|
|
220
|
+
if (id) {
|
|
221
|
+
if (!/^\d+$/.test(id))
|
|
222
|
+
throw new Error("Invalid conversation ID");
|
|
223
|
+
const conversation = await options.agent.harness.conversation(Number(id), BACKGROUND_CONTEXT);
|
|
224
|
+
if (!conversation) {
|
|
225
|
+
await json(response, 404, { error: "Conversation not found" });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const cursor = readCursor(url.searchParams.get("cursor"));
|
|
229
|
+
const detail = {
|
|
230
|
+
history: await history(conversation.id, cursor),
|
|
231
|
+
delivery: await delivery(conversation.id),
|
|
232
|
+
live: await options.agent.harness.snapshot(LiveDoc, conversation.id, BACKGROUND_CONTEXT),
|
|
233
|
+
revision: viewRevision(),
|
|
234
|
+
};
|
|
235
|
+
if (!url.pathname.endsWith("/data") || url.searchParams.get("view") === "display") {
|
|
236
|
+
const [chats, configuration] = await Promise.all([
|
|
237
|
+
options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
|
|
238
|
+
settings(),
|
|
239
|
+
]);
|
|
240
|
+
value = {
|
|
241
|
+
history: displayHistory(detail.history),
|
|
242
|
+
delivery: detail.delivery,
|
|
243
|
+
chat: chats?.items.find((chat) => chat.conversationId === conversation.id),
|
|
244
|
+
settings: { chatAllowlist: configuration.chatAllowlist },
|
|
245
|
+
revision: detail.revision,
|
|
246
|
+
};
|
|
247
|
+
compact = true;
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
value = {
|
|
251
|
+
history: detail.history,
|
|
252
|
+
delivery: detail.delivery,
|
|
253
|
+
live: detail.live,
|
|
254
|
+
agent: await conversation.agent(BACKGROUND_CONTEXT),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
else if (!url.pathname.endsWith("/data") || url.searchParams.get("view") === "overview") {
|
|
258
|
+
value = await overview();
|
|
259
|
+
compact = true;
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
value = {
|
|
263
|
+
chats: await options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
|
|
264
|
+
sessions: await options.agent.harness.snapshot(Sessions, BACKGROUND_CONTEXT),
|
|
265
|
+
tasks: await options.agent.harness.commit((tx) => tx.scanTasks({}, 100), BACKGROUND_CONTEXT),
|
|
266
|
+
sends: await options.agent.harness.snapshot(DirectSends, BACKGROUND_CONTEXT),
|
|
267
|
+
inspection: await options.agent.harness.inspect(BACKGROUND_CONTEXT),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
else if (page === "/scheduled") {
|
|
271
|
+
value = await readSchedules(options.agent.harness);
|
|
272
|
+
compact = true;
|
|
273
|
+
}
|
|
274
|
+
else if (page === "/chats") {
|
|
275
|
+
value = await chatSettings();
|
|
276
|
+
compact = true;
|
|
277
|
+
}
|
|
278
|
+
else if (page === "/tasks") {
|
|
279
|
+
value = {
|
|
280
|
+
...(await readTaskTree(options.agent.harness)),
|
|
281
|
+
schedules: await options.agent.harness.snapshot(Schedules, BACKGROUND_CONTEXT),
|
|
282
|
+
chats: await options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
|
|
283
|
+
sessions: await options.agent.harness.snapshot(Sessions, BACKGROUND_CONTEXT),
|
|
284
|
+
revision: viewRevision(),
|
|
285
|
+
};
|
|
286
|
+
compact = true;
|
|
287
|
+
}
|
|
288
|
+
else if (url.pathname === "/tasks/task/data") {
|
|
289
|
+
const id = url.searchParams.get("taskId");
|
|
290
|
+
if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
|
|
291
|
+
throw new Error("Invalid task ID");
|
|
292
|
+
value = await options.agent.harness.getTask(Number(id), BACKGROUND_CONTEXT);
|
|
293
|
+
if (!value) {
|
|
294
|
+
await json(response, 404, { error: "Task not found" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else if (page === "/settings")
|
|
299
|
+
value = await settings();
|
|
300
|
+
else if (page === "/logs") {
|
|
301
|
+
const cursor = readCursor(url.searchParams.get("cursor"));
|
|
302
|
+
const tasks = await options.agent.harness.commit((tx) => tx.scanTasks(cursor ? {} : { order: "descending" }, 100, cursor), BACKGROUND_CONTEXT);
|
|
303
|
+
let hostLog = "";
|
|
304
|
+
try {
|
|
305
|
+
const file = await open(join(options.workingDir, "service.log"), "r");
|
|
306
|
+
try {
|
|
307
|
+
const size = (await file.stat()).size;
|
|
308
|
+
const buffer = Buffer.alloc(Math.min(size, 50000));
|
|
309
|
+
const read = await file.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length));
|
|
310
|
+
hostLog = buffer.subarray(0, read.bytesRead).toString();
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await file.close();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (error.code !== "ENOENT")
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
value = { tasks, hostLog };
|
|
321
|
+
}
|
|
322
|
+
else if (page === "/memory") {
|
|
323
|
+
const root = join(options.workingDir, "skills", "file-memory", "namespaces");
|
|
324
|
+
const files = await readdir(root, { recursive: true }).catch((error) => {
|
|
325
|
+
if (error.code === "ENOENT")
|
|
326
|
+
return [];
|
|
327
|
+
throw error;
|
|
328
|
+
});
|
|
329
|
+
const namespaces = files.filter((file) => file.endsWith(".jsonl")).sort();
|
|
330
|
+
if (!url.pathname.endsWith("/data") || url.searchParams.get("view") === "records") {
|
|
331
|
+
const records = await Promise.all(namespaces.map(async (file) => parseMemoryFile(await readFile(join(root, file), "utf8"), file.slice(0, -6))));
|
|
332
|
+
value = memoryPage(records, url.searchParams);
|
|
333
|
+
compact = true;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
// Keep the raw namespace API shape, but never cut a JSONL record in half.
|
|
337
|
+
value = await Promise.all(namespaces.map(async (file) => ({
|
|
338
|
+
namespace: file.slice(0, -6),
|
|
339
|
+
records: await readFile(join(root, file), "utf8"),
|
|
340
|
+
})));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
await json(response, 404, { error: "Not found" });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (url.pathname.endsWith("/data")) {
|
|
348
|
+
await json(response, 200, value, compact, page !== "/memory");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
await send(response, 200, "text/html; charset=utf-8", renderPage(page, redact(value, compact, page !== "/memory")));
|
|
352
|
+
}
|
|
353
|
+
const unsubscribeRevision = options.agent.harness.subscribeCommits((publication) => {
|
|
354
|
+
if (publication.changes.length)
|
|
355
|
+
revision++;
|
|
356
|
+
});
|
|
357
|
+
await new Promise((resolve, reject) => {
|
|
358
|
+
server.once("error", reject);
|
|
359
|
+
server.listen(options.port ?? 7750, options.host ?? "localhost", () => {
|
|
360
|
+
server.off("error", reject);
|
|
361
|
+
resolve();
|
|
362
|
+
});
|
|
363
|
+
}).catch((error) => {
|
|
364
|
+
unsubscribeRevision();
|
|
365
|
+
throw error;
|
|
366
|
+
});
|
|
367
|
+
const address = server.address();
|
|
368
|
+
if (!address || typeof address === "string")
|
|
369
|
+
throw new Error("Missing web server address");
|
|
370
|
+
return {
|
|
371
|
+
address,
|
|
372
|
+
async close() {
|
|
373
|
+
unsubscribeRevision();
|
|
374
|
+
for (const [stream, unsubscribe] of streams) {
|
|
375
|
+
unsubscribe();
|
|
376
|
+
stream.end();
|
|
377
|
+
}
|
|
378
|
+
server.closeAllConnections();
|
|
379
|
+
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|