@kingcrab/pi-imessage 0.0.42 → 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 (183) hide show
  1. package/README.md +101 -108
  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 +571 -74
  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 +33 -45
  101. package/dist/agent.d.ts +0 -60
  102. package/dist/agent.d.ts.map +0 -1
  103. package/dist/agent.js +0 -715
  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 -67
  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/rich-text.d.ts +0 -26
  136. package/dist/rich-text.d.ts.map +0 -1
  137. package/dist/rich-text.js.map +0 -1
  138. package/dist/self-echo.d.ts +0 -19
  139. package/dist/self-echo.d.ts.map +0 -1
  140. package/dist/self-echo.js +0 -54
  141. package/dist/self-echo.js.map +0 -1
  142. package/dist/send.d.ts +0 -24
  143. package/dist/send.d.ts.map +0 -1
  144. package/dist/send.js.map +0 -1
  145. package/dist/settings.d.ts +0 -44
  146. package/dist/settings.d.ts.map +0 -1
  147. package/dist/settings.js +0 -80
  148. package/dist/settings.js.map +0 -1
  149. package/dist/store.d.ts +0 -57
  150. package/dist/store.d.ts.map +0 -1
  151. package/dist/store.js +0 -124
  152. package/dist/store.js.map +0 -1
  153. package/dist/tasks.d.ts +0 -95
  154. package/dist/tasks.d.ts.map +0 -1
  155. package/dist/tasks.js +0 -384
  156. package/dist/tasks.js.map +0 -1
  157. package/dist/types.d.ts +0 -93
  158. package/dist/types.d.ts.map +0 -1
  159. package/dist/types.js +0 -38
  160. package/dist/types.js.map +0 -1
  161. package/dist/watch.d.ts +0 -21
  162. package/dist/watch.d.ts.map +0 -1
  163. package/dist/watch.js +0 -219
  164. package/dist/watch.js.map +0 -1
  165. package/dist/web/data.d.ts +0 -11
  166. package/dist/web/data.d.ts.map +0 -1
  167. package/dist/web/data.js +0 -56
  168. package/dist/web/data.js.map +0 -1
  169. package/dist/web/html.d.ts +0 -5
  170. package/dist/web/html.d.ts.map +0 -1
  171. package/dist/web/html.js +0 -16
  172. package/dist/web/html.js.map +0 -1
  173. package/dist/web/index.d.ts +0 -23
  174. package/dist/web/index.d.ts.map +0 -1
  175. package/dist/web/index.js +0 -279
  176. package/dist/web/index.js.map +0 -1
  177. package/dist/web/render.d.ts +0 -11
  178. package/dist/web/render.d.ts.map +0 -1
  179. package/dist/web/render.js +0 -56
  180. package/dist/web/render.js.map +0 -1
  181. package/dist/web/templates/logs.eta +0 -75
  182. package/dist/web/templates/memory.eta +0 -91
  183. package/dist/web/templates/page.eta +0 -98
