@join-pantheon/cli 0.24.0 → 0.24.4
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/dist/cli.js +76 -0
- package/dist/continued.js +35 -0
- package/dist/decision-ui.js +14 -1
- package/dist/humanize.js +223 -0
- package/dist/notifier.js +19 -5
- package/dist/relay.js +11 -0
- package/dist/tray.js +29 -4
- package/dist/upgrade.js +5 -1
- package/dist/worker.js +34 -10
- package/helper/macos/main.swift +31 -1
- package/helper/macos/menubar.swift +577 -17
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -331,6 +331,42 @@ async function fetchHistory(cfg, since = 0) {
|
|
|
331
331
|
throw new Error(`relay answered ${res.status} for the ledger`);
|
|
332
332
|
return (await res.json()).acts ?? [];
|
|
333
333
|
}
|
|
334
|
+
async function fetchAct(cfg, id) {
|
|
335
|
+
const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
336
|
+
const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(8000) });
|
|
339
|
+
if (res.ok) {
|
|
340
|
+
const body = await res.json();
|
|
341
|
+
if (body.act)
|
|
342
|
+
return { act: body.act, ...(body.goal?.title ? { goalTitle: body.goal.title } : {}) };
|
|
343
|
+
}
|
|
344
|
+
if (res.status === 404) {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch { }
|
|
348
|
+
let head = 0;
|
|
349
|
+
try {
|
|
350
|
+
const h = await (await fetch(`${base}/health?space=${encodeURIComponent(cfg.space)}`, { signal: AbortSignal.timeout(8000) })).json();
|
|
351
|
+
head = h.spaces?.[0]?.seq ?? 0;
|
|
352
|
+
}
|
|
353
|
+
catch { }
|
|
354
|
+
const windows = head > 0 ? [60, 200, 600].map((n) => Math.max(0, head - n)) : [0];
|
|
355
|
+
if (windows[windows.length - 1] !== 0)
|
|
356
|
+
windows.push(0);
|
|
357
|
+
for (const since of windows) {
|
|
358
|
+
try {
|
|
359
|
+
const acts = await fetchHistory(cfg, since);
|
|
360
|
+
const act = acts.find((a) => a.id === id || String(a.seq) === id);
|
|
361
|
+
if (act)
|
|
362
|
+
return { act };
|
|
363
|
+
if (since === 0)
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
catch { }
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
334
370
|
function workerMidRun() {
|
|
335
371
|
try {
|
|
336
372
|
const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
|
|
@@ -2165,6 +2201,21 @@ async function main() {
|
|
|
2165
2201
|
hud = null;
|
|
2166
2202
|
}
|
|
2167
2203
|
}
|
|
2204
|
+
if (hud?.action === "done") {
|
|
2205
|
+
const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
|
|
2206
|
+
await c2.connect().catch((e) => die(e.message));
|
|
2207
|
+
const o = hud.decision ? c2.obligations.find((x) => x.id === hud.decision) : undefined;
|
|
2208
|
+
if (!o) {
|
|
2209
|
+
c2.close();
|
|
2210
|
+
console.error(D("that decision is no longer waiting on you"));
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
await c2.send({ act: "ANSWER", ref: o.ref, goal: o.goal, after_seq: c2.readSeq,
|
|
2214
|
+
body: "Marked done from the alert — handled outside this thread." }).catch((e) => die(e.message));
|
|
2215
|
+
c2.close();
|
|
2216
|
+
console.error(D(`done — ${o.id} is no longer waiting on you`));
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2168
2219
|
if (hud?.action === "details") {
|
|
2169
2220
|
const acts = await fetchHistory(cfg).catch(() => []);
|
|
2170
2221
|
const a = hud.act ? acts.find((x) => x.id === hud.act) : undefined;
|
|
@@ -2460,6 +2511,31 @@ async function main() {
|
|
|
2460
2511
|
console.log(JSON.stringify(await trayStatus()));
|
|
2461
2512
|
return;
|
|
2462
2513
|
}
|
|
2514
|
+
case "tray-detail": {
|
|
2515
|
+
const key = argv[1] ?? die("usage: pantheon tray-detail <act id>");
|
|
2516
|
+
const cfgNow = load() ?? die("not joined to a team");
|
|
2517
|
+
const { humanize } = await import("./humanize.js");
|
|
2518
|
+
const found = await fetchAct(cfgNow, key);
|
|
2519
|
+
if (!found) {
|
|
2520
|
+
console.log(JSON.stringify({ ok: false }));
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
const a = found.act;
|
|
2524
|
+
let goalTitle = found.goalTitle;
|
|
2525
|
+
if (!goalTitle && a.goal) {
|
|
2526
|
+
const c2 = new PantheonClient(cfgNow, undefined, { role: "cli" });
|
|
2527
|
+
goalTitle = await c2.connect().then(() => c2.goals.get(a.goal)?.title).catch(() => undefined);
|
|
2528
|
+
c2.close();
|
|
2529
|
+
}
|
|
2530
|
+
const { headline, blocks } = humanize(a.body, { ...(goalTitle ? { goalTitle } : {}), from: a.from });
|
|
2531
|
+
console.log(JSON.stringify({
|
|
2532
|
+
ok: true, id: a.id, act: a.act, from: a.from, ts: a.ts,
|
|
2533
|
+
...(a.summary ? { summary: a.summary } : {}),
|
|
2534
|
+
...(goalTitle ? { goal_title: goalTitle } : {}),
|
|
2535
|
+
headline, blocks, raw: a.body,
|
|
2536
|
+
}));
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2463
2539
|
case "menubar": {
|
|
2464
2540
|
need();
|
|
2465
2541
|
const { ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "./config.js";
|
|
4
|
+
const KEEP = 500;
|
|
5
|
+
export function continuedPath(home) {
|
|
6
|
+
return join(home ?? CONFIG_DIR, "continued.json");
|
|
7
|
+
}
|
|
8
|
+
function readAll(home) {
|
|
9
|
+
try {
|
|
10
|
+
const p = continuedPath(home);
|
|
11
|
+
if (!existsSync(p))
|
|
12
|
+
return {};
|
|
13
|
+
const v = JSON.parse(readFileSync(p, "utf8"));
|
|
14
|
+
return v && typeof v === "object" ? v : {};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function loadContinued(space, home) {
|
|
21
|
+
return new Set(readAll(home)[space] ?? []);
|
|
22
|
+
}
|
|
23
|
+
export function markContinued(space, id, home) {
|
|
24
|
+
try {
|
|
25
|
+
const all = readAll(home);
|
|
26
|
+
const list = (all[space] ?? []).filter((x) => x !== id);
|
|
27
|
+
list.push(id);
|
|
28
|
+
all[space] = list.slice(-KEEP);
|
|
29
|
+
const p = continuedPath(home);
|
|
30
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
31
|
+
writeFileSync(p, JSON.stringify(all));
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=continued.js.map
|
package/dist/decision-ui.js
CHANGED
|
@@ -4,7 +4,14 @@ export function decisionTitle(body, max = 96) {
|
|
|
4
4
|
.replace(/^[a-z0-9._-]+\s+escalated:\s*/i, "")
|
|
5
5
|
.replace(/^[a-z0-9._-]+\s+needs a decision:\s*/i, "")
|
|
6
6
|
.replace(/^human (?:action|input) required\s*[—:-]\s*/i, "")
|
|
7
|
-
.replace(/^human (?:action|input) required\s*/i, "")
|
|
7
|
+
.replace(/^human (?:action|input) required\s*/i, "")
|
|
8
|
+
.replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
|
|
9
|
+
.replace(/\(goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
|
|
10
|
+
.replace(/\s*\b(?:of|in|for|on|from)?\s*\(?\bgoal\s+g_[a-z0-9]+\)?/gi, "")
|
|
11
|
+
.replace(/\s{2,}/g, " ")
|
|
12
|
+
.replace(/[\s,]+(?:of|in|for|on|at|to|from)$/i, "")
|
|
13
|
+
.replace(/[\s,;:]+$/, "")
|
|
14
|
+
.trim();
|
|
8
15
|
const blockedAsk = text.match(/^cannot answer (.+?)\s+ask\s*:/i);
|
|
9
16
|
if (blockedAsk) {
|
|
10
17
|
text = `Decide whether to share the information requested for ${blockedAsk[1]}`;
|
|
@@ -25,6 +32,12 @@ export function decisionPrompt(id) {
|
|
|
25
32
|
export function allDecisionsPrompt(count) {
|
|
26
33
|
return `Check my ${count} Pantheon decision${count === 1 ? "" : "s"}. Explain each one in plain language: what happened, why I am needed, my options, and your recommendation. Prioritize what needs attention first. Do not decide or act for me; wait for my answers.`;
|
|
27
34
|
}
|
|
35
|
+
export function owedPrompt(title, goal) {
|
|
36
|
+
return `Pantheon: my side owes this — "${title}". Read the thread (pantheon history${goal ? ` --goal ${goal}` : ""}), tell me in plain language what is owed, what is already done, and exactly what you would send to discharge it. Draft the act, then wait for me before sending.`;
|
|
37
|
+
}
|
|
38
|
+
export function waitingPrompt(who, title, goal) {
|
|
39
|
+
return `Pantheon: I am waiting on ${who} for "${title}". Read the thread (pantheon history${goal ? ` --goal ${goal}` : ""}), tell me whether it is genuinely blocked on them, how long it has been waiting, and whether anything on my side is holding it up. If a nudge is warranted, draft a short one and wait for me.`;
|
|
40
|
+
}
|
|
28
41
|
export function workerSummary(raw) {
|
|
29
42
|
const state = oneLine(raw);
|
|
30
43
|
if (/deferring.*turn is in flight|holding.*live session/i.test(state))
|
package/dist/humanize.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const PARA_CHARS = 340;
|
|
2
|
+
const flatten = (s) => s.replace(/[\u2018\u2019\u201B]/g, "'").replace(/[\u201C\u201D]/g, '"');
|
|
3
|
+
export function stripIds(text, goalTitle) {
|
|
4
|
+
let out = flatten(text);
|
|
5
|
+
if (goalTitle) {
|
|
6
|
+
const esc = flatten(goalTitle).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
out = out.replace(new RegExp(`\\s*\\(\\s*"${esc}"\\s*\\)`, "gi"), "");
|
|
8
|
+
out = out.replace(new RegExp(`\\s*,?\\s*"${esc}"`, "gi"), "");
|
|
9
|
+
}
|
|
10
|
+
return out
|
|
11
|
+
.replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
|
|
12
|
+
.replace(/^[a-z0-9._-]+\s+(?:escalated|needs a decision)\s*:\s*/i, "")
|
|
13
|
+
.replace(/\(\s*goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
|
|
14
|
+
.replace(/\s*\(\s*goal\s+g_[a-z0-9]+\s*\)/gi, "")
|
|
15
|
+
.replace(/\s*\bgoal\s+g_[a-z0-9]+/gi, "")
|
|
16
|
+
.replace(/\s*\bid=[ao]_[a-z0-9]+/gi, "")
|
|
17
|
+
.replace(/\(\s*\)/g, "")
|
|
18
|
+
.replace(/\s+([,.;:])/g, "$1")
|
|
19
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
const ABBREV = /\b(?:e\.g|i\.e|vs|etc|approx|no|fig|mr|ms|dr|jr|sr|[a-z])\.$/i;
|
|
23
|
+
export function sentences(text) {
|
|
24
|
+
const out = [];
|
|
25
|
+
let buf = "";
|
|
26
|
+
for (const piece of text.split(/(?<=[.!?])\s+/)) {
|
|
27
|
+
buf = buf ? `${buf} ${piece}` : piece;
|
|
28
|
+
if (ABBREV.test(buf.trim()))
|
|
29
|
+
continue;
|
|
30
|
+
if (/[.!?]["')\]]?$/.test(buf.trim())) {
|
|
31
|
+
out.push(buf.trim());
|
|
32
|
+
buf = "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (buf.trim())
|
|
36
|
+
out.push(buf.trim());
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
const VERBS = /^(?:uname|hostname|echo|ls|cat|grep|tail|head|node|npm|npx|pnpm|yarn|git|gh|pantheon|cowork|xcode-select|launchctl|systemctl|curl|wget|which|ps|kill|pkill|sudo|open|defaults|codesign|swiftc|swift|python3?|pip3?|docker|make|cd|mkdir|rm|cp|mv|chmod|export|brew|sw_vers|scutil|lsof|dig|ping|ssh|scp|awk|sed|sort|uniq|wc|jq|tar|zip|unzip|env|printenv|type|command)\b/;
|
|
40
|
+
const PROSE_WORDS = /\s(?:the|a|an|is|are|was|were|be|been|and|or|to|of|that|this|with|from|into|please|it|its|our|your|their|her|his|for|but|so|then|when|while|which|who|what|why|how|should|would|could|will|can|may|must|has|have|had|does|did|say|says|said|asks?|needs?|wants?)\s/i;
|
|
41
|
+
export function commandLike(segment) {
|
|
42
|
+
const t = segment.trim().replace(/[.,]$/, "").trim();
|
|
43
|
+
if (!t || t.length > 160)
|
|
44
|
+
return false;
|
|
45
|
+
if (PROSE_WORDS.test(` ${t} `))
|
|
46
|
+
return false;
|
|
47
|
+
const first = t.split(/\s+/)[0] ?? "";
|
|
48
|
+
if (!/^[a-z[$][\w.[\]$-]*$/.test(first))
|
|
49
|
+
return false;
|
|
50
|
+
const words = t.split(/\s+/).length;
|
|
51
|
+
return VERBS.test(t) || /\s-{1,2}[\w-]|[~/]|\||&&|\$\(|^\[/.test(t) || words <= 2;
|
|
52
|
+
}
|
|
53
|
+
function splitLeadIn(segment) {
|
|
54
|
+
const marks = [...segment.matchAll(/[:,]\s+/g)];
|
|
55
|
+
for (let i = marks.length - 1; i >= 0; i--) {
|
|
56
|
+
const at = (marks[i].index ?? 0) + marks[i][0].length;
|
|
57
|
+
const tail = segment.slice(at);
|
|
58
|
+
if (commandLike(tail))
|
|
59
|
+
return { lead: segment.slice(0, at).trim(), cmd: tail.trim() };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function asOptions(sentence) {
|
|
64
|
+
const marks = [...sentence.matchAll(/\((?:[a-z]|[ivx]{1,3})\)\s*/gi)];
|
|
65
|
+
if (marks.length < 2)
|
|
66
|
+
return null;
|
|
67
|
+
const lead = sentence.slice(0, marks[0].index ?? 0).trim().replace(/[,:]$/, "");
|
|
68
|
+
const items = [];
|
|
69
|
+
for (let i = 0; i < marks.length; i++) {
|
|
70
|
+
const from = (marks[i].index ?? 0) + marks[i][0].length;
|
|
71
|
+
const to = i + 1 < marks.length ? marks[i + 1].index ?? sentence.length : sentence.length;
|
|
72
|
+
const item = sentence.slice(from, to).trim()
|
|
73
|
+
.replace(/[,;.]\s*(?:or|and)?\s*$/i, "")
|
|
74
|
+
.replace(/^(?:or|and)\s+/i, "")
|
|
75
|
+
.trim();
|
|
76
|
+
if (item)
|
|
77
|
+
items.push(item);
|
|
78
|
+
}
|
|
79
|
+
return items.length >= 2 ? { lead, items } : null;
|
|
80
|
+
}
|
|
81
|
+
const ITEM = /^(?:[-*•]|\d+[.)])\s+/;
|
|
82
|
+
function asWrittenList(lines) {
|
|
83
|
+
const at = lines.findIndex((l) => ITEM.test(l));
|
|
84
|
+
if (at < 0 || lines.length - at < 2)
|
|
85
|
+
return null;
|
|
86
|
+
if (!lines.slice(at).every((l) => ITEM.test(l)))
|
|
87
|
+
return null;
|
|
88
|
+
return {
|
|
89
|
+
lead: lines.slice(0, at).join(" ").trim(),
|
|
90
|
+
items: lines.slice(at).map((l) => l.replace(ITEM, "").trim()),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function extractCommands(sentence) {
|
|
94
|
+
if (!sentence.includes(";"))
|
|
95
|
+
return null;
|
|
96
|
+
const parts = sentence.split(";").map((p) => p.trim()).filter(Boolean);
|
|
97
|
+
if (parts.length < 2)
|
|
98
|
+
return null;
|
|
99
|
+
let lead = "";
|
|
100
|
+
let start = -1;
|
|
101
|
+
const lines = [];
|
|
102
|
+
for (let i = 0; i < parts.length; i++) {
|
|
103
|
+
const part = parts[i];
|
|
104
|
+
if (commandLike(part)) {
|
|
105
|
+
if (start < 0)
|
|
106
|
+
start = i;
|
|
107
|
+
lines.push(part.replace(/\.$/, "").trim());
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (start < 0) {
|
|
111
|
+
const split = splitLeadIn(part);
|
|
112
|
+
if (split) {
|
|
113
|
+
lead = [lead, split.lead].filter(Boolean).join(" ");
|
|
114
|
+
start = i;
|
|
115
|
+
lines.push(split.cmd);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
lead = [lead, part].filter(Boolean).join("; ");
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
return lines.length >= 2 ? { lead: lead.trim(), lines, tail: parts.slice(i).join("; ").trim() } : null;
|
|
122
|
+
}
|
|
123
|
+
return lines.length >= 2 ? { lead: lead.trim(), lines, tail: "" } : null;
|
|
124
|
+
}
|
|
125
|
+
function paragraphs(text) {
|
|
126
|
+
const parts = sentences(text);
|
|
127
|
+
const out = [];
|
|
128
|
+
let buf = "";
|
|
129
|
+
for (const s of parts) {
|
|
130
|
+
const next = buf ? `${buf} ${s}` : s;
|
|
131
|
+
if (next.length > PARA_CHARS && buf) {
|
|
132
|
+
out.push({ kind: "para", text: buf });
|
|
133
|
+
buf = s;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
buf = next;
|
|
137
|
+
}
|
|
138
|
+
if (buf)
|
|
139
|
+
out.push({ kind: "para", text: buf });
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
const asLeadIn = (text) => text.trim().replace(/[,;]$/, ":").replace(/([^:.!?])$/, "$1:");
|
|
143
|
+
const push = (blocks, text, lead = false) => {
|
|
144
|
+
let t = text.trim();
|
|
145
|
+
if (!t)
|
|
146
|
+
return;
|
|
147
|
+
if (/^[a-z]/.test(t))
|
|
148
|
+
t = t[0].toUpperCase() + t.slice(1);
|
|
149
|
+
if (lead) {
|
|
150
|
+
blocks.push({ kind: "para", text: asLeadIn(t) });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
blocks.push(...paragraphs(t));
|
|
154
|
+
};
|
|
155
|
+
export function humanize(body, opts = {}) {
|
|
156
|
+
const clean = stripIds(body.replace(/\r\n/g, "\n"), opts.goalTitle);
|
|
157
|
+
const blocks = [];
|
|
158
|
+
for (const chunk of clean.split(/\n{2,}/)) {
|
|
159
|
+
const lines = chunk.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
160
|
+
if (!lines.length)
|
|
161
|
+
continue;
|
|
162
|
+
const written = asWrittenList(lines);
|
|
163
|
+
if (written) {
|
|
164
|
+
if (written.lead)
|
|
165
|
+
push(blocks, written.lead, true);
|
|
166
|
+
blocks.push({ kind: "bullets", items: written.items });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (lines.length >= 2 && lines.every((l) => commandLike(l))) {
|
|
170
|
+
blocks.push({ kind: "code", lines });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
let pending = "";
|
|
174
|
+
for (const sentence of sentences(lines.join(" "))) {
|
|
175
|
+
const options = asOptions(sentence);
|
|
176
|
+
if (options) {
|
|
177
|
+
push(blocks, `${pending} ${options.lead}`.trim(), true);
|
|
178
|
+
pending = "";
|
|
179
|
+
blocks.push({ kind: "bullets", items: options.items });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const cmds = extractCommands(sentence);
|
|
183
|
+
if (cmds) {
|
|
184
|
+
push(blocks, `${pending} ${cmds.lead}`.trim(), true);
|
|
185
|
+
pending = "";
|
|
186
|
+
blocks.push({ kind: "code", lines: cmds.lines });
|
|
187
|
+
if (cmds.tail)
|
|
188
|
+
pending = cmds.tail;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
pending = pending ? `${pending} ${sentence}` : sentence;
|
|
192
|
+
}
|
|
193
|
+
push(blocks, pending);
|
|
194
|
+
}
|
|
195
|
+
if (!blocks.length)
|
|
196
|
+
blocks.push({ kind: "para", text: clean });
|
|
197
|
+
const firstPara = blocks.find((b) => b.kind === "para");
|
|
198
|
+
let headline = sentences(firstPara?.text ?? clean)[0] ?? clean;
|
|
199
|
+
headline = headline
|
|
200
|
+
.replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
|
|
201
|
+
.replace(/^[a-z0-9._-]+\s+(?:escalated|needs a decision)\s*:\s*/i, "")
|
|
202
|
+
.trim();
|
|
203
|
+
if (headline)
|
|
204
|
+
headline = headline[0].toUpperCase() + headline.slice(1);
|
|
205
|
+
headline = headline.replace(/\.$/, "").trim();
|
|
206
|
+
const full = headline;
|
|
207
|
+
if (headline.length > 180)
|
|
208
|
+
headline = `${headline.slice(0, 179).trimEnd()}…`;
|
|
209
|
+
const first = blocks[0];
|
|
210
|
+
if (full && first && first.kind === "para") {
|
|
211
|
+
const [lead, ...rest] = sentences(first.text);
|
|
212
|
+
const same = (lead ?? "").replace(/\.$/, "").trim().toLowerCase() === full.toLowerCase();
|
|
213
|
+
if (same) {
|
|
214
|
+
const remainder = rest.join(" ").trim();
|
|
215
|
+
if (remainder)
|
|
216
|
+
blocks[0] = { kind: "para", text: remainder };
|
|
217
|
+
else
|
|
218
|
+
blocks.shift();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { headline: headline || "Open item", blocks };
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=humanize.js.map
|
package/dist/notifier.js
CHANGED
|
@@ -9,26 +9,37 @@ import { CONFIG_DIR } from "./config.js";
|
|
|
9
9
|
import { loadProfile } from "./profile.js";
|
|
10
10
|
import { APP_TOOLS, composeDecision, composeDelivery, TOOL_LABELS } from "./notice.js";
|
|
11
11
|
import { cliOnPath, detectAvailable } from "./agents.js";
|
|
12
|
+
import { whichPantheon } from "./service.js";
|
|
12
13
|
export const DEFAULT_REMINDER_MS = 4 * 60 * 60 * 1_000;
|
|
14
|
+
export const MAX_REMINDERS = 4;
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
13
16
|
export function humanObligations(obligations, member) {
|
|
14
17
|
return obligations.filter((o) => o.kind === "HUMAN" && o.owner === `human:${member}`);
|
|
15
18
|
}
|
|
16
19
|
export function dueHumanObligations(obligations, member, state, now, reminderMs) {
|
|
17
20
|
return humanObligations(obligations, member).filter((o) => {
|
|
18
21
|
const last = state.notified_at[o.id];
|
|
19
|
-
|
|
22
|
+
if (last === undefined)
|
|
23
|
+
return true;
|
|
24
|
+
const n = state.reminded?.[o.id] ?? 0;
|
|
25
|
+
if (n >= MAX_REMINDERS)
|
|
26
|
+
return false;
|
|
27
|
+
const gap = Math.min(reminderMs * 2 ** n, Math.max(reminderMs, DAY_MS));
|
|
28
|
+
return now - last >= gap;
|
|
20
29
|
});
|
|
21
30
|
}
|
|
22
31
|
export function pruneNotificationState(state, obligations, member, now = Date.now()) {
|
|
23
32
|
const active = new Set(humanObligations(obligations, member).map((o) => o.id));
|
|
33
|
+
const reminded = Object.fromEntries(Object.entries(state.reminded ?? {}).filter(([id]) => active.has(id)));
|
|
24
34
|
return {
|
|
25
35
|
notified_at: Object.fromEntries(Object.entries(state.notified_at).filter(([id, at]) => active.has(id) || (id.startsWith("act:") && now - at < 7 * 86_400_000))),
|
|
36
|
+
...(Object.keys(reminded).length ? { reminded } : {}),
|
|
26
37
|
};
|
|
27
38
|
}
|
|
28
39
|
function readState(file) {
|
|
29
40
|
try {
|
|
30
41
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
31
|
-
return { notified_at: parsed.notified_at ?? {} };
|
|
42
|
+
return { notified_at: parsed.notified_at ?? {}, ...(parsed.reminded ? { reminded: parsed.reminded } : {}) };
|
|
32
43
|
}
|
|
33
44
|
catch {
|
|
34
45
|
return { notified_at: {} };
|
|
@@ -111,7 +122,7 @@ export function sendDesktopNotification(notice, home = CONFIG_DIR) {
|
|
|
111
122
|
if (!existsSync(binary))
|
|
112
123
|
return false;
|
|
113
124
|
try {
|
|
114
|
-
const child = spawn(binary, ["post", "--json", JSON.stringify({ ...notice, bin_dir: join(home, "bin") })], { detached: true, stdio: "ignore" });
|
|
125
|
+
const child = spawn(binary, ["post", "--json", JSON.stringify({ ...notice, bin_dir: join(home, "bin"), pantheon: whichPantheon() ?? "pantheon" })], { detached: true, stdio: "ignore" });
|
|
115
126
|
child.unref();
|
|
116
127
|
return true;
|
|
117
128
|
}
|
|
@@ -139,7 +150,7 @@ export class HumanNotifier {
|
|
|
139
150
|
const pollMs = this.opts.pollMs ?? 5_000;
|
|
140
151
|
let state = readState(stateFile);
|
|
141
152
|
this.opts.log(`notifier online as ${this.cfg.member}`);
|
|
142
|
-
this.opts.log(` reminders
|
|
153
|
+
this.opts.log(` reminders after ${Math.round(this.opts.reminderMs / 60_000)} minutes, then doubling to daily, ${MAX_REMINDERS} at most — after that a decision waits on pantheon brief and the menu bar`);
|
|
143
154
|
while (!this.stopped) {
|
|
144
155
|
const pruned = pruneNotificationState(state, client.obligations, this.cfg.member, now());
|
|
145
156
|
let dirty = JSON.stringify(pruned) !== JSON.stringify(state);
|
|
@@ -153,13 +164,16 @@ export class HumanNotifier {
|
|
|
153
164
|
const peerOf = (from) => from ? { member: from, agent: client.roster.find((m) => m.id === from)?.agent } : undefined;
|
|
154
165
|
const withLabel = (n) => this.opts.label ? { ...n, context: n.context ? `${this.opts.label} · ${n.context}` : this.opts.label } : n;
|
|
155
166
|
for (const obligation of due) {
|
|
167
|
+
const isReminder = state.notified_at[obligation.id] !== undefined;
|
|
156
168
|
const act = client.seenAct(obligation.ref);
|
|
157
169
|
const notice = withLabel(composeDecision(obligation, act, obligation.goal ? client.goals.get(obligation.goal) : undefined, peerOf(act?.from), { tools, preferred: preferredTool(profile, tools), now: now() }));
|
|
158
170
|
const ok = send(notice);
|
|
159
171
|
if (ok) {
|
|
160
172
|
state.notified_at[obligation.id] = now();
|
|
173
|
+
if (isReminder)
|
|
174
|
+
state.reminded = { ...(state.reminded ?? {}), [obligation.id]: (state.reminded?.[obligation.id] ?? 0) + 1 };
|
|
161
175
|
dirty = true;
|
|
162
|
-
this.opts.log(`> notified for ${obligation.id} (${oneLine(obligation.body, 100)})`);
|
|
176
|
+
this.opts.log(`> ${isReminder ? `reminder ${state.reminded[obligation.id]}/${MAX_REMINDERS}` : "notified"} for ${obligation.id} (${oneLine(obligation.body, 100)})`);
|
|
163
177
|
}
|
|
164
178
|
else {
|
|
165
179
|
this.opts.log(`! could not show a desktop notification for ${obligation.id}`);
|
package/dist/relay.js
CHANGED
|
@@ -263,6 +263,17 @@ export class Relay {
|
|
|
263
263
|
: {}),
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
|
+
if (url.pathname === "/act") {
|
|
267
|
+
const space = this.spaces.get(url.searchParams.get("space") ?? "");
|
|
268
|
+
const token = url.searchParams.get("token") ?? "";
|
|
269
|
+
if (!space || space.token !== token)
|
|
270
|
+
return this.json(res, 404, { error: "no such space" });
|
|
271
|
+
const act = space.ledger.find(url.searchParams.get("id") ?? "");
|
|
272
|
+
if (!act)
|
|
273
|
+
return this.json(res, 404, { error: "no such act" });
|
|
274
|
+
const goal = act.goal ? space.goals.get(act.goal) : undefined;
|
|
275
|
+
return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
|
|
276
|
+
}
|
|
266
277
|
if (url.pathname === "/history") {
|
|
267
278
|
const space = this.spaces.get(url.searchParams.get("space") ?? "");
|
|
268
279
|
const token = url.searchParams.get("token") ?? "";
|
package/dist/tray.js
CHANGED
|
@@ -11,7 +11,7 @@ import { requireDeveloperTools } from "./notifier.js";
|
|
|
11
11
|
import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
|
|
12
12
|
import { VERSION } from "./version.js";
|
|
13
13
|
import { LIMITS } from "./protocol.js";
|
|
14
|
-
import { allDecisionsPrompt, decisionPrompt, decisionTitle, workerSummary } from "./decision-ui.js";
|
|
14
|
+
import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, waitingPrompt, workerSummary } from "./decision-ui.js";
|
|
15
15
|
import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
|
|
16
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
export const trayBinaryPath = (home = CONFIG_DIR) => join(home, "bin", "pantheon-tray");
|
|
@@ -93,6 +93,9 @@ export async function trayStatus(cfg = load(), f = loadAll()) {
|
|
|
93
93
|
members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
|
|
94
94
|
decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
|
|
95
95
|
decisions_prompt: allDecisionsPrompt(ok.reduce((n, p) => n + (p.decisions?.length ?? 0), 0)),
|
|
96
|
+
items: ok.flatMap((p) => (p.items ?? []).map((i) => ({ ...i, space: p.space }))),
|
|
97
|
+
owed_count: ok.reduce((n, p) => n + (p.owed_count ?? 0), 0),
|
|
98
|
+
waiting_count: ok.reduce((n, p) => n + (p.waiting_count ?? 0), 0),
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
101
|
return trayStatusOne(cfg);
|
|
@@ -104,14 +107,33 @@ async function trayStatusOne(cfg) {
|
|
|
104
107
|
const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
|
|
105
108
|
const [health, obligations] = await Promise.all([
|
|
106
109
|
getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
|
|
107
|
-
getJson(`${base}/obligations?${q}
|
|
110
|
+
getJson(`${base}/obligations?${q}`),
|
|
108
111
|
]);
|
|
109
112
|
const space = health
|
|
110
113
|
?.spaces?.find((s) => s.name === cfg.space);
|
|
111
114
|
const pending = obligations?.obligations;
|
|
112
|
-
const
|
|
113
|
-
const
|
|
115
|
+
const ok = Array.isArray(pending) && pending.every((o) => o && typeof o.id === "string" && typeof o.body === "string" && typeof o.owner === "string");
|
|
116
|
+
const all = (ok ? pending : []);
|
|
117
|
+
const mineHuman = all.filter((o) => o.owner === `human:${cfg.member}`);
|
|
118
|
+
const decisionsOk = ok;
|
|
119
|
+
const decisions = mineHuman
|
|
114
120
|
.map((o) => ({ id: o.id, body: decisionTitle(o.body), detail: o.body, prompt: decisionPrompt(o.id) }));
|
|
121
|
+
const row = (o, kind, who, prompt) => ({
|
|
122
|
+
id: o.id, kind, title: decisionTitle(o.body), headline: decisionTitle(o.body, 200), detail: o.body, prompt, who,
|
|
123
|
+
when: o.created_at ?? 0,
|
|
124
|
+
...(o.goal ? { goal: o.goal } : {}),
|
|
125
|
+
...(o.ref ? { ref: o.ref } : {}),
|
|
126
|
+
});
|
|
127
|
+
const owed = all.filter((o) => o.owner === cfg.member);
|
|
128
|
+
const waiting = all.filter((o) => o.owner !== cfg.member && o.owner !== `human:${cfg.member}`);
|
|
129
|
+
const items = [
|
|
130
|
+
...mineHuman.map((o) => row(o, "decision", "you", decisionPrompt(o.id))),
|
|
131
|
+
...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
|
|
132
|
+
...waiting.map((o) => {
|
|
133
|
+
const who = o.owner.replace(/^human:/, "");
|
|
134
|
+
return row(o, "waiting", who, waitingPrompt(who, decisionTitle(o.body), o.goal));
|
|
135
|
+
}),
|
|
136
|
+
];
|
|
115
137
|
const profile = loadProfile(cfg);
|
|
116
138
|
const surface = profile.agent.primary_surface;
|
|
117
139
|
const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : "Codex";
|
|
@@ -137,6 +159,9 @@ async function trayStatusOne(cfg) {
|
|
|
137
159
|
surface_bundle_id: SURFACE_BUNDLE_IDS[surface],
|
|
138
160
|
decisions_prompt: allDecisionsPrompt(decisions.length),
|
|
139
161
|
decisions,
|
|
162
|
+
items,
|
|
163
|
+
owed_count: owed.length,
|
|
164
|
+
waiting_count: waiting.length,
|
|
140
165
|
};
|
|
141
166
|
}
|
|
142
167
|
//# sourceMappingURL=tray.js.map
|
package/dist/upgrade.js
CHANGED
|
@@ -87,7 +87,11 @@ export function upgrade(deps = defaultDeps()) {
|
|
|
87
87
|
catch (e) {
|
|
88
88
|
steps.push({ name: "wire", ok: false, detail: `could not refresh agent config: ${e.message.split("\n")[0]} (run \`pantheon install --user\`)` });
|
|
89
89
|
}
|
|
90
|
-
const to = deps.
|
|
90
|
+
const to = deps.version(root);
|
|
91
|
+
if (to === from) {
|
|
92
|
+
steps.push({ name: "check", ok: true, detail: `${from} is the newest the registry serves right now — a version published minutes ago can take a few minutes to appear` });
|
|
93
|
+
return { root, from, to, steps, ok: true, alreadyCurrent: true, restart: [] };
|
|
94
|
+
}
|
|
91
95
|
const restart = [...deps.running(), "your agent session"];
|
|
92
96
|
return { root, from, to, steps, ok: true, alreadyCurrent: false, restart };
|
|
93
97
|
}
|