@kingcrab/pi-imessage 0.0.43 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +101 -127
  2. package/dist/agent/behavior.js +79 -0
  3. package/dist/agent/chats.js +67 -0
  4. package/dist/agent/commands.js +163 -0
  5. package/dist/agent/compaction.js +51 -0
  6. package/dist/agent/deliver.js +139 -0
  7. package/dist/agent/direct-send.js +68 -0
  8. package/dist/agent/harness.js +79 -0
  9. package/dist/agent/health.js +32 -0
  10. package/dist/agent/models.js +69 -0
  11. package/dist/agent/progress-format.js +87 -0
  12. package/dist/agent/progress.js +135 -0
  13. package/dist/agent/prompt.js +79 -0
  14. package/dist/agent/replies.js +9 -0
  15. package/dist/agent/run.js +159 -0
  16. package/dist/agent/scheduling.js +290 -0
  17. package/dist/agent/time.js +18 -0
  18. package/dist/cli.js +88 -191
  19. package/dist/config/settings.js +60 -0
  20. package/dist/extensions/browser.js +326 -0
  21. package/dist/extensions/final-text.js +23 -0
  22. package/dist/extensions/memory.js +109 -0
  23. package/dist/extensions/message.js +33 -0
  24. package/dist/extensions/read-image.js +72 -0
  25. package/dist/extensions/schedules.js +41 -0
  26. package/dist/extensions/subagent.js +54 -0
  27. package/dist/extensions/web.js +99 -0
  28. package/dist/extensions/workspace.js +98 -0
  29. package/dist/main.js +568 -88
  30. package/dist/transport/attachments.js +37 -0
  31. package/dist/transport/http.js +110 -0
  32. package/dist/{rich-text.js → transport/rich-text.js} +25 -13
  33. package/dist/{send.js → transport/send.js} +13 -13
  34. package/dist/transport/watch.js +124 -0
  35. package/dist/web/app.js +748 -0
  36. package/dist/web/history.js +87 -0
  37. package/dist/web/memory.js +81 -0
  38. package/dist/web/page.js +28 -0
  39. package/dist/web/schedules.js +74 -0
  40. package/dist/web/server.js +348 -0
  41. package/dist/web/style.css +543 -0
  42. package/dist/web/tasks.js +63 -0
  43. package/docs/adr/0001-durable-owns-state.md +11 -0
  44. package/docs/adr/0002-module-layering.md +11 -0
  45. package/docs/adr/0003-replies-at-most-once.md +11 -0
  46. package/docs/adr/0004-native-extensions.md +11 -0
  47. package/docs/adr/0005-history-migration.md +11 -0
  48. package/docs/adr/0006-jsonl-storage.md +11 -0
  49. package/docs/adr/0007-images-by-path.md +11 -0
  50. package/docs/adr/0008-compaction-and-run.md +11 -0
  51. package/docs/adr/0009-one-shot-cutover.md +11 -0
  52. package/docs/adr/0010-test-budget.md +11 -0
  53. package/docs/adr/0011-reconcile-unsent-answers.md +24 -0
  54. package/docs/adr/0012-run-in-owned-conversation.md +22 -0
  55. package/docs/adr/0013-thin-adapters-around-durable.md +18 -0
  56. package/docs/adr/0014-ephemeral-private-storage.md +13 -0
  57. package/docs/adr/0015-ordinary-stop-keeps-native-inbox.md +21 -0
  58. package/docs/adr/0016-process-http-transport.md +21 -0
  59. package/docs/adr/0017-browser-cli-owned-scope.md +22 -0
  60. package/docs/adr/0018-tasks-without-prompt-api.md +19 -0
  61. package/docs/adr/0019-remove-scheduled-services.md +41 -0
  62. package/docs/adr/0020-chat-task-progress.md +41 -0
  63. package/docs/adr/0021-readable-progress-without-link-previews.md +33 -0
  64. package/docs/adr/0022-progress-admission-and-settlement.md +35 -0
  65. package/docs/adr/0023-retain-progress-type-prefixes.md +18 -0
  66. package/docs/adr/0024-status-marker-before-type-prefix.md +13 -0
  67. package/docs/adr/0025-reusable-progress-and-command-preview.md +49 -0
  68. package/docs/adr/0026-inline-command-only-requests.md +16 -0
  69. package/docs/adr/0027-command-preview-width.md +10 -0
  70. package/docs/adr/0028-command-preview-120.md +8 -0
  71. package/docs/adr/0030-shared-web-read-cache.md +33 -0
  72. package/docs/adr/0031-quiet-compaction-progress.md +32 -0
  73. package/docs/adr/0032-native-background-schedules.md +58 -0
  74. package/docs/adr/0033-operator-scheduled-run.md +23 -0
  75. package/docs/adr/0034-machine-local-schedule-time.md +36 -0
  76. package/docs/adr/0035-external-business-task-extensions.md +56 -0
  77. package/docs/adr/0036-workspace-policy-and-neutral-service-id.md +37 -0
  78. package/docs/adr/0037-restart-during-in-flight-work.md +30 -0
  79. package/docs/adr/0038-restart-any-time.md +25 -0
  80. package/docs/adr/0039-rerun-interrupted-tools.md +24 -0
  81. package/docs/adr/0040-chat-progress-notices.md +44 -0
  82. package/docs/adr/0041-restart-and-recovery.md +28 -0
  83. package/docs/adr/0042-background-schedules-and-workspace-extensions.md +44 -0
  84. package/docs/adr/0043-remove-legacy-migration.md +23 -0
  85. package/docs/adr/0044-message-channels.md +32 -0
  86. package/docs/adr/0045-chat-scheduling-tools.md +51 -0
  87. package/docs/adr/0046-selective-command-redaction.md +31 -0
  88. package/docs/adr/0047-one-scheduling-path.md +158 -0
  89. package/docs/adr/README.md +50 -0
  90. package/docs/api.md +92 -0
  91. package/docs/architecture.md +164 -0
  92. package/docs/browser.md +93 -0
  93. package/docs/memory.md +27 -0
  94. package/docs/screenshot.png +0 -0
  95. package/docs/workspace-extensions.md +133 -0
  96. package/examples/workspace-extensions/system-context/index.ts +69 -0
  97. package/examples/workspace-extensions/system-context/package.json +3 -0
  98. package/ops/README.md +267 -0
  99. package/ops/preflight.mjs +28 -0
  100. package/package.json +36 -37
  101. package/dist/agent.d.ts +0 -84
  102. package/dist/agent.d.ts.map +0 -1
  103. package/dist/agent.js +0 -780
  104. package/dist/agent.js.map +0 -1
  105. package/dist/cli.d.ts +0 -15
  106. package/dist/cli.d.ts.map +0 -1
  107. package/dist/cli.js.map +0 -1
  108. package/dist/imessage.d.ts +0 -33
  109. package/dist/imessage.d.ts.map +0 -1
  110. package/dist/imessage.js +0 -76
  111. package/dist/imessage.js.map +0 -1
  112. package/dist/logger.d.ts +0 -33
  113. package/dist/logger.d.ts.map +0 -1
  114. package/dist/logger.js +0 -104
  115. package/dist/logger.js.map +0 -1
  116. package/dist/main.d.ts +0 -5
  117. package/dist/main.d.ts.map +0 -1
  118. package/dist/main.js.map +0 -1
  119. package/dist/memory.d.ts +0 -53
  120. package/dist/memory.d.ts.map +0 -1
  121. package/dist/memory.js +0 -196
  122. package/dist/memory.js.map +0 -1
  123. package/dist/model-health.d.ts +0 -11
  124. package/dist/model-health.d.ts.map +0 -1
  125. package/dist/model-health.js +0 -69
  126. package/dist/model-health.js.map +0 -1
  127. package/dist/pipeline.d.ts +0 -27
  128. package/dist/pipeline.d.ts.map +0 -1
  129. package/dist/pipeline.js +0 -59
  130. package/dist/pipeline.js.map +0 -1
  131. package/dist/queue.d.ts +0 -23
  132. package/dist/queue.d.ts.map +0 -1
  133. package/dist/queue.js +0 -89
  134. package/dist/queue.js.map +0 -1
  135. package/dist/reminders.d.ts +0 -42
  136. package/dist/reminders.d.ts.map +0 -1
  137. package/dist/reminders.js +0 -207
  138. package/dist/reminders.js.map +0 -1
  139. package/dist/rich-text.d.ts +0 -26
  140. package/dist/rich-text.d.ts.map +0 -1
  141. package/dist/rich-text.js.map +0 -1
  142. package/dist/self-echo.d.ts +0 -19
  143. package/dist/self-echo.d.ts.map +0 -1
  144. package/dist/self-echo.js +0 -54
  145. package/dist/self-echo.js.map +0 -1
  146. package/dist/send.d.ts +0 -24
  147. package/dist/send.d.ts.map +0 -1
  148. package/dist/send.js.map +0 -1
  149. package/dist/settings.d.ts +0 -44
  150. package/dist/settings.d.ts.map +0 -1
  151. package/dist/settings.js +0 -80
  152. package/dist/settings.js.map +0 -1
  153. package/dist/store.d.ts +0 -57
  154. package/dist/store.d.ts.map +0 -1
  155. package/dist/store.js +0 -124
  156. package/dist/store.js.map +0 -1
  157. package/dist/tasks.d.ts +0 -95
  158. package/dist/tasks.d.ts.map +0 -1
  159. package/dist/tasks.js +0 -384
  160. package/dist/tasks.js.map +0 -1
  161. package/dist/types.d.ts +0 -93
  162. package/dist/types.d.ts.map +0 -1
  163. package/dist/types.js +0 -38
  164. package/dist/types.js.map +0 -1
  165. package/dist/watch.d.ts +0 -21
  166. package/dist/watch.d.ts.map +0 -1
  167. package/dist/watch.js +0 -219
  168. package/dist/watch.js.map +0 -1
  169. package/dist/web/data.d.ts +0 -11
  170. package/dist/web/data.d.ts.map +0 -1
  171. package/dist/web/data.js +0 -56
  172. package/dist/web/data.js.map +0 -1
  173. package/dist/web/html.d.ts +0 -5
  174. package/dist/web/html.d.ts.map +0 -1
  175. package/dist/web/html.js +0 -16
  176. package/dist/web/html.js.map +0 -1
  177. package/dist/web/index.d.ts +0 -25
  178. package/dist/web/index.d.ts.map +0 -1
  179. package/dist/web/index.js +0 -335
  180. package/dist/web/index.js.map +0 -1
  181. package/dist/web/render.d.ts +0 -11
  182. package/dist/web/render.d.ts.map +0 -1
  183. package/dist/web/render.js +0 -56
  184. package/dist/web/render.js.map +0 -1
  185. package/dist/web/templates/logs.eta +0 -75
  186. package/dist/web/templates/memory.eta +0 -91
  187. 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
