@kingcrab/pi-imessage 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -46
- package/dist/agent/chats.js +21 -0
- package/dist/agent/commands.js +66 -38
- package/dist/agent/direct-send.js +1 -0
- package/dist/agent/error-log.js +37 -0
- package/dist/agent/health.js +1 -0
- package/dist/agent/progress.js +43 -5
- package/dist/agent/replies.js +1 -0
- package/dist/agent/run.js +2 -1
- package/dist/agent/scheduling.js +8 -7
- package/dist/extensions/browser.js +1 -1
- package/dist/extensions/memory.js +1 -1
- package/dist/extensions/message.js +1 -1
- package/dist/extensions/read-image.js +1 -1
- package/dist/extensions/restart.js +100 -0
- package/dist/extensions/schedules.js +1 -0
- package/dist/extensions/subagent.js +1 -1
- package/dist/extensions/web.js +4 -3
- package/dist/extensions/workspace.js +1 -1
- package/dist/main.js +25 -4
- package/dist/web/app.js +198 -55
- package/dist/web/page.js +3 -2
- package/dist/web/schedules.js +4 -15
- package/dist/web/server.js +48 -3
- package/dist/web/style.css +40 -0
- package/dist/web/tasks.js +183 -41
- package/docs/adr/0048-quiet-attachment-reads.md +11 -0
- package/docs/adr/0049-per-chat-progress-notices.md +21 -0
- package/docs/adr/0052-host-owned-self-restart.md +53 -0
- package/docs/adr/0053-service-log-errors.md +44 -0
- package/docs/adr/README.md +7 -2
- package/docs/api.md +16 -4
- package/ops/README.md +24 -41
- package/package.json +5 -5
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, chmod, copyFile, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
6
|
+
import { defineExtension, defineTool } from "@earendil-works/pi-durable";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/** Variables that describe the old shell session or that the controller sets itself. */
|
|
9
|
+
const EXCLUDED_ENVIRONMENT = new Set(["_", "PWD", "OLDPWD", "SHLVL", "TERM_SESSION_ID"]);
|
|
10
|
+
function shellQuote(value) {
|
|
11
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
12
|
+
}
|
|
13
|
+
async function processField(field) {
|
|
14
|
+
const { stdout } = await execFileAsync("ps", ["-p", String(process.pid), "-o", `${field}=`]);
|
|
15
|
+
// Exactly what the controller's `$(ps ...)` yields: trailing newlines stripped, nothing else.
|
|
16
|
+
return stdout.replace(/\n+$/, "");
|
|
17
|
+
}
|
|
18
|
+
/** Writes the private controller directory of ops/README.md step 3 for the target release; starts nothing. */
|
|
19
|
+
export async function prepareRestart(options, release) {
|
|
20
|
+
if (!/^[0-9a-f]{40}$/.test(release))
|
|
21
|
+
throw new Error("release must be the full commit hash of a prepared release directory");
|
|
22
|
+
const target = join(dirname(options.currentRelease), release);
|
|
23
|
+
for (const entry of ["src/cli.ts", "node_modules", "ops/restart.command"]) {
|
|
24
|
+
await access(join(target, entry)).catch(() => {
|
|
25
|
+
throw new Error(`Release directory is not prepared: ${join(target, entry)} is missing`);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const [started, command] = await Promise.all([processField("lstart"), processField("command")]);
|
|
29
|
+
const deployDir = await mkdtemp(join(options.deployRoot, "pi-imessage-restart."));
|
|
30
|
+
// The whole process environment, including .env values loaded at startup; the controller deletes the file.
|
|
31
|
+
const environment = Object.entries(process.env)
|
|
32
|
+
.filter(([name, value]) => value !== undefined && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !EXCLUDED_ENVIRONMENT.has(name))
|
|
33
|
+
.map(([name, value]) => `export ${name}=${shellQuote(value ?? "")}`);
|
|
34
|
+
await writeFile(join(deployDir, "environment.sh"), `${environment.join("\n")}\n`, { mode: 0o600 });
|
|
35
|
+
const control = {
|
|
36
|
+
OLD_PID: String(process.pid),
|
|
37
|
+
OLD_STARTED: started,
|
|
38
|
+
EXPECTED_COMMAND: command,
|
|
39
|
+
REPO: target,
|
|
40
|
+
NODE: process.execPath,
|
|
41
|
+
WORKING_DIR: options.workingDir,
|
|
42
|
+
WEB_PORT: process.env.WEB_PORT ?? "7750",
|
|
43
|
+
LOG_FILE: join(options.workingDir, "service.log"),
|
|
44
|
+
};
|
|
45
|
+
await writeFile(join(deployDir, "control.sh"), `${Object.entries(control)
|
|
46
|
+
.map(([name, value]) => `${name}=${shellQuote(value)}`)
|
|
47
|
+
.join("\n")}\n`, { mode: 0o600 });
|
|
48
|
+
const controller = join(deployDir, "restart.command");
|
|
49
|
+
await copyFile(join(target, "ops", "restart.command"), controller);
|
|
50
|
+
await chmod(controller, 0o700);
|
|
51
|
+
return { target, deployDir, controller };
|
|
52
|
+
}
|
|
53
|
+
/** The claw baseline: a Terminal window keeps the controller alive independently of this process. */
|
|
54
|
+
export async function launchInTerminal(controller) {
|
|
55
|
+
await execFileAsync("open", ["-a", "Terminal", controller], { timeout: 15_000 });
|
|
56
|
+
}
|
|
57
|
+
/** The chat assistant restarts the service through the host instead of a bash command that stops its own host. */
|
|
58
|
+
export function restartExtension(options) {
|
|
59
|
+
let scheduled;
|
|
60
|
+
return defineExtension({
|
|
61
|
+
name: "restart",
|
|
62
|
+
tools: [
|
|
63
|
+
defineTool({
|
|
64
|
+
name: "restart_service",
|
|
65
|
+
description: "Restart this service into a prepared release directory: a sibling of the running one named by its full commit hash, with npm ci and ops/preflight.mjs done. The service stops only after this call has settled; end the call at once and verify in a later call: controller.log and new.pid in the returned directory, then the service log, the listener and the running release directory. Never call it again before the first outcome is known.",
|
|
66
|
+
parameters: Type.Object({
|
|
67
|
+
release: Type.String({ description: "Full commit hash of the prepared release directory" }),
|
|
68
|
+
}),
|
|
69
|
+
// A rerun would launch a second controller (#39); an interrupted call is reported to the model instead.
|
|
70
|
+
replay: "unsafe",
|
|
71
|
+
async execute(args, api) {
|
|
72
|
+
if (scheduled)
|
|
73
|
+
throw new Error(`A restart is already scheduled from ${scheduled}`);
|
|
74
|
+
const prepared = await prepareRestart(options, args.release);
|
|
75
|
+
scheduled = prepared.deployDir;
|
|
76
|
+
console.log(new Date().toISOString(), "Restart requested", {
|
|
77
|
+
release: args.release,
|
|
78
|
+
controller: prepared.deployDir,
|
|
79
|
+
task: api.taskId,
|
|
80
|
+
});
|
|
81
|
+
void options
|
|
82
|
+
.settled(api.taskId)
|
|
83
|
+
.then(() => options.launch(prepared.controller))
|
|
84
|
+
.catch((error) => {
|
|
85
|
+
scheduled = undefined;
|
|
86
|
+
console.error(new Date().toISOString(), "Restart launch failed", error);
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
output: [
|
|
90
|
+
{
|
|
91
|
+
type: "text",
|
|
92
|
+
text: `Restart into ${prepared.target} is scheduled; the service stops after this call settles. Controller directory: ${prepared.deployDir}. Verify in a later call.`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
}
|
package/dist/extensions/web.js
CHANGED
|
@@ -9,6 +9,7 @@ export const WebResults = defineDoc({
|
|
|
9
9
|
history: "rewindable",
|
|
10
10
|
fork: "asOf",
|
|
11
11
|
initial: () => ({ queries: {} }),
|
|
12
|
+
checkpointWhen: () => false,
|
|
12
13
|
});
|
|
13
14
|
async function fetchBounded(url, options = {}) {
|
|
14
15
|
const parsed = new URL(url);
|
|
@@ -65,7 +66,7 @@ export function webExtension(apiKey = process.env.BRAVE_API_KEY ?? process.env.B
|
|
|
65
66
|
for (const key of keys.slice(0, Math.max(0, keys.length - 20)))
|
|
66
67
|
delete results.queries[key];
|
|
67
68
|
}, context);
|
|
68
|
-
return {
|
|
69
|
+
return { output: [{ type: "text", text }] };
|
|
69
70
|
},
|
|
70
71
|
}),
|
|
71
72
|
defineTool({
|
|
@@ -81,7 +82,7 @@ export function webExtension(apiKey = process.env.BRAVE_API_KEY ?? process.env.B
|
|
|
81
82
|
? (await Defuddle(result.bytes.toString(), result.url, { markdown: true, useAsync: false }))
|
|
82
83
|
.content
|
|
83
84
|
: result.bytes.toString();
|
|
84
|
-
return {
|
|
85
|
+
return { output: [{ type: "text", text: text.slice(0, 50000) }] };
|
|
85
86
|
},
|
|
86
87
|
}),
|
|
87
88
|
defineTool({
|
|
@@ -91,7 +92,7 @@ export function webExtension(apiKey = process.env.BRAVE_API_KEY ?? process.env.B
|
|
|
91
92
|
replay: "safe",
|
|
92
93
|
async execute(args, api, context) {
|
|
93
94
|
const data = await api.snapshot(WebResults, api.conversationId, context);
|
|
94
|
-
return {
|
|
95
|
+
return { output: [{ type: "text", text: data?.queries[args.query] ?? "No saved result" }] };
|
|
95
96
|
},
|
|
96
97
|
}),
|
|
97
98
|
],
|
|
@@ -16,7 +16,7 @@ export function workspaceExtension(reload) {
|
|
|
16
16
|
parameters: Type.Object({}),
|
|
17
17
|
async execute() {
|
|
18
18
|
await reload();
|
|
19
|
-
return {
|
|
19
|
+
return { output: [{ type: "text", text: "Workspace extensions and configuration reloaded." }] };
|
|
20
20
|
},
|
|
21
21
|
}),
|
|
22
22
|
],
|
package/dist/main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { compactionReply, isCommand, runCommand } from "./agent/commands.js";
|
|
|
7
7
|
import { compactChats } from "./agent/compaction.js";
|
|
8
8
|
import { deliverReplies, recoverSending } from "./agent/deliver.js";
|
|
9
9
|
import { deliverDirect } from "./agent/direct-send.js";
|
|
10
|
+
import { logErrors } from "./agent/error-log.js";
|
|
10
11
|
import { openHarness } from "./agent/harness.js";
|
|
11
12
|
import { submitHealth } from "./agent/health.js";
|
|
12
13
|
import { openModels, readDefaults, requestSettings, withModelPolicy } from "./agent/models.js";
|
|
@@ -19,6 +20,7 @@ import { createBrowser } from "./extensions/browser.js";
|
|
|
19
20
|
import { memoryExtension } from "./extensions/memory.js";
|
|
20
21
|
import { messageExtension } from "./extensions/message.js";
|
|
21
22
|
import { ImageRead } from "./extensions/read-image.js";
|
|
23
|
+
import { launchInTerminal, restartExtension } from "./extensions/restart.js";
|
|
22
24
|
import { validateSchedules } from "./extensions/schedules.js";
|
|
23
25
|
import { Subagent } from "./extensions/subagent.js";
|
|
24
26
|
import { webExtension } from "./extensions/web.js";
|
|
@@ -75,6 +77,14 @@ reloadHttpTransport) {
|
|
|
75
77
|
browser.extension,
|
|
76
78
|
Subagent,
|
|
77
79
|
workspaceExtension(reload),
|
|
80
|
+
// ADR 0052: the service runs from a release directory; the controller starts after the call settles.
|
|
81
|
+
restartExtension({
|
|
82
|
+
workingDir: options.workingDir,
|
|
83
|
+
currentRelease: process.cwd(),
|
|
84
|
+
deployRoot: "/tmp",
|
|
85
|
+
settled: (taskId) => harness.waitForTask(taskId, BACKGROUND_CONTEXT),
|
|
86
|
+
launch: launchInTerminal,
|
|
87
|
+
}),
|
|
78
88
|
]),
|
|
79
89
|
...(await loadWorkspaceExtensions(options.workingDir)),
|
|
80
90
|
];
|
|
@@ -96,6 +106,7 @@ reloadHttpTransport) {
|
|
|
96
106
|
const { harness, storage } = owner;
|
|
97
107
|
const scheduling = schedulingExtension(harness, defaults);
|
|
98
108
|
let progress;
|
|
109
|
+
const stopErrorLog = logErrors(harness);
|
|
99
110
|
try {
|
|
100
111
|
// The native owner lock is acquired before cleaning up browsers left by a previous process.
|
|
101
112
|
await browser.close();
|
|
@@ -271,6 +282,7 @@ reloadHttpTransport) {
|
|
|
271
282
|
models: runtime.models,
|
|
272
283
|
defaults,
|
|
273
284
|
reload,
|
|
285
|
+
workingDir: options.workingDir,
|
|
274
286
|
chatGuid: snapshot.chatGuid,
|
|
275
287
|
guid: snapshot.guid,
|
|
276
288
|
text: snapshot.text,
|
|
@@ -384,6 +396,7 @@ reloadHttpTransport) {
|
|
|
384
396
|
closing ??= (async () => {
|
|
385
397
|
await Promise.allSettled([...operations]);
|
|
386
398
|
await progress.close();
|
|
399
|
+
stopErrorLog();
|
|
387
400
|
await owner.close(browser.close);
|
|
388
401
|
})();
|
|
389
402
|
return closing;
|
|
@@ -436,10 +449,15 @@ export async function startMessaging(options, reloadHttpTransport) {
|
|
|
436
449
|
let closing;
|
|
437
450
|
let timer;
|
|
438
451
|
let started = false;
|
|
452
|
+
/** Rows committed before the call are read even when a poll is already in flight. */
|
|
439
453
|
function poll() {
|
|
440
454
|
if (closed)
|
|
441
455
|
return Promise.reject(new Error("Messaging is closed"));
|
|
442
|
-
|
|
456
|
+
const previous = pending?.catch(() => undefined) ?? Promise.resolve();
|
|
457
|
+
const current = previous
|
|
458
|
+
.then(async () => {
|
|
459
|
+
if (closed)
|
|
460
|
+
return;
|
|
443
461
|
try {
|
|
444
462
|
await watcher.poll();
|
|
445
463
|
}
|
|
@@ -448,10 +466,13 @@ export async function startMessaging(options, reloadHttpTransport) {
|
|
|
448
466
|
if (!closed)
|
|
449
467
|
await service.deliver();
|
|
450
468
|
}
|
|
451
|
-
})
|
|
452
|
-
|
|
469
|
+
})
|
|
470
|
+
.finally(() => {
|
|
471
|
+
if (pending === current)
|
|
472
|
+
pending = undefined;
|
|
453
473
|
});
|
|
454
|
-
|
|
474
|
+
pending = current;
|
|
475
|
+
return current;
|
|
455
476
|
}
|
|
456
477
|
function tick() {
|
|
457
478
|
void poll()
|
package/dist/web/app.js
CHANGED
|
@@ -16,7 +16,8 @@ document.body.classList.add(`page-${page.slice(1)}`);
|
|
|
16
16
|
if (conversationId) document.body.classList.add("conversation-detail");
|
|
17
17
|
document.getElementById("title").textContent =
|
|
18
18
|
{
|
|
19
|
-
"/chat": "
|
|
19
|
+
"/chat": "Messages",
|
|
20
|
+
"/chats": "Chats",
|
|
20
21
|
"/tasks": "Tasks",
|
|
21
22
|
"/scheduled": "Scheduled tasks",
|
|
22
23
|
"/logs": "Logs",
|
|
@@ -424,6 +425,80 @@ function renderSettings() {
|
|
|
424
425
|
}
|
|
425
426
|
}
|
|
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
|
+
|
|
427
502
|
function renderLogs() {
|
|
428
503
|
const scrollPositions = Array.from(view.querySelectorAll(".card-content"), (node) => ({
|
|
429
504
|
top: node.scrollTop,
|
|
@@ -540,7 +615,6 @@ function renderScheduled() {
|
|
|
540
615
|
|
|
541
616
|
function renderTasks() {
|
|
542
617
|
view.replaceChildren();
|
|
543
|
-
const children = new Map();
|
|
544
618
|
const conversations = new Map(data.conversations.map((conversation) => [conversation.id, conversation]));
|
|
545
619
|
const chatNames = new Map(
|
|
546
620
|
(data.chats?.items || []).map((chat) => [
|
|
@@ -548,92 +622,160 @@ function renderTasks() {
|
|
|
548
622
|
chatName({ ...chat, history: { groupName: conversations.get(chat.conversationId)?.groupName } }, []),
|
|
549
623
|
]),
|
|
550
624
|
);
|
|
551
|
-
const conversationNames = new Map(chatNames);
|
|
552
625
|
const scheduleNames = new Map((data.schedules?.items || []).map((job) => [job.conversationId, job.name]));
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
626
|
+
const sessionNames = new Map(
|
|
627
|
+
(data.sessions?.items || []).map((session) => [session.conversationId, session.label]),
|
|
628
|
+
);
|
|
629
|
+
// Every conversation is named by the session document that created it, or by the task that owns it.
|
|
630
|
+
function source(id) {
|
|
631
|
+
if (chatNames.has(id)) return { name: `chat ${chatNames.get(id)}`, doc: "imessage.chats" };
|
|
632
|
+
if (scheduleNames.has(id))
|
|
633
|
+
return { name: `schedule ${scheduleNames.get(id)}`, doc: "imessage.schedules" };
|
|
634
|
+
if (sessionNames.has(id)) return { name: `session ${sessionNames.get(id)}`, doc: "imessage.sessions" };
|
|
561
635
|
const owner = conversations.get(id)?.owner;
|
|
562
|
-
|
|
563
|
-
const parentName = conversationName(owner.conversationId);
|
|
564
|
-
return parentName ? `${parentName} / subagent` : "subagent";
|
|
636
|
+
return owner ? { name: "subagent", doc: "owned by a tool" } : { name: "", doc: "" };
|
|
565
637
|
}
|
|
566
638
|
function belongsToChat(id) {
|
|
567
639
|
if (chatNames.has(id) || scheduleNames.has(id)) return true;
|
|
568
640
|
const owner = conversations.get(id)?.owner;
|
|
569
641
|
return owner ? belongsToChat(owner.conversationId) : false;
|
|
570
642
|
}
|
|
571
|
-
|
|
572
|
-
|
|
643
|
+
// Children by storage relation: conversations under their owner task, tasks under their owner task or
|
|
644
|
+
// conversation, entries under the task that wrote them (byTaskId). A pi.user entry opens a turn: the
|
|
645
|
+
// later root tasks and loose entries of its conversation nest under it until the next one.
|
|
646
|
+
const children = new Map();
|
|
647
|
+
const add = (key, node) => {
|
|
573
648
|
if (!children.has(key)) children.set(key, []);
|
|
574
|
-
children.get(key).push(
|
|
649
|
+
children.get(key).push(node);
|
|
650
|
+
};
|
|
651
|
+
const taskIds = new Set(data.tasks.map((task) => task.id));
|
|
652
|
+
for (const conversation of data.conversations) {
|
|
653
|
+
if (conversation.owner)
|
|
654
|
+
add(`task-${conversation.owner.taskId}`, { type: "conversation", ...conversation });
|
|
655
|
+
}
|
|
656
|
+
const turns = new Map();
|
|
657
|
+
for (const entry of data.entries || []) {
|
|
658
|
+
if (entry.kind !== "pi.user") continue;
|
|
659
|
+
if (!turns.has(entry.conversationId)) turns.set(entry.conversationId, []);
|
|
660
|
+
turns.get(entry.conversationId).push(entry.id);
|
|
661
|
+
}
|
|
662
|
+
// Entries arrive newest first; a turn lookup needs ascending ids.
|
|
663
|
+
for (const ids of turns.values()) ids.sort((left, right) => left - right);
|
|
664
|
+
function turnKey(conversationId, id) {
|
|
665
|
+
const opened = (turns.get(conversationId) || []).filter((turn) => turn < id).at(-1);
|
|
666
|
+
return opened === undefined ? `conversation-${conversationId}` : `entry-${opened}`;
|
|
575
667
|
}
|
|
576
668
|
for (const task of data.tasks) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
669
|
+
add(task.owner === undefined ? turnKey(task.conversationId, task.id) : `task-${task.owner}`, {
|
|
670
|
+
type: "task",
|
|
671
|
+
...task,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
for (const entry of data.entries || []) {
|
|
675
|
+
if (entry.kind === "pi.user") add(`conversation-${entry.conversationId}`, { type: "entry", ...entry });
|
|
676
|
+
else if (entry.byTaskId !== undefined && taskIds.has(entry.byTaskId))
|
|
677
|
+
add(`task-${entry.byTaskId}`, { type: "entry", ...entry });
|
|
678
|
+
else add(turnKey(entry.conversationId, entry.id), { type: "entry", ...entry });
|
|
679
|
+
}
|
|
680
|
+
// Newest turn first in a card; inside a turn the chain reads in storage order. Cards are sorted by activity.
|
|
681
|
+
for (const [key, nodes] of children) {
|
|
682
|
+
if (key.startsWith("conversation-")) nodes.sort((left, right) => right.id - left.id);
|
|
683
|
+
else nodes.sort((left, right) => left.id - right.id);
|
|
684
|
+
}
|
|
685
|
+
function lazyDetails(summary, url) {
|
|
686
|
+
const detail = element("details");
|
|
687
|
+
const content = element("pre", "Loading latest state...");
|
|
688
|
+
detail.append(summary, content);
|
|
689
|
+
let loaded = false;
|
|
690
|
+
detail.addEventListener("toggle", async () => {
|
|
691
|
+
if (!detail.open || loaded) return;
|
|
692
|
+
loaded = true;
|
|
693
|
+
try {
|
|
694
|
+
content.textContent = JSON.stringify(await fetchData(url), null, 2);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
loaded = false;
|
|
697
|
+
content.textContent = error.message;
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
return detail;
|
|
701
|
+
}
|
|
702
|
+
function taskSummary(node) {
|
|
703
|
+
const label =
|
|
704
|
+
node.kind === "pi.generation"
|
|
705
|
+
? "Generation"
|
|
706
|
+
: node.kind === "pi.tool"
|
|
707
|
+
? `Tool ${node.name || "?"}`
|
|
708
|
+
: node.kind;
|
|
709
|
+
const summary = element("summary", label);
|
|
710
|
+
const state = node.outcome ? [node.outcome] : [node.status, node.phase].filter(Boolean);
|
|
711
|
+
const waiting = node.on?.length ? ` on ${node.on.length} task${node.on.length === 1 ? "" : "s"}` : "";
|
|
712
|
+
const sleeping =
|
|
713
|
+
typeof node.until === "number" ? ` until ${new Date(node.until).toLocaleString("sv-SE")}` : "";
|
|
714
|
+
const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
|
|
715
|
+
summary.append(element("span", ` [${state.join(" / ")}]${waiting}${sleeping}${flags}`, "meta"));
|
|
716
|
+
return summary;
|
|
717
|
+
}
|
|
718
|
+
function entrySummary(node) {
|
|
719
|
+
const summary = element("summary", node.kind.replace(/^pi\./, ""));
|
|
720
|
+
if (node.text) summary.append(element("span", ` "${node.text}"`, "entry-text"));
|
|
721
|
+
return summary;
|
|
580
722
|
}
|
|
581
723
|
function renderNode(node) {
|
|
582
724
|
const item = element("li");
|
|
583
725
|
const key = `${node.type}-${node.id}`;
|
|
584
726
|
item.id = `tree-${key}`;
|
|
585
727
|
if (node.type === "conversation") {
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
728
|
+
const { name, doc } = source(node.id);
|
|
729
|
+
// A card is one conversation; a conversation owned by a task folds under it.
|
|
730
|
+
const heading = element(node.owner ? "summary" : "span", "Conversation");
|
|
731
|
+
if (name) heading.append(element("span", ` ${name}`));
|
|
732
|
+
if (doc) heading.append(element("span", ` <- ${doc}`, "meta"));
|
|
733
|
+
if (chatNames.has(node.id))
|
|
734
|
+
heading.append(element("span", " "), link("Chat", `/chat?conversationId=${node.id}`, "meta"));
|
|
735
|
+
if (node.owner) {
|
|
736
|
+
const detail = element("details");
|
|
737
|
+
detail.id = key;
|
|
738
|
+
detail.append(heading);
|
|
739
|
+
item.append(detail);
|
|
740
|
+
} else item.append(heading);
|
|
741
|
+
} else if (node.type === "task") {
|
|
742
|
+
const detail = lazyDetails(taskSummary(node), `/tasks/task/data?taskId=${node.id}`);
|
|
743
|
+
detail.id = key;
|
|
744
|
+
item.append(detail);
|
|
593
745
|
} else {
|
|
594
|
-
|
|
746
|
+
item.className = node.kind === "pi.user" ? "entry turn" : "entry";
|
|
747
|
+
const detail = lazyDetails(entrySummary(node), `/tasks/entry/data?entryId=${node.id}`);
|
|
595
748
|
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
749
|
item.append(detail);
|
|
616
750
|
}
|
|
617
751
|
const descendants = children.get(key) || [];
|
|
618
|
-
|
|
752
|
+
const more = node.type === "conversation" && data.more?.[node.id];
|
|
753
|
+
if (descendants.length || more) {
|
|
619
754
|
const list = element("ul", undefined, "task-tree");
|
|
620
755
|
for (const child of descendants) list.append(renderNode(child));
|
|
621
|
-
|
|
756
|
+
if (more) list.append(element("li", "older turns not listed", "meta"));
|
|
757
|
+
(node.type === "conversation" && node.owner ? item.firstChild : item).append(list);
|
|
622
758
|
}
|
|
623
759
|
return item;
|
|
624
760
|
}
|
|
625
761
|
const roots = conversationId
|
|
626
|
-
? data.conversations
|
|
627
|
-
|
|
628
|
-
.map((conversation) => ({ type: "conversation", ...conversation }))
|
|
629
|
-
: children.get("root") || [];
|
|
762
|
+
? data.conversations.filter((conversation) => String(conversation.id) === conversationId)
|
|
763
|
+
: data.conversations.filter((conversation) => !conversation.owner);
|
|
630
764
|
const visible = roots.filter((conversation) => belongsToChat(conversation.id));
|
|
765
|
+
if (!conversationId && data.session)
|
|
766
|
+
view.append(
|
|
767
|
+
element(
|
|
768
|
+
"p",
|
|
769
|
+
`Session ${data.session} docs: imessage.chats, imessage.schedules, imessage.sessions`,
|
|
770
|
+
"muted",
|
|
771
|
+
),
|
|
772
|
+
);
|
|
631
773
|
if (!visible.length) view.append(element("p", "No conversations.", "empty"));
|
|
632
774
|
for (const conversation of visible.toSorted(compareConversations)) {
|
|
633
775
|
const card = element("section", undefined, "card");
|
|
634
776
|
card.id = `tasks-${conversation.id}`;
|
|
635
777
|
const list = element("ul", undefined, "task-tree task-root");
|
|
636
|
-
list.append(renderNode(conversation));
|
|
778
|
+
list.append(renderNode({ type: "conversation", ...conversation }));
|
|
637
779
|
card.append(list);
|
|
638
780
|
view.append(card);
|
|
639
781
|
}
|
|
@@ -643,7 +785,7 @@ function setupToolbar() {
|
|
|
643
785
|
if (page === "/chat" || page === "/tasks") {
|
|
644
786
|
if (conversationId) {
|
|
645
787
|
toolbar.className = "toolbar";
|
|
646
|
-
toolbar.append(page === "/chat" ? link("<
|
|
788
|
+
toolbar.append(page === "/chat" ? link("< Messages", "/") : link("< All conversations", "/tasks"));
|
|
647
789
|
}
|
|
648
790
|
} else if (page === "/memory") {
|
|
649
791
|
const records = Array.isArray(data) ? memoryRows() : data.records;
|
|
@@ -702,6 +844,7 @@ async function render() {
|
|
|
702
844
|
if (page === "/chat") await loadChats();
|
|
703
845
|
else if (page === "/memory") renderMemory();
|
|
704
846
|
else if (page === "/settings") renderSettings();
|
|
847
|
+
else if (page === "/chats") renderChatSettings();
|
|
705
848
|
else if (page === "/logs") renderLogs();
|
|
706
849
|
else if (page === "/tasks") renderTasks();
|
|
707
850
|
else if (page === "/scheduled") renderScheduled();
|
package/dist/web/page.js
CHANGED
|
@@ -5,7 +5,8 @@ function escapeHtml(text) {
|
|
|
5
5
|
export function renderPage(page, state) {
|
|
6
6
|
const active = page === "/" || page === "/chat" ? "/" : page;
|
|
7
7
|
const navigation = [
|
|
8
|
-
["/", "
|
|
8
|
+
["/", "Messages"],
|
|
9
|
+
["/chats", "Chats"],
|
|
9
10
|
["/tasks", "Tasks"],
|
|
10
11
|
["/scheduled", "Scheduled"],
|
|
11
12
|
["/logs", "Logs"],
|
|
@@ -19,7 +20,7 @@ export function renderPage(page, state) {
|
|
|
19
20
|
<title>blue — pi-imessage</title><link rel="stylesheet" href="/assets/style.css">
|
|
20
21
|
<script src="/assets/app.js" defer></script></head>
|
|
21
22
|
<body><nav class="app-nav" aria-label="Main navigation">${navigation}</nav>
|
|
22
|
-
<main class="content" id="main"><header class="page-header"><h1 id="title">
|
|
23
|
+
<main class="content" id="main"><header class="page-header"><h1 id="title">Messages</h1>
|
|
23
24
|
<div class="connection"><button id="refresh" type="button">Refresh</button></div></header>
|
|
24
25
|
<div id="toolbar"></div><p id="notice" role="status" hidden></p><div id="view" aria-busy="true"></div>
|
|
25
26
|
<details class="native-state"><summary>Native state</summary><pre id="state">${escapeHtml(state)}</pre></details></main>
|
package/dist/web/schedules.js
CHANGED
|
@@ -11,18 +11,9 @@ export function readSchedules(harness) {
|
|
|
11
11
|
const task = await tx.task(schedule.taskId);
|
|
12
12
|
const checkpoint = task?.state.checkpoint;
|
|
13
13
|
const state = checkpoint && typeof checkpoint === "object" && !Array.isArray(checkpoint) ? checkpoint : undefined;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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) => {
|
|
14
|
+
// Occurrence IDs grow with admission time; the newest ten are the history shown.
|
|
15
|
+
const executions = await tx.scanTasks({ conversationId: schedule.conversationId, kind: EXECUTION_KIND, order: "descending" }, 10);
|
|
16
|
+
const runs = executions.items.map((run) => {
|
|
26
17
|
const outcome = run.state.outcome;
|
|
27
18
|
const result = outcome?.status === "completed" &&
|
|
28
19
|
outcome.result &&
|
|
@@ -38,9 +29,7 @@ export function readSchedules(harness) {
|
|
|
38
29
|
input: run.input,
|
|
39
30
|
status: outcome?.status ?? run.state.status,
|
|
40
31
|
result,
|
|
41
|
-
error: outcome?.status === "failed" || outcome?.status === "faulted"
|
|
42
|
-
? outcome.error.message
|
|
43
|
-
: undefined,
|
|
32
|
+
error: outcome?.status === "failed" || outcome?.status === "faulted" ? outcome.error.message : undefined,
|
|
44
33
|
delivery: receipt?.textStatus ?? (result?.requestId ? "queued" : undefined),
|
|
45
34
|
};
|
|
46
35
|
});
|