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