+ }
@@ -0,0 +1,28 @@
1
+ function escapeHtml(text) {
2
+ return text.replace(/[&<>"']/g, (value) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
+ ["/", "Recent Chats"],
9
+ ["/tasks", "Tasks"],
10
+ ["/scheduled", "Scheduled"],
11
+ ["/logs", "Logs"],
12
+ ["/memory", "Memory"],
13
+ ["/settings", "Settings"],
14
+ ]
15
+ .map(([href, label]) => `<a href="${href}"${href === active ? ' class="active" aria-current="page"' : ""}>${label}</a>`)
16
+ .join("");
17
+ return `<!doctype html>
18
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
19
+ <title>blue — pi-imessage</title><link rel="stylesheet" href="/assets/style.css">
20
+ <script src="/assets/app.js" defer></script></head>
21
+ <body><nav class="app-nav" aria-label="Main navigation">${navigation}</nav>
22
+ <main class="content" id="main"><header class="page-header"><h1 id="title">Recent Chats</h1>
23
+ <div class="connection"><button id="refresh" type="button">Refresh</button></div></header>
24
+ <div id="toolbar"></div><p id="notice" role="status" hidden></p><div id="view" aria-busy="true"></div>
25
+ <details class="native-state"><summary>Native state</summary><pre id="state">${escapeHtml(state)}</pre></details></main>
26
+ <aside class="chat-list" id="chat-list" aria-label="Conversations" hidden></aside>
27
+ </body></html>`;
28
+ }
@@ -0,0 +1,74 @@
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
+ const executions = [];
15
+ let cursor;
16
+ do {
17
+ const page = await tx.scanTasks({ conversationId: schedule.conversationId, kind: EXECUTION_KIND }, 100, cursor);
18
+ executions.push(...page.items);
19
+ cursor = page.next;
20
+ } while (cursor);
21
+ // Native scans merge live/terminal records; their order is not execution chronology.
22
+ const runs = executions
23
+ .sort((a, b) => b.id - a.id)
24
+ .slice(0, 10)
25
+ .map((run) => {
26
+ const outcome = run.state.outcome;
27
+ const result = outcome?.status === "completed" &&
28
+ outcome.result &&
29
+ typeof outcome.result === "object" &&
30
+ !Array.isArray(outcome.result)
31
+ ? outcome.result
32
+ : undefined;
33
+ const receipt = typeof result?.requestId === "string"
34
+ ? sends.requests.find((item) => item.requestId === result.requestId)
35
+ : undefined;
36
+ return {
37
+ id: run.id,
38
+ input: run.input,
39
+ status: outcome?.status ?? run.state.status,
40
+ result,
41
+ error: outcome?.status === "failed" || outcome?.status === "faulted"
42
+ ? outcome.error.message
43
+ : undefined,
44
+ delivery: receipt?.textStatus ?? (result?.requestId ? "queued" : undefined),
45
+ };
46
+ });
47
+ jobs.push({
48
+ id: schedule.id,
49
+ name: schedule.name,
50
+ kind: schedule.kind,
51
+ enabled: schedule.enabled,
52
+ time: schedule.time,
53
+ intervalMs: schedule.intervalMs,
54
+ timezone: schedule.timezone,
55
+ chatGuid: schedule.chatGuid,
56
+ extension: schedule.extension,
57
+ execution: schedule.execution,
58
+ conversationId: schedule.conversationId,
59
+ taskId: schedule.taskId,
60
+ status: task?.state.outcome?.status ?? task?.state.status ?? "missing",
61
+ phase: state?.phase,
62
+ nextAt: state?.phase === "sleep" ? state.at : undefined,
63
+ runs,
64
+ });
65
+ }
66
+ return {
67
+ jobs,
68
+ recent: jobs
69
+ .flatMap((job) => job.runs.map((run) => ({ ...run, jobId: job.id, name: job.name })))
70
+ .sort((a, b) => b.id - a.id)
71
+ .slice(0, 10),
72
+ };
73
+ }, BACKGROUND_CONTEXT);
74
+ }
@@ -0,0 +1,348 @@
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 } 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 { 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
+ const server = createServer((request, response) => {
117
+ void handle(request, response).catch((error) => {
118
+ if (!response.headersSent)
119
+ void json(response, 400, {
120
+ ok: false,
121
+ error: error instanceof Error ? error.message : String(error),
122
+ }).catch(() => response.destroy());
123
+ else
124
+ response.end();
125
+ });
126
+ });
127
+ async function handle(request, response) {
128
+ const url = new URL(request.url ?? "/", "http://localhost");
129
+ if (request.method === "GET" && url.pathname === "/favicon.ico") {
130
+ response.writeHead(204);
131
+ response.end();
132
+ return;
133
+ }
134
+ if (request.headers["sec-fetch-site"] === "cross-site" ||
135
+ (request.headers.origin && new URL(request.headers.origin).host !== request.headers.host)) {
136
+ await json(response, 403, { error: "Cross-origin requests are not allowed" });
137
+ return;
138
+ }
139
+ if (request.method === "GET" && url.pathname === "/health/model") {
140
+ try {
141
+ await json(response, 200, await options.agent.health());
142
+ }
143
+ catch (error) {
144
+ await json(response, 503, {
145
+ ok: false,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ });
148
+ }
149
+ return;
150
+ }
151
+ if (request.method !== "GET") {
152
+ await json(response, 404, { error: "Not found" });
153
+ return;
154
+ }
155
+ if (url.pathname === "/events") {
156
+ response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store" });
157
+ response.write(`event: ready\ndata: ${JSON.stringify({ revision: viewRevision() })}\n\n`);
158
+ const unsubscribe = options.agent.harness.subscribeCommits(() => {
159
+ if (!response.write('data: {"updated":true}\n\n')) {
160
+ unsubscribe();
161
+ response.destroy();
162
+ }
163
+ });
164
+ streams.set(response, unsubscribe);
165
+ response.on("error", () => {
166
+ unsubscribe();
167
+ streams.delete(response);
168
+ });
169
+ response.on("close", () => {
170
+ unsubscribe();
171
+ streams.delete(response);
172
+ });
173
+ return;
174
+ }
175
+ const asset = url.pathname === "/assets/style.css"
176
+ ? "style.css"
177
+ : url.pathname === "/assets/app.js"
178
+ ? "app.js"
179
+ : undefined;
180
+ if (asset) {
181
+ const path = new URL(asset, import.meta.url);
182
+ await send(response, 200, asset.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8", await readFile(path, "utf8"));
183
+ return;
184
+ }
185
+ let value;
186
+ let compact = false;
187
+ const page = url.pathname.replace(/\/data$/, "");
188
+ if (page === "/" || page === "/chat") {
189
+ const id = url.searchParams.get("conversationId");
190
+ if (id) {
191
+ if (!/^\d+$/.test(id))
192
+ throw new Error("Invalid conversation ID");
193
+ const conversation = await options.agent.harness.conversation(Number(id), BACKGROUND_CONTEXT);
194
+ if (!conversation) {
195
+ await json(response, 404, { error: "Conversation not found" });
196
+ return;
197
+ }
198
+ const cursor = readCursor(url.searchParams.get("cursor"));
199
+ const detail = {
200
+ history: await history(conversation.id, cursor),
201
+ delivery: await delivery(conversation.id),
202
+ live: await options.agent.harness.snapshot(LiveDoc, conversation.id, BACKGROUND_CONTEXT),
203
+ revision: viewRevision(),
204
+ };
205
+ if (!url.pathname.endsWith("/data") || url.searchParams.get("view") === "display") {
206
+ const [chats, configuration] = await Promise.all([
207
+ options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
208
+ settings(),
209
+ ]);
210
+ value = {
211
+ history: displayHistory(detail.history),
212
+ delivery: detail.delivery,
213
+ chat: chats?.items.find((chat) => chat.conversationId === conversation.id),
214
+ settings: { chatAllowlist: configuration.chatAllowlist },
215
+ revision: detail.revision,
216
+ };
217
+ compact = true;
218
+ }
219
+ else
220
+ value = {
221
+ history: detail.history,
222
+ delivery: detail.delivery,
223
+ live: detail.live,
224
+ agent: await conversation.agent(BACKGROUND_CONTEXT),
225
+ };
226
+ }
227
+ else if (!url.pathname.endsWith("/data") || url.searchParams.get("view") === "overview") {
228
+ value = await overview();
229
+ compact = true;
230
+ }
231
+ else
232
+ value = {
233
+ chats: await options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
234
+ sessions: await options.agent.harness.snapshot(Sessions, BACKGROUND_CONTEXT),
235
+ tasks: await options.agent.harness.commit((tx) => tx.scanTasks({}, 100), BACKGROUND_CONTEXT),
236
+ sends: await options.agent.harness.snapshot(DirectSends, BACKGROUND_CONTEXT),
237
+ inspection: await options.agent.harness.inspect(BACKGROUND_CONTEXT),
238
+ };
239
+ }
240
+ else if (page === "/scheduled") {
241
+ value = await readSchedules(options.agent.harness);
242
+ compact = true;
243
+ }
244
+ else if (page === "/tasks") {
245
+ value = {
246
+ ...(await readTaskTree(options.agent.harness)),
247
+ schedules: await options.agent.harness.snapshot(Schedules, BACKGROUND_CONTEXT),
248
+ chats: await options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
249
+ sessions: await options.agent.harness.snapshot(Sessions, BACKGROUND_CONTEXT),
250
+ revision: viewRevision(),
251
+ };
252
+ compact = true;
253
+ }
254
+ else if (url.pathname === "/tasks/task/data") {
255
+ const id = url.searchParams.get("taskId");
256
+ if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
257
+ throw new Error("Invalid task ID");
258
+ value = await options.agent.harness.getTask(Number(id), BACKGROUND_CONTEXT);
259
+ if (!value) {
260
+ await json(response, 404, { error: "Task not found" });
261
+ return;
262
+ }
263
+ }
264
+ else if (page === "/settings")
265
+ value = await settings();
266
+ else if (page === "/logs") {
267
+ const cursor = readCursor(url.searchParams.get("cursor"));
268
+ const tasks = await options.agent.harness.commit((tx) => tx.scanTasks({}, 100, cursor), BACKGROUND_CONTEXT);
269
+ let hostLog = "";
270
+ try {
271
+ const file = await open(join(options.workingDir, "service.log"), "r");
272
+ try {
273
+ const size = (await file.stat()).size;
274
+ const buffer = Buffer.alloc(Math.min(size, 50000));
275
+ const read = await file.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length));
276
+ hostLog = buffer.subarray(0, read.bytesRead).toString();
277
+ }
278
+ finally {
279
+ await file.close();
280
+ }
281
+ }
282
+ catch (error) {
283
+ if (error.code !== "ENOENT")
284
+ throw error;
285
+ }
286
+ value = { tasks, hostLog };
287
+ }
288
+ else if (page === "/memory") {
289
+ const root = join(options.workingDir, "skills", "file-memory", "namespaces");
290
+ const files = await readdir(root, { recursive: true }).catch((error) => {
291
+ if (error.code === "ENOENT")
292
+ return [];
293
+ throw error;
294
+ });
295
+ const namespaces = files.filter((file) => file.endsWith(".jsonl")).sort();
296
+ if (!url.pathname.endsWith("/data") || url.searchParams.get("view") === "records") {
297
+ const records = await Promise.all(namespaces.map(async (file) => parseMemoryFile(await readFile(join(root, file), "utf8"), file.slice(0, -6))));
298
+ value = memoryPage(records, url.searchParams);
299
+ compact = true;
300
+ }
301
+ else {
302
+ // Keep the raw namespace API shape, but never cut a JSONL record in half.
303
+ value = await Promise.all(namespaces.map(async (file) => ({
304
+ namespace: file.slice(0, -6),
305
+ records: await readFile(join(root, file), "utf8"),
306
+ })));
307
+ }
308
+ }
309
+ else {
310
+ await json(response, 404, { error: "Not found" });
311
+ return;
312
+ }
313
+ if (url.pathname.endsWith("/data")) {
314
+ await json(response, 200, value, compact, page !== "/memory");
315
+ return;
316
+ }
317
+ await send(response, 200, "text/html; charset=utf-8", renderPage(page, redact(value, compact, page !== "/memory")));
318
+ }
319
+ const unsubscribeRevision = options.agent.harness.subscribeCommits((publication) => {
320
+ if (publication.changes.length)
321
+ revision++;
322
+ });
323
+ await new Promise((resolve, reject) => {
324
+ server.once("error", reject);
325
+ server.listen(options.port ?? 7750, options.host ?? "localhost", () => {
326
+ server.off("error", reject);
327
+ resolve();
328
+ });
329
+ }).catch((error) => {
330
+ unsubscribeRevision();
331
+ throw error;
332
+ });
333
+ const address = server.address();
334
+ if (!address || typeof address === "string")
335
+ throw new Error("Missing web server address");
336
+ return {
337
+ address,
338
+ async close() {
339
+ unsubscribeRevision();
340
+ for (const [stream, unsubscribe] of streams) {
341
+ unsubscribe();
342
+ stream.end();
343
+ }
344
+ server.closeAllConnections();
345
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
346
+ },
347
+ };
348
+ }