@@ -0,0 +1,748 @@
1
+ const page = location.pathname === "/" ? "/chat" : location.pathname;
2
+ const conversationId = new URLSearchParams(location.search).get("conversationId");
3
+ const view = document.getElementById("view");
4
+ const toolbar = document.getElementById("toolbar");
5
+ const notice = document.getElementById("notice");
6
+ const state = document.getElementById("state");
7
+ const main = document.getElementById("main");
8
+ const sidebar = document.getElementById("chat-list");
9
+ document.getElementById("connection")?.remove();
10
+ let data = JSON.parse(state.textContent);
11
+ let chats = [];
12
+ let memoryQuery = { q: "", namespace: "", kind: "", status: "active", page: 1 };
13
+ let refreshing = false;
14
+
15
+ document.body.classList.add(`page-${page.slice(1)}`);
16
+ if (conversationId) document.body.classList.add("conversation-detail");
17
+ document.getElementById("title").textContent =
18
+ {
19
+ "/chat": "Recent Chats",
20
+ "/tasks": "Tasks",
21
+ "/scheduled": "Scheduled tasks",
22
+ "/logs": "Logs",
23
+ "/memory": "Memory / 记忆与规则",
24
+ "/settings": "Settings",
25
+ }[page] || "pi-imessage";
26
+
27
+ function element(tag, text, className) {
28
+ const node = document.createElement(tag);
29
+ if (text !== undefined) node.textContent = String(text);
30
+ if (className) node.className = className;
31
+ return node;
32
+ }
33
+
34
+ function link(text, href, className) {
35
+ const node = element("a", text, className);
36
+ node.href = href;
37
+ return node;
38
+ }
39
+
40
+ function showNotice(text) {
41
+ notice.textContent = text;
42
+ notice.hidden = !text;
43
+ }
44
+
45
+ async function fetchData(url) {
46
+ const response = await fetch(url, { cache: "no-store" });
47
+ if (!response.ok) throw new Error(`Unable to load ${url.split("?")[0]} (${response.status})`);
48
+ return response.json();
49
+ }
50
+
51
+ function messageText(content) {
52
+ if (typeof content === "string") return content;
53
+ if (!Array.isArray(content)) return "";
54
+ return content
55
+ .map((block) => {
56
+ if (block.type === "text") return block.text;
57
+ if (block.type === "image") return "[Image attachment]";
58
+ if (block.type === "toolCall") return `[Tool: ${block.name}]`;
59
+ return "";
60
+ })
61
+ .filter(Boolean)
62
+ .join("\n");
63
+ }
64
+
65
+ function messages(detail, chatGuid) {
66
+ return (detail.history?.items || [])
67
+ .flatMap((entry) =>
68
+ (entry.model || [])
69
+ .filter((message) => ["user", "assistant"].includes(message.role) && message.stopReason !== "toolUse")
70
+ .map((message) => {
71
+ const original = entry.data?.original || {};
72
+ const content = messageText(message.content);
73
+ const prefix = content.match(/^\[(?:Group '([^']*)'|DM|SMS) from ([^\]]+)\] /);
74
+ const sender =
75
+ message.role === "assistant"
76
+ ? "pi"
77
+ : original.senderName || original.sender || prefix?.[2] || "user";
78
+ return {
79
+ id: `${entry.id}-${message.role}`,
80
+ time: Number(message.timestamp) || Date.parse(original.date) || 0,
81
+ role: message.role,
82
+ status:
83
+ message.role === "assistant" && !entry.data?.legacy
84
+ ? detail.delivery?.drafts?.[entry.id]?.status ||
85
+ detail.delivery?.answers?.[entry.id] ||
86
+ "pending"
87
+ : undefined,
88
+ sender,
89
+ groupName: original.groupName || prefix?.[1],
90
+ text: prefix ? content.slice(prefix[0].length) : content,
91
+ channel: chatGuid.includes(";+;") ? "GROUP" : chatGuid.startsWith("SMS;") ? "SMS" : "DM",
92
+ };
93
+ }),
94
+ )
95
+ .filter((message) => message.text)
96
+ .sort((left, right) => left.time - right.time);
97
+ }
98
+
99
+ function formatTime(timestamp) {
100
+ if (!timestamp) return "[unknown time]";
101
+ const date = new Date(timestamp);
102
+ const parts = [
103
+ date.getMonth() + 1,
104
+ date.getDate(),
105
+ date.getHours(),
106
+ date.getMinutes(),
107
+ date.getSeconds(),
108
+ ].map((value) => String(value).padStart(2, "0"));
109
+ return `[${date.getFullYear()}-${parts[0]}-${parts[1]} ${parts[2]}:${parts[3]}:${parts[4]}]`;
110
+ }
111
+
112
+ function chatName(chat, rows) {
113
+ const named = rows.findLast((row) => row.groupName);
114
+ if (named) return named.groupName;
115
+ if (chat.history?.groupName) return chat.history.groupName;
116
+ if (chat.chatGuid.includes(";+;")) return `Group ${chat.chatGuid.split(";").at(-1).replace(/^chat/, "")}`;
117
+ return chat.chatGuid.split(";").at(-1) || `Conversation ${chat.conversationId}`;
118
+ }
119
+
120
+ function compareConversations(left, right) {
121
+ const leftActivity = left.updatedAt ?? left.rows?.at(-1)?.time ?? 0;
122
+ const rightActivity = right.updatedAt ?? right.rows?.at(-1)?.time ?? 0;
123
+ return (
124
+ rightActivity - leftActivity || (right.conversationId ?? right.id) - (left.conversationId ?? left.id)
125
+ );
126
+ }
127
+
128
+ function replyEnabled(settings, chatGuid) {
129
+ const { whitelist = [], blacklist = [] } = settings.chatAllowlist || {};
130
+ if (blacklist.includes(chatGuid)) return false;
131
+ if (whitelist.includes(chatGuid)) return true;
132
+ return !blacklist.includes("*") && whitelist.includes("*");
133
+ }
134
+
135
+ function chatCard(chat, full, expanded) {
136
+ const card = element("section", undefined, "card");
137
+ card.id = `chat-${chat.conversationId}`;
138
+ const header = element("header", undefined, "card-header");
139
+ const heading = element("span");
140
+ heading.append(link(chat.name, `/chat?conversationId=${chat.conversationId}`));
141
+ heading.append(element("span", ` ${chat.rows.length} recent msgs `, "meta"));
142
+ heading.append(link("Task tree", `/tasks?conversationId=${chat.conversationId}`, "meta"));
143
+ header.append(heading);
144
+ const reply = element("span", chat.enabled ? "[on]" : "[off]", "reply-state");
145
+ reply.title = "Reply allowlist status (read-only). Change settings.json to update it.";
146
+ header.append(reply);
147
+ card.append(header);
148
+ const body = element("div", undefined, "card-messages");
149
+ if (chat.error) body.append(element("p", chat.error, "muted"));
150
+ else if (!chat.rows.length) body.append(element("p", "No messages yet.", "empty"));
151
+ else {
152
+ const table = element("table");
153
+ table.setAttribute("aria-label", `Messages in ${chat.name}`);
154
+ const rows = full
155
+ ? chat.rows
156
+ : chat.rows.filter(
157
+ (message, index) =>
158
+ index >= chat.rows.length - 15 || expanded.has(`message-${chat.conversationId}-${message.id}`),
159
+ );
160
+ let previousTime;
161
+ for (const message of rows) {
162
+ if (previousTime && message.time - previousTime > 10 * 60 * 1000) {
163
+ const gap = table.insertRow().insertCell();
164
+ gap.colSpan = 6;
165
+ gap.textContent = "\u00a0";
166
+ }
167
+ previousTime = message.time;
168
+ const row = table.insertRow();
169
+ for (const [text, className] of [
170
+ [formatTime(message.time), "c-time"],
171
+ ["[sid]", "c-source"],
172
+ [message.role === "assistant" ? "->" : "<-", "c-arrow"],
173
+ [`[${message.channel}]`, "c-channel"],
174
+ [`${message.sender}:`, "c-sender"],
175
+ ]) {
176
+ row.append(element("td", text, `c-fix ${className}`));
177
+ }
178
+ const cell = element("td", undefined, "c-msg");
179
+ const detail = element("details", undefined, "message-detail");
180
+ detail.id = `message-${chat.conversationId}-${message.id}`;
181
+ const firstLine = message.text.split("\n").find((line) => line.trim()) || "[Empty message]";
182
+ const summary = element("summary", firstLine.length > 160 ? `${firstLine.slice(0, 160)}…` : firstLine);
183
+ if (message.status && message.status !== "sent") {
184
+ const status = element("span", `[${message.status}] `, "meta");
185
+ status.title = "Transport receipt / draft status; this is not confirmed as sent.";
186
+ summary.prepend(status);
187
+ }
188
+ detail.append(summary);
189
+ detail.append(element("pre", message.text));
190
+ cell.append(detail);
191
+ row.append(cell);
192
+ }
193
+ body.append(table);
194
+ }
195
+ card.append(body);
196
+ return card;
197
+ }
198
+
199
+ function renderChats() {
200
+ const expanded = new Set(Array.from(view.querySelectorAll("details[open]"), (node) => node.id));
201
+ const positions = new Map(
202
+ Array.from(view.querySelectorAll(".card"), (card) => {
203
+ const body = card.querySelector(".card-messages");
204
+ return [
205
+ card.id,
206
+ { top: body.scrollTop, bottom: body.scrollHeight - body.scrollTop - body.clientHeight < 20 },
207
+ ];
208
+ }),
209
+ );
210
+ view.replaceChildren();
211
+ sidebar.replaceChildren();
212
+ sidebar.hidden = false;
213
+ if (!chats.length) view.append(element("p", "No conversations.", "empty"));
214
+ for (const chat of chats) {
215
+ const card = chatCard(chat, Boolean(conversationId), expanded);
216
+ view.append(card);
217
+ for (const detail of card.querySelectorAll("details")) detail.open = expanded.has(detail.id);
218
+ if (!conversationId) {
219
+ const body = card.querySelector(".card-messages");
220
+ const previous = positions.get(card.id);
221
+ body.scrollTop = !previous || previous.bottom ? body.scrollHeight : previous.top;
222
+ }
223
+ sidebar.append(link(chat.name, `#chat-${chat.conversationId}`));
224
+ }
225
+ if (conversationId && data.history?.next) {
226
+ const next = new URL(location.href);
227
+ const bytes = new TextEncoder().encode(JSON.stringify(data.history.next));
228
+ next.searchParams.set(
229
+ "cursor",
230
+ btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""))
231
+ .replaceAll("+", "-")
232
+ .replaceAll("/", "_")
233
+ .replace(/=+$/, ""),
234
+ );
235
+ view.append(link("Older messages >", next.pathname + next.search, "pagination"));
236
+ }
237
+ }
238
+
239
+ async function loadChats() {
240
+ if ((!conversationId && Array.isArray(data.conversations)) || (conversationId && data.chat)) {
241
+ const selected = conversationId ? [{ ...data, ...data.chat }] : data.conversations;
242
+ chats = selected
243
+ .map((detail) => {
244
+ const rows = messages(detail, detail.chatGuid);
245
+ return {
246
+ ...detail,
247
+ rows,
248
+ name: chatName(detail, rows),
249
+ enabled: replyEnabled(data.settings, detail.chatGuid),
250
+ };
251
+ })
252
+ .sort(compareConversations);
253
+ renderChats();
254
+ return;
255
+ }
256
+ // Older running servers still return native state until their next restart.
257
+ const [index, settings] = await Promise.all([
258
+ conversationId ? fetchData("/chat/data") : Promise.resolve(data),
259
+ fetchData("/settings/data"),
260
+ ]);
261
+ const selected = (index.chats?.items || []).filter(
262
+ (chat) => !conversationId || String(chat.conversationId) === conversationId,
263
+ );
264
+ const loaded = [];
265
+ let offset = 0;
266
+ await Promise.all(
267
+ Array.from({ length: Math.min(4, selected.length) }, async () => {
268
+ while (offset < selected.length) {
269
+ const chat = selected[offset++];
270
+ try {
271
+ const detail = conversationId
272
+ ? data
273
+ : await fetchData(`/chat/data?conversationId=${chat.conversationId}`);
274
+ const rows = messages(detail, chat.chatGuid);
275
+ loaded.push({
276
+ ...chat,
277
+ rows,
278
+ name: chatName(chat, rows),
279
+ enabled: replyEnabled(settings, chat.chatGuid),
280
+ });
281
+ } catch (error) {
282
+ loaded.push({ ...chat, rows: [], name: chatName(chat, []), error: error.message });
283
+ }
284
+ }
285
+ }),
286
+ );
287
+ chats = loaded.sort(compareConversations);
288
+ renderChats();
289
+ }
290
+
291
+ function memoryRows() {
292
+ const records = [];
293
+ let incomplete = false;
294
+ for (const file of data) {
295
+ for (const line of file.records.split("\n").filter(Boolean)) {
296
+ try {
297
+ const record = JSON.parse(line);
298
+ if (typeof record.text !== "string") continue;
299
+ records.push({ ...record, namespace: record.namespace || file.namespace });
300
+ } catch {
301
+ incomplete = true;
302
+ }
303
+ }
304
+ }
305
+ const superseded = new Set(records.flatMap((record) => [record.supersedes || []].flat()));
306
+ for (const record of records)
307
+ record.effectiveStatus = superseded.has(record.id) ? "superseded" : record.status || "unknown";
308
+ if (incomplete)
309
+ showNotice("Memory snapshot is incomplete; some records or correction links may be missing.");
310
+ return records.sort((left, right) =>
311
+ String(right.created_at || "").localeCompare(String(left.created_at || "")),
312
+ );
313
+ }
314
+
315
+ function renderMemory() {
316
+ const paged = !Array.isArray(data);
317
+ if (paged) memoryQuery = { ...data.query, page: data.page };
318
+ toolbar.replaceChildren();
319
+ setupToolbar();
320
+ const records = paged ? data.records : memoryRows();
321
+ const filtered = paged
322
+ ? records
323
+ : records.filter(
324
+ (record) =>
325
+ (!memoryQuery.namespace || record.namespace === memoryQuery.namespace) &&
326
+ (!memoryQuery.kind || record.kind === memoryQuery.kind) &&
327
+ (!memoryQuery.status || record.effectiveStatus === memoryQuery.status) &&
328
+ JSON.stringify(record).toLowerCase().includes(memoryQuery.q.toLowerCase()),
329
+ );
330
+ const pages = paged ? data.pages : Math.max(1, Math.ceil(filtered.length / 20));
331
+ memoryQuery.page = Math.min(memoryQuery.page, pages);
332
+ if (paged && data.invalidRecords)
333
+ showNotice(`Skipped ${data.invalidRecords} invalid memory records. Source files were not modified.`);
334
+ view.replaceChildren(
335
+ element(
336
+ "p",
337
+ `${paged ? data.total : filtered.length} matching / ${paged ? data.recordCount : records.length} records · read-only`,
338
+ "counts",
339
+ ),
340
+ );
341
+ if (!filtered.length) view.append(element("p", "No matching memory records.", "empty"));
342
+ for (const record of paged
343
+ ? filtered
344
+ : filtered.slice((memoryQuery.page - 1) * 20, memoryQuery.page * 20)) {
345
+ const card = element("article", undefined, "memory-record");
346
+ const header = element("header", undefined, "row-head");
347
+ header.append(element("span", `${record.namespace} / ${record.kind || "unknown"}`));
348
+ header.append(
349
+ element("span", `${record.effectiveStatus} · ${record.event_time || record.created_at || ""}`, "meta"),
350
+ );
351
+ card.append(header, element("p", record.text, "memory-text"));
352
+ if (Array.isArray(record.subjects) && record.subjects.length)
353
+ card.append(element("p", record.subjects.join(" / "), "muted"));
354
+ const details = element("details", undefined, "record-details");
355
+ details.id = `record-${record.id}`;
356
+ const summary = element("summary", "...", "record-details-icon");
357
+ summary.setAttribute("aria-label", "Record details");
358
+ summary.title = "Details";
359
+ details.append(summary);
360
+ details.append(element("pre", JSON.stringify(record, null, 2)));
361
+ card.append(details);
362
+ view.append(card);
363
+ }
364
+ const pagination = element("nav", undefined, "pagination");
365
+ pagination.setAttribute("aria-label", "Memory pagination");
366
+ for (const [label, delta] of [
367
+ ["< Previous", -1],
368
+ ["Next >", 1],
369
+ ]) {
370
+ const button = element("button", label);
371
+ button.type = "button";
372
+ button.disabled = memoryQuery.page + delta < 1 || memoryQuery.page + delta > pages;
373
+ button.addEventListener("click", () => {
374
+ memoryQuery.page += delta;
375
+ if (paged) void refresh();
376
+ else renderMemory();
377
+ main.scrollTop = 0;
378
+ });
379
+ pagination.append(button);
380
+ if (delta === -1) pagination.append(element("span", `Page ${memoryQuery.page} / ${pages}`));
381
+ }
382
+ view.append(pagination);
383
+ }
384
+
385
+ function renderSettings() {
386
+ view.replaceChildren(
387
+ element(
388
+ "p",
389
+ "Read-only runtime configuration. Edit settings.json and use /reload to apply changes.",
390
+ "muted",
391
+ ),
392
+ );
393
+ for (const [title, rows] of [
394
+ [
395
+ "Chat replies",
396
+ [
397
+ ["Allowlist", data.chatAllowlist.whitelist],
398
+ ["Blocklist", data.chatAllowlist.blacklist],
399
+ ],
400
+ ],
401
+ [
402
+ "Rich text",
403
+ [
404
+ ["Enabled", data.richText.enabled],
405
+ ["Markdown", data.richText.markdown],
406
+ ],
407
+ ],
408
+ ]) {
409
+ const card = element("section", undefined, "card");
410
+ card.append(element("header", title, "card-header"));
411
+ const table = element("table", undefined, "setting-table");
412
+ for (const [name, value] of rows) {
413
+ const row = table.insertRow();
414
+ row.append(element("th", name));
415
+ const cell = row.insertCell();
416
+ if (Array.isArray(value)) {
417
+ const list = element("ul");
418
+ for (const item of value) list.append(element("li", item));
419
+ cell.append(value.length ? list : element("span", "None", "muted"));
420
+ } else cell.textContent = value ? "on" : "off";
421
+ }
422
+ card.append(table);
423
+ view.append(card);
424
+ }
425
+ }
426
+
427
+ function renderLogs() {
428
+ const scrollPositions = Array.from(view.querySelectorAll(".card-content"), (node) => ({
429
+ top: node.scrollTop,
430
+ bottom: node.scrollHeight - node.scrollTop - node.clientHeight < 20,
431
+ }));
432
+ view.replaceChildren();
433
+ const host = element("section", undefined, "card");
434
+ host.append(element("header", "service.log", "card-header"));
435
+ const hostBody = element("div", undefined, "card-content");
436
+ hostBody.append(
437
+ element("pre", data.hostLog || "No service.log yet. Foreground output remains in the startup Terminal."),
438
+ );
439
+ host.append(hostBody);
440
+ view.append(host);
441
+ const tasks = element("section", undefined, "card");
442
+ tasks.append(element("header", "Native execution history", "card-header"));
443
+ const taskBody = element("div", undefined, "card-content");
444
+ const table = element("table", undefined, "task-table");
445
+ const heading = table.insertRow();
446
+ for (const label of ["ID", "Type", "State", "Details"]) heading.append(element("th", label));
447
+ for (const task of data.tasks?.items || []) {
448
+ const row = table.insertRow();
449
+ for (const value of [task.id, task.kind || task.type || "task", task.state?.status || task.status || "—"])
450
+ row.append(element("td", value));
451
+ const detail = element("details");
452
+ detail.id = `task-${task.id}`;
453
+ detail.append(element("summary", "Details"), element("pre", JSON.stringify(task, null, 2)));
454
+ row.insertCell().append(detail);
455
+ }
456
+ taskBody.append(table);
457
+ if (!data.tasks?.items?.length) taskBody.append(element("p", "No task records.", "empty"));
458
+ if (data.tasks?.next) {
459
+ const next = new URL(location.href);
460
+ next.searchParams.set(
461
+ "cursor",
462
+ btoa(JSON.stringify(data.tasks.next)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""),
463
+ );
464
+ taskBody.append(link("Older tasks >", next.pathname + next.search, "pagination"));
465
+ }
466
+ tasks.append(taskBody);
467
+ view.append(tasks);
468
+ [hostBody, taskBody].forEach((node, index) => {
469
+ const previous = scrollPositions[index];
470
+ node.scrollTop = !previous || previous.bottom ? node.scrollHeight : previous.top;
471
+ });
472
+ }
473
+
474
+ function renderScheduled() {
475
+ document.querySelector(".native-state")?.remove();
476
+ view.replaceChildren();
477
+ function time(timestamp, timezone) {
478
+ return typeof timestamp === "number"
479
+ ? new Date(timestamp).toLocaleString("sv-SE", { timeZone: timezone })
480
+ : "-";
481
+ }
482
+ function history(runs, conversationId, timezone) {
483
+ const body = element("div", undefined, "card-content");
484
+ const table = element("table", undefined, "task-table");
485
+ const heading = table.insertRow();
486
+ for (const label of ["Task", "Due", "Started", "Finished", "State / delivery", "Result"])
487
+ heading.append(element("th", label));
488
+ for (const run of runs) {
489
+ const row = table.insertRow();
490
+ row
491
+ .insertCell()
492
+ .append(
493
+ link(
494
+ `${run.name || "Execution"} #${run.id}`,
495
+ `/tasks?conversationId=${conversationId}#tree-task-${run.id}`,
496
+ ),
497
+ );
498
+ for (const value of [
499
+ time(run.input?.dueAt, timezone),
500
+ time(run.input?.startedAt, timezone),
501
+ time(run.result?.finishedAt, timezone),
502
+ [run.status, run.delivery].filter(Boolean).join(" / "),
503
+ run.error || run.result?.summary || "In progress",
504
+ ])
505
+ row.append(element("td", value));
506
+ }
507
+ body.append(table);
508
+ if (!runs.length) body.append(element("p", "No executions yet.", "empty"));
509
+ return body;
510
+ }
511
+ for (const job of data.jobs || []) {
512
+ const details = element("details", undefined, "schedule-row");
513
+ details.id = `schedule-history-${job.id}`;
514
+ const latest = job.runs?.[0];
515
+ const status = !job.enabled
516
+ ? "disabled"
517
+ : ["aborted", "faulted", "failed", "missing"].includes(job.status)
518
+ ? job.status
519
+ : latest
520
+ ? [latest.status, latest.delivery].filter(Boolean).join(" / ")
521
+ : "enabled";
522
+ const lastRun = latest ? time(latest.input?.startedAt, job.timezone) : "No executions yet";
523
+ const cadence = job.time !== undefined ? `daily ${job.time}` : `every ${job.intervalMs / 3600000}h`;
524
+ const summary = element("summary", undefined, "schedule-summary");
525
+ summary.append(
526
+ element("span", job.name, "schedule-name"),
527
+ element(
528
+ "span",
529
+ `${cadence} / Next: ${time(job.nextAt, job.timezone)} (${job.timezone})`,
530
+ "schedule-next",
531
+ ),
532
+ element("span", `Last: ${lastRun}`, "schedule-last"),
533
+ element("span", status, "schedule-status"),
534
+ );
535
+ details.append(summary, history(job.runs || [], job.conversationId, job.timezone));
536
+ view.append(details);
537
+ }
538
+ if (!data.jobs?.length) view.append(element("p", "No scheduled tasks.", "empty"));
539
+ }
540
+
541
+ function renderTasks() {
542
+ view.replaceChildren();
543
+ const children = new Map();
544
+ const conversations = new Map(data.conversations.map((conversation) => [conversation.id, conversation]));
545
+ const chatNames = new Map(
546
+ (data.chats?.items || []).map((chat) => [
547
+ chat.conversationId,
548
+ chatName({ ...chat, history: { groupName: conversations.get(chat.conversationId)?.groupName } }, []),
549
+ ]),
550
+ );
551
+ const conversationNames = new Map(chatNames);
552
+ const scheduleNames = new Map((data.schedules?.items || []).map((job) => [job.conversationId, job.name]));
553
+ for (const [id, name] of scheduleNames) conversationNames.set(id, name);
554
+ for (const session of data.sessions?.items || []) {
555
+ if (!conversationNames.has(session.conversationId))
556
+ conversationNames.set(session.conversationId, session.label);
557
+ }
558
+ function conversationName(id) {
559
+ const name = conversationNames.get(id);
560
+ if (name) return name;
561
+ const owner = conversations.get(id)?.owner;
562
+ if (!owner) return "";
563
+ const parentName = conversationName(owner.conversationId);
564
+ return parentName ? `${parentName} / subagent` : "subagent";
565
+ }
566
+ function belongsToChat(id) {
567
+ if (chatNames.has(id) || scheduleNames.has(id)) return true;
568
+ const owner = conversations.get(id)?.owner;
569
+ return owner ? belongsToChat(owner.conversationId) : false;
570
+ }
571
+ for (const conversation of data.conversations.toSorted(compareConversations)) {
572
+ const key = conversation.owner ? `task-${conversation.owner.taskId}` : "root";
573
+ if (!children.has(key)) children.set(key, []);
574
+ children.get(key).push({ type: "conversation", ...conversation });
575
+ }
576
+ for (const task of data.tasks) {
577
+ const key = task.owner === undefined ? `conversation-${task.conversationId}` : `task-${task.owner}`;
578
+ if (!children.has(key)) children.set(key, []);
579
+ children.get(key).push({ type: "task", ...task });
580
+ }
581
+ function renderNode(node) {
582
+ const item = element("li");
583
+ const key = `${node.type}-${node.id}`;
584
+ item.id = `tree-${key}`;
585
+ if (node.type === "conversation") {
586
+ const heading = element("span", `Conversation #${node.id}`);
587
+ const name = conversationName(node.id);
588
+ if (name) heading.append(element("span", ` / ${name} `, "meta"));
589
+ if (chatNames.has(node.id)) {
590
+ heading.append(link("Chat", `/chat?conversationId=${node.id}`, "meta"));
591
+ }
592
+ item.append(heading);
593
+ } else {
594
+ const detail = element("details");
595
+ detail.id = key;
596
+ const summary = element("summary", `${node.kind}${node.name ? `: ${node.name}` : ""} #${node.id}`);
597
+ const status = [node.status, node.phase, node.outcome].filter(Boolean).join(" / ");
598
+ const waiting = node.on?.length ? ` on [${node.on.join(", ")}]` : "";
599
+ const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
600
+ summary.append(element("span", ` ${status}${waiting}${flags}`, "meta"));
601
+ const content = element("pre", "Loading latest state...");
602
+ detail.append(summary, content);
603
+ let loaded = false;
604
+ detail.addEventListener("toggle", async () => {
605
+ if (!detail.open || loaded) return;
606
+ loaded = true;
607
+ try {
608
+ const task = await fetchData(`/tasks/task/data?taskId=${node.id}`);
609
+ content.textContent = JSON.stringify(task, null, 2);
610
+ } catch (error) {
611
+ loaded = false;
612
+ content.textContent = error.message;
613
+ }
614
+ });
615
+ item.append(detail);
616
+ }
617
+ const descendants = children.get(key) || [];
618
+ if (descendants.length) {
619
+ const list = element("ul", undefined, "task-tree");
620
+ for (const child of descendants) list.append(renderNode(child));
621
+ item.append(list);
622
+ }
623
+ return item;
624
+ }
625
+ const roots = conversationId
626
+ ? data.conversations
627
+ .filter((conversation) => String(conversation.id) === conversationId)
628
+ .map((conversation) => ({ type: "conversation", ...conversation }))
629
+ : children.get("root") || [];
630
+ const visible = roots.filter((conversation) => belongsToChat(conversation.id));
631
+ if (!visible.length) view.append(element("p", "No conversations.", "empty"));
632
+ for (const conversation of visible.toSorted(compareConversations)) {
633
+ const card = element("section", undefined, "card");
634
+ card.id = `tasks-${conversation.id}`;
635
+ const list = element("ul", undefined, "task-tree task-root");
636
+ list.append(renderNode(conversation));
637
+ card.append(list);
638
+ view.append(card);
639
+ }
640
+ }
641
+
642
+ function setupToolbar() {
643
+ if (page === "/chat" || page === "/tasks") {
644
+ if (conversationId) {
645
+ toolbar.className = "toolbar";
646
+ toolbar.append(page === "/chat" ? link("< Recent Chats", "/") : link("< All conversations", "/tasks"));
647
+ }
648
+ } else if (page === "/memory") {
649
+ const records = Array.isArray(data) ? memoryRows() : data.records;
650
+ const form = element("form", undefined, "toolbar");
651
+ const searchLabel = element("label", "Search text / subjects / sources", "search");
652
+ const search = element("input");
653
+ search.type = "search";
654
+ search.name = "q";
655
+ search.value = memoryQuery.q;
656
+ search.placeholder = "Keyword, record ID or source";
657
+ searchLabel.append(search);
658
+ form.append(searchLabel);
659
+ for (const [name, label, values] of [
660
+ [
661
+ "namespace",
662
+ "Namespace",
663
+ data.namespaces || [...new Set(records.map((record) => record.namespace))].sort(),
664
+ ],
665
+ [
666
+ "kind",
667
+ "Kind",
668
+ data.kinds || [...new Set(records.map((record) => record.kind).filter(Boolean))].sort(),
669
+ ],
670
+ ["status", "Status", ["active", "superseded", "deleted", "unknown"]],
671
+ ]) {
672
+ const field = element("label", label);
673
+ const select = element("select");
674
+ select.name = name;
675
+ select.append(new Option("All", ""));
676
+ for (const value of values) select.append(new Option(value, value));
677
+ if (memoryQuery[name] && !values.includes(memoryQuery[name]))
678
+ select.append(new Option(memoryQuery[name], memoryQuery[name]));
679
+ select.value = memoryQuery[name];
680
+ field.append(select);
681
+ form.append(field);
682
+ }
683
+ const button = element("button", "Filter");
684
+ button.type = "submit";
685
+ form.append(button);
686
+ form.addEventListener("submit", (event) => {
687
+ event.preventDefault();
688
+ memoryQuery = { ...memoryQuery, ...Object.fromEntries(new FormData(form)), page: 1 };
689
+ void refresh();
690
+ main.scrollTop = 0;
691
+ });
692
+ toolbar.append(form);
693
+ }
694
+ }
695
+
696
+ async function render() {
697
+ const openDetails = new Set(Array.from(view.querySelectorAll("details[open]"), (node) => node.id));
698
+ const scrollTop = main.scrollTop;
699
+ const windowPositions = new Map(
700
+ Array.from(view.querySelectorAll(".card"), (card) => [card.id, card.scrollTop]),
701
+ );
702
+ if (page === "/chat") await loadChats();
703
+ else if (page === "/memory") renderMemory();
704
+ else if (page === "/settings") renderSettings();
705
+ else if (page === "/logs") renderLogs();
706
+ else if (page === "/tasks") renderTasks();
707
+ else if (page === "/scheduled") renderScheduled();
708
+ for (const node of view.querySelectorAll("details")) node.open = openDetails.has(node.id);
709
+ for (const card of view.querySelectorAll(".card")) card.scrollTop = windowPositions.get(card.id) || 0;
710
+ main.scrollTop = scrollTop;
711
+ view.setAttribute("aria-busy", "false");
712
+ }
713
+
714
+ async function refresh() {
715
+ if (refreshing) return;
716
+ refreshing = true;
717
+ document.getElementById("refresh").disabled = true;
718
+ try {
719
+ const query = new URLSearchParams(location.search);
720
+ if (page === "/chat") query.set("view", conversationId ? "display" : "overview");
721
+ if (page === "/memory") {
722
+ query.set("view", "records");
723
+ for (const [key, value] of Object.entries(memoryQuery)) query.set(key, String(value));
724
+ }
725
+ data = await fetchData(`${page}/data?${query}`);
726
+ state.textContent = JSON.stringify(data, null, 2);
727
+ showNotice("");
728
+ await render();
729
+ } catch (error) {
730
+ showNotice(error.message);
731
+ } finally {
732
+ refreshing = false;
733
+ document.getElementById("refresh").disabled = false;
734
+ view.setAttribute("aria-busy", "false");
735
+ }
736
+ }
737
+
738
+ if (page !== "/memory") setupToolbar();
739
+ refreshing = true;
740
+ render()
741
+ .catch((error) => {
742
+ showNotice(error.message);
743
+ view.setAttribute("aria-busy", "false");
744
+ })
745
+ .finally(() => {
746
+ refreshing = false;
747
+ });
748
+ document.getElementById("refresh").addEventListener("click", refresh);