@timqi/pier 0.1.14 → 0.2.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.
- package/README.md +44 -22
- package/dist/agent/config.js +2 -2
- package/dist/agent/events.js +32 -3
- package/dist/agent/packages.js +6 -10
- package/dist/agent/pi.js +111 -68
- package/dist/agent/roles.js +103 -0
- package/dist/agent/types.js +78 -0
- package/dist/boards/boards.js +42 -32
- package/dist/channels/chains.js +0 -4
- package/dist/channels/commands.js +7 -12
- package/dist/channels/config.js +27 -1
- package/dist/channels/control.js +5 -16
- package/dist/channels/conversations.js +0 -5
- package/dist/channels/lark-api.js +20 -26
- package/dist/channels/lark-outbound.js +16 -29
- package/dist/channels/lark-panel.js +1 -1
- package/dist/channels/lark.js +61 -39
- package/dist/channels/lines.js +33 -1
- package/dist/channels/panel.js +17 -107
- package/dist/channels/routes.js +11 -21
- package/dist/channels/runtime.js +34 -25
- package/dist/channels/slack-outbound.js +6 -15
- package/dist/channels/slack-panel.js +1 -1
- package/dist/channels/slack-render.js +1 -1
- package/dist/channels/slack.js +63 -57
- package/dist/channels/types.js +1 -1
- package/dist/config-sync.js +42 -37
- package/dist/core/chain.js +202 -0
- package/dist/core/identity.js +34 -11
- package/dist/core/inbound-file.js +4 -3
- package/dist/core/reply.js +96 -80
- package/dist/core/router.js +111 -118
- package/dist/core/types.js +36 -77
- package/dist/db.js +33 -2
- package/dist/main.js +46 -37
- package/dist/settings.js +25 -18
- package/dist/tasks/agent.js +11 -5
- package/dist/tasks/callbacks.js +55 -5
- package/dist/tasks/cli.js +22 -11
- package/dist/tasks/definitions.js +30 -17
- package/dist/tasks/execution.js +2 -2
- package/dist/tasks/groups.js +5 -2
- package/dist/tasks/messages.js +7 -7
- package/dist/tasks/open-items.js +70 -0
- package/dist/tasks/operations.js +76 -39
- package/dist/tasks/outbox.js +19 -6
- package/dist/tasks/routes.js +19 -249
- package/dist/tasks/service.js +145 -34
- package/dist/tasks/store.js +119 -56
- package/dist/tasks/types.js +5 -0
- package/dist/tools.js +2 -2
- package/dist/web/auth.js +2 -2
- package/dist/web/config-sync.js +4 -5
- package/dist/web/config.js +9 -2
- package/dist/web/explorer.js +2 -2
- package/dist/web/fs.js +16 -1
- package/dist/web/instance.js +8 -5
- package/dist/web/packages.js +1 -1
- package/dist/web/passkeys.js +3 -3
- package/dist/web/providers.js +3 -3
- package/dist/web/public/assets/code-BZwvPani.js +2 -0
- package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
- package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
- package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
- package/dist/web/public/index.html +40 -109
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +0 -2
- package/dist/web/push.js +1 -1
- package/dist/web/server.js +91 -116
- package/dist/web/session-state.js +5 -40
- package/docs/deploy.md +5 -3
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +14 -4
- package/skills/pier-help/SKILL.md +57 -23
- package/skills/pier-tasks/SKILL.md +58 -18
- package/dist/channels/handoff.js +0 -94
- package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
- package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
- package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
- package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
- package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js +0 -85
- package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
- package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
- package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
- package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
- package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
- package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Which session takes the user's message: an ordered chain of main sessions in
|
|
2
|
+
// `$PIER_HOME/home`, whose newest — the head — takes every one, rotated lazily
|
|
3
|
+
// once idle past an hour or full (docs/design/10-continuous-session.md).
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { transact } from "../db.js";
|
|
8
|
+
import { logger } from "../log.js";
|
|
9
|
+
import { day } from "./identity.js";
|
|
10
|
+
import { CHAIN_FULL_TOKENS, CHAIN_IDLE_MS, isChatCommand } from "./types.js";
|
|
11
|
+
const log = logger("core");
|
|
12
|
+
const EXCHANGES = 3;
|
|
13
|
+
const WHY = {
|
|
14
|
+
first: "the first one",
|
|
15
|
+
idle: "the previous one was idle for an hour",
|
|
16
|
+
lost: "the previous one is gone from Pi",
|
|
17
|
+
full: `the previous one reached ${String(CHAIN_FULL_TOKENS / 1000)}K tokens`,
|
|
18
|
+
new: "you asked for one with /new",
|
|
19
|
+
};
|
|
20
|
+
/** A `/new` sent to a head mid-reply: a 409 to the composer, never a card the
|
|
21
|
+
* head's queue would only deliver after the turn it refuses for. */
|
|
22
|
+
export class ChatCommandRefused extends Error {
|
|
23
|
+
}
|
|
24
|
+
const webKey = (sessionId) => ({ channelId: "web", conversationId: sessionId });
|
|
25
|
+
/** The last `n` user turns and the replies after them, text only. */
|
|
26
|
+
function lastExchanges(turns, n) {
|
|
27
|
+
const users = turns.flatMap((t, i) => (t.role === "user" ? [i] : []));
|
|
28
|
+
const from = users[Math.max(0, users.length - n)];
|
|
29
|
+
if (from === undefined)
|
|
30
|
+
return "";
|
|
31
|
+
return turns.slice(from).filter((t) => t.role !== "system" && t.text)
|
|
32
|
+
.map((t) => `${t.role}: ${t.text}`).join("\n\n");
|
|
33
|
+
}
|
|
34
|
+
const ledgerLine = (r) => `${r.runId} · ${r.name} · ${r.state} · session ${r.targetSessionId ?? "—"} · ${r.cwd ?? "—"}`;
|
|
35
|
+
/** `/skills`' answer on every surface, the head's and a thread's (channels/). */
|
|
36
|
+
export const skillsText = (skills) => skills.map((s) => `${s.name} — ${s.description}`).join("\n") || "no skills";
|
|
37
|
+
/** Only the exact word is a command: the composer is not a shell. */
|
|
38
|
+
function chatCommand(text) {
|
|
39
|
+
const draft = text.trim().toLowerCase();
|
|
40
|
+
const word = draft.slice(1);
|
|
41
|
+
return /^[/%]/.test(draft) && isChatCommand(word) ? word : undefined;
|
|
42
|
+
}
|
|
43
|
+
export class MainChain {
|
|
44
|
+
db;
|
|
45
|
+
deps;
|
|
46
|
+
/** Sends pass one at a time, so two cannot both find the head idle and rotate twice. */
|
|
47
|
+
queue = Promise.resolve();
|
|
48
|
+
/** The chat the send in flight came from, if any: the head is attached under
|
|
49
|
+
* it before anything is appended, so the seed reaches the chat too. */
|
|
50
|
+
chat;
|
|
51
|
+
constructor(db, deps) {
|
|
52
|
+
this.db = db;
|
|
53
|
+
this.deps = deps;
|
|
54
|
+
}
|
|
55
|
+
/** Newest first. */
|
|
56
|
+
members() {
|
|
57
|
+
const rows = this.db.prepare("SELECT session_id, started_at, reason FROM main_chain ORDER BY started_at DESC, rowid DESC")
|
|
58
|
+
.all();
|
|
59
|
+
return rows.map((r) => ({ sessionId: r.session_id, startedAt: r.started_at, reason: r.reason }));
|
|
60
|
+
}
|
|
61
|
+
/** Every member's id, newest first, when `sessionId` is one: a member's
|
|
62
|
+
* launches are the whole chain's, and a result owed to it goes to the head. */
|
|
63
|
+
chainOf(sessionId) {
|
|
64
|
+
const ids = this.members().map((m) => m.sessionId);
|
|
65
|
+
return ids.includes(sessionId) ? ids : undefined;
|
|
66
|
+
}
|
|
67
|
+
/** What `/status` answers, now: the Status panel shows this same text. */
|
|
68
|
+
status() {
|
|
69
|
+
return this.deps.status(this.now());
|
|
70
|
+
}
|
|
71
|
+
/** Resolve the head, rotating it first when due, then dispatch to `key` (a
|
|
72
|
+
* chat's) or else the head's own; `command` names a chat command, answered
|
|
73
|
+
* without a turn. */
|
|
74
|
+
send(message, key) {
|
|
75
|
+
const command = chatCommand(message.text);
|
|
76
|
+
if (command)
|
|
77
|
+
return this.serial(key, (session, rotated) => this.command(command, session, rotated)).then((head) => ({ ...head, command }));
|
|
78
|
+
return this.serial(key, async (session) => {
|
|
79
|
+
await this.deps.router.dispatch({ ...message, key: key ?? webKey(session.id) });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** `status`, `stop` and `skills` answer with a card on the head; `new` answers with the
|
|
83
|
+
* next head's seed card — a head just rotated for its own reason is that answer. */
|
|
84
|
+
async command(command, session, rotated) {
|
|
85
|
+
const origin = { kind: "chat-command", command };
|
|
86
|
+
if (command === "new") {
|
|
87
|
+
if (session.state === "streaming")
|
|
88
|
+
throw new ChatCommandRefused("the conversation is replying — /stop first");
|
|
89
|
+
if (rotated)
|
|
90
|
+
return undefined;
|
|
91
|
+
return { session: await this.rotate("new", this.members()[0], session), rotated: "new" };
|
|
92
|
+
}
|
|
93
|
+
if (command === "skills") {
|
|
94
|
+
await session.systemInput(skillsText(session.skills()), origin, "append");
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (command === "stop") {
|
|
98
|
+
const running = session.state === "streaming";
|
|
99
|
+
if (running)
|
|
100
|
+
await session.abort();
|
|
101
|
+
await session.systemInput(running ? "stopped" : "nothing running", origin, "append");
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const { text, sessions } = this.status();
|
|
105
|
+
await session.systemInput(text, { ...origin, sessions }, "append");
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
/** `then` may rotate again and return the head it made; the caller's answer is that one. */
|
|
109
|
+
serial(key, then) {
|
|
110
|
+
const done = this.queue.then(async () => {
|
|
111
|
+
this.chat = key;
|
|
112
|
+
const found = await this.current();
|
|
113
|
+
if (key)
|
|
114
|
+
this.deps.router.attach(key, found.session);
|
|
115
|
+
const { session, rotated } = (await then(found.session, found.rotated)) ?? found;
|
|
116
|
+
return { sessionId: session.id, ...(rotated ? { rotated } : {}) };
|
|
117
|
+
});
|
|
118
|
+
this.queue = done.catch(() => undefined);
|
|
119
|
+
return done;
|
|
120
|
+
}
|
|
121
|
+
now() {
|
|
122
|
+
return this.deps.now?.() ?? Date.now();
|
|
123
|
+
}
|
|
124
|
+
async current() {
|
|
125
|
+
const head = this.members()[0];
|
|
126
|
+
if (!head)
|
|
127
|
+
return { session: await this.rotate("first"), rotated: "first" };
|
|
128
|
+
const live = this.deps.router.stateOf(head.sessionId) !== undefined;
|
|
129
|
+
if (!live && !(await this.deps.factory.find(head.sessionId))) {
|
|
130
|
+
log.warn(`main session ${head.sessionId} is gone from Pi — starting the next one`);
|
|
131
|
+
return { session: await this.rotate("lost", head), rotated: "lost" };
|
|
132
|
+
}
|
|
133
|
+
const session = await this.deps.router.ensure(webKey(head.sessionId));
|
|
134
|
+
// A head with no user message yet counts from its start: its seed is not the user speaking.
|
|
135
|
+
const spoke = (await session.history()).reduce((at, t) => (t.role === "user" && t.at ? t.at : at), head.startedAt);
|
|
136
|
+
if (session.state === "streaming")
|
|
137
|
+
return { session };
|
|
138
|
+
if (this.now() - spoke >= CHAIN_IDLE_MS)
|
|
139
|
+
return { session: await this.rotate("idle", head, session), rotated: "idle" };
|
|
140
|
+
// Unknown right after a compaction: that head is small again, not full.
|
|
141
|
+
if ((session.contextUsage?.tokens ?? 0) <= CHAIN_FULL_TOKENS)
|
|
142
|
+
return { session };
|
|
143
|
+
return { session: await this.rotate("full", head, session), rotated: "full" };
|
|
144
|
+
}
|
|
145
|
+
async rotate(reason, previous, open) {
|
|
146
|
+
// Built before anything is created: a seed that fails leaves no orphan session.
|
|
147
|
+
const seed = await this.seed(reason, previous, open).catch((err) => {
|
|
148
|
+
log.warn("the continuous conversation's next session could not be seeded", err);
|
|
149
|
+
throw new Error(`a new session could not start — its seed failed: ${String(err)}`);
|
|
150
|
+
});
|
|
151
|
+
// An unset or unreadable default keeps the previous head's choice, then Pi's model at low effort.
|
|
152
|
+
const defaults = await this.deps.defaults().catch((err) => {
|
|
153
|
+
log.warn("the Settings defaults could not be read — the next main session keeps the previous one's model", err);
|
|
154
|
+
return { defaultModel: null, defaultThinkingLevel: null };
|
|
155
|
+
});
|
|
156
|
+
const model = defaults.defaultModel ?? open?.model;
|
|
157
|
+
mkdirSync(this.deps.home, { recursive: true });
|
|
158
|
+
const session = await this.deps.factory.create({
|
|
159
|
+
cwd: this.deps.home,
|
|
160
|
+
...(model ? { model } : {}),
|
|
161
|
+
thinking: defaults.defaultThinkingLevel ?? open?.thinkingLevel ?? "low",
|
|
162
|
+
});
|
|
163
|
+
transact(this.db, () => {
|
|
164
|
+
this.db.prepare("INSERT INTO main_chain(session_id, started_at, reason) VALUES (?, ?, ?)").run(session.id, this.now(), reason);
|
|
165
|
+
// A lost head has no transcript to page back to; the new head's reason says it went.
|
|
166
|
+
if (reason === "lost" && previous)
|
|
167
|
+
this.db.prepare("DELETE FROM main_chain WHERE session_id = ?").run(previous.sessionId);
|
|
168
|
+
});
|
|
169
|
+
this.deps.router.attach(webKey(session.id), session);
|
|
170
|
+
if (this.chat)
|
|
171
|
+
this.deps.router.attach(this.chat, session);
|
|
172
|
+
await session.systemInput(seed, { kind: "session-seed", reason, previousSessionId: previous?.sessionId ?? null }, "append");
|
|
173
|
+
log.info(`main session ${session.id} started (${reason})`);
|
|
174
|
+
return session;
|
|
175
|
+
}
|
|
176
|
+
/** Read fresh at every rotation; a part that cannot be read says so in the seed. */
|
|
177
|
+
async seed(reason, previous, open) {
|
|
178
|
+
const today = new Date(this.now());
|
|
179
|
+
const days = [new Date(today.getTime() - 86_400_000), today].map(day);
|
|
180
|
+
const runs = this.deps.ledger(this.members().map((m) => m.sessionId), previous?.startedAt ?? this.now());
|
|
181
|
+
const section = (title, text) => (text ? `## ${title}\n\n${text}` : "");
|
|
182
|
+
return [
|
|
183
|
+
`[Pier: a new session of the continuous conversation — ${WHY[reason]}. The rest of this note is context, not a message.]`,
|
|
184
|
+
section("MEMORY.md", await this.read("MEMORY.md")),
|
|
185
|
+
section("Open", this.deps.status(this.now()).text),
|
|
186
|
+
section("Runs — in flight, and finished since the previous session started", runs.map(ledgerLine).join("\n") || "none"),
|
|
187
|
+
...(await Promise.all(days.map(async (date) => section(`memory/${date}.md`, await this.read(join("memory", `${date}.md`)))))),
|
|
188
|
+
section("The previous session's last exchanges", open ? lastExchanges(await open.history(), EXCHANGES) : ""),
|
|
189
|
+
].filter(Boolean).join("\n\n");
|
|
190
|
+
}
|
|
191
|
+
async read(name) {
|
|
192
|
+
try {
|
|
193
|
+
return (await readFile(join(this.deps.home, name), "utf8")).trim();
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if (err.code === "ENOENT")
|
|
197
|
+
return "";
|
|
198
|
+
log.warn(`seed: could not read ${name}`, err);
|
|
199
|
+
return `(could not be read: ${String(err)})`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
package/dist/core/identity.js
CHANGED
|
@@ -19,7 +19,24 @@ export function sanitizeIdentity(value) {
|
|
|
19
19
|
const sanitizePlace = (value) => value.replace(/[[\]<>\s]+/g, "").slice(0, 120);
|
|
20
20
|
const two = (n) => String(n).padStart(2, "0");
|
|
21
21
|
const hhmm = (d) => `${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
22
|
-
const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
|
|
22
|
+
export const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
|
|
23
|
+
/** Pasted code, links and paths are English whatever the speaker writes in. */
|
|
24
|
+
const NOT_PROSE = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|[!-~]*[/\\][!-~]*/g;
|
|
25
|
+
/** The language a message is written in, or `undefined` when it is too short
|
|
26
|
+
* to tell (`ok`, `👍`) and the last one still applies. A CJK character counts
|
|
27
|
+
* as two Latin words: a short question under a pasted English log is still
|
|
28
|
+
* the speaker's language. */
|
|
29
|
+
export function detectLanguage(text) {
|
|
30
|
+
const prose = text.replace(NOT_PROSE, " ");
|
|
31
|
+
const han = prose.match(/\p{Script=Han}/gu)?.length ?? 0;
|
|
32
|
+
const kana = prose.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu)?.length ?? 0;
|
|
33
|
+
const hangul = prose.match(/\p{Script=Hangul}/gu)?.length ?? 0;
|
|
34
|
+
const words = prose.match(/[A-Za-z]{2,}/g)?.length ?? 0;
|
|
35
|
+
const cjk = han + kana + hangul;
|
|
36
|
+
if (cjk > 0 && cjk * 2 >= words)
|
|
37
|
+
return kana ? "ja" : hangul > han ? "ko" : "zh";
|
|
38
|
+
return words >= 3 ? "en" : undefined;
|
|
39
|
+
}
|
|
23
40
|
/** What each session has already been told. In memory: after a restart one
|
|
24
41
|
* redundant header is a rounding error. */
|
|
25
42
|
export class SenderPrefix {
|
|
@@ -27,18 +44,22 @@ export class SenderPrefix {
|
|
|
27
44
|
/** The line to put above this message, or `""` when the session already
|
|
28
45
|
* knows. `conversation` is the chat's `<channelId>:<conversationId>`, told
|
|
29
46
|
* once: a session never moves, but the rule stays the same as the rest.
|
|
30
|
-
* `opaqueIds` is the platform's (`core/types.ts`): its ids buy nothing.
|
|
31
|
-
|
|
47
|
+
* `opaqueIds` is the platform's (`core/types.ts`): its ids buy nothing.
|
|
48
|
+
* `text` is the message, for its language: `lang=zh` tells the model which
|
|
49
|
+
* language to answer in, since an English-heavy context drags replies there. */
|
|
50
|
+
next(sessionId, sender, at = Date.now(), conversation, opaqueIds = false, text = "") {
|
|
32
51
|
if (!sender?.id)
|
|
33
52
|
return "";
|
|
34
53
|
const last = this.seen.get(sessionId);
|
|
35
|
-
|
|
54
|
+
const lang = detectLanguage(text) ?? last?.lang;
|
|
55
|
+
this.seen.set(sessionId, { senderId: sender.id, at, conversation, lang });
|
|
36
56
|
const now = new Date(at);
|
|
37
57
|
const newSpeaker = last?.senderId !== sender.id;
|
|
38
58
|
const gap = !last || at - last.at >= GAP_MS;
|
|
39
59
|
const newDay = !last || day(new Date(last.at)) !== day(now);
|
|
40
60
|
const newPlace = !!conversation && last?.conversation !== conversation;
|
|
41
|
-
|
|
61
|
+
const newLang = !!lang && last?.lang !== lang;
|
|
62
|
+
if (!newSpeaker && !gap && !newDay && !newPlace && !newLang)
|
|
42
63
|
return "";
|
|
43
64
|
// The id is the only thing a mention can be built from; an unresolved name
|
|
44
65
|
// is the id, and `U123<U123>` would read as a broken record.
|
|
@@ -52,7 +73,7 @@ export class SenderPrefix {
|
|
|
52
73
|
const when = clock ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
|
|
53
74
|
const place = opaqueIds ? conversation?.split(":")[0] : conversation;
|
|
54
75
|
const where = newPlace && place ? sanitizePlace(place) : "";
|
|
55
|
-
return `[${[who, when, where].filter(Boolean).join(" ")}]`;
|
|
76
|
+
return `[${[who, when, where, newLang ? `lang=${lang}` : ""].filter(Boolean).join(" ")}]`;
|
|
56
77
|
}
|
|
57
78
|
forget(sessionId) {
|
|
58
79
|
this.seen.delete(sessionId);
|
|
@@ -62,18 +83,19 @@ export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text
|
|
|
62
83
|
// Only the shapes `next()` emits, newline included: a human typing
|
|
63
84
|
// `[14:23] on my way` is body text and must come back untouched.
|
|
64
85
|
const TIME = String.raw `(?<when>(?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})`;
|
|
65
|
-
const
|
|
86
|
+
const LANG = String.raw `lang=(?<lang>[a-z]{2})`;
|
|
87
|
+
const WITH_ID = new RegExp(String.raw `^\[(?:(?<name>[^\n[\]<>]*)<(?<id>[^\n[\]<>]+)>)? ?${TIME}? ?(?<where>[a-z]+:[^\s[\]<>]+)? ?(?:${LANG})?\]\n`);
|
|
66
88
|
/** The opaque-ids shape: a name with no `<>`, told apart from body text by the
|
|
67
89
|
* time that always follows it, and a platform with no conversation after it.
|
|
68
90
|
* A line of its own reading `[meeting 14:23]` is the price. */
|
|
69
|
-
const NAMED = new RegExp(String.raw `^\[(?<name>[^\n[\]<>]*?) ${TIME}(?: (?<where>[a-z]+))?\]\n`);
|
|
91
|
+
const NAMED = new RegExp(String.raw `^\[(?<name>[^\n[\]<>]*?) ${TIME}(?: (?<where>[a-z]+))?(?: ${LANG})?\]\n`);
|
|
70
92
|
/** Read back a header this module wrote: the prefix is for the model, and a
|
|
71
93
|
* surface showing a stored message renders the speaker its own way. */
|
|
72
94
|
export function splitSpeaker(text) {
|
|
73
95
|
const head = WITH_ID.exec(text);
|
|
74
|
-
const { id, when, where } = head?.groups ?? {};
|
|
96
|
+
const { id, when, where, lang } = head?.groups ?? {};
|
|
75
97
|
// A name on its own proves nothing: try the shape that requires a time.
|
|
76
|
-
const m = id || when || where ? head : NAMED.exec(text);
|
|
98
|
+
const m = id || when || where || lang ? head : NAMED.exec(text);
|
|
77
99
|
if (!m?.groups)
|
|
78
100
|
return { text };
|
|
79
101
|
return {
|
|
@@ -81,6 +103,7 @@ export function splitSpeaker(text) {
|
|
|
81
103
|
...(m.groups.id ? { id: m.groups.id } : {}),
|
|
82
104
|
...(m.groups.when ? { when: m.groups.when } : {}),
|
|
83
105
|
...(m.groups.where ? { where: m.groups.where } : {}),
|
|
106
|
+
...(m.groups.lang ? { lang: m.groups.lang } : {}),
|
|
84
107
|
text: text.slice(m[0].length),
|
|
85
108
|
};
|
|
86
109
|
}
|
|
@@ -108,7 +131,7 @@ export function readableTitle(title) {
|
|
|
108
131
|
.trim();
|
|
109
132
|
return said || title;
|
|
110
133
|
}
|
|
111
|
-
/** How a session is named where it is announced (a push
|
|
134
|
+
/** How a session is named where it is announced (a push): its
|
|
112
135
|
* readable title, else its directory. Never empty — a listing that could
|
|
113
136
|
* not answer must not silence the message. One line: every caller puts it
|
|
114
137
|
* inside emphasis or a button, where a newline breaks the markup. */
|
|
@@ -74,11 +74,12 @@ export function replaceOutsideCode(text, pattern, replace) {
|
|
|
74
74
|
let last = 0;
|
|
75
75
|
for (const match of text.matchAll(pattern)) {
|
|
76
76
|
const start = match.index;
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// A match is code when it begins in code; one that merely holds a span
|
|
78
|
+
// (`<open>run \`x\`</open>`) is prose around it.
|
|
79
|
+
if (skip.some(([from, to]) => start >= from && start < to))
|
|
79
80
|
continue;
|
|
80
81
|
out += text.slice(last, start) + replace(match);
|
|
81
|
-
last =
|
|
82
|
+
last = start + match[0].length;
|
|
82
83
|
}
|
|
83
84
|
return out + text.slice(last);
|
|
84
85
|
}
|
package/dist/core/reply.js
CHANGED
|
@@ -1,71 +1,27 @@
|
|
|
1
1
|
// Assistant-reply presentation, computed once for every surface: the next-step
|
|
2
|
-
// block syntax, silence, completion stats and the
|
|
3
|
-
// every IM adapter render these, so the wording
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Your replies render in a chat UI (web and IM). Three optional markdown
|
|
9
|
-
conventions:
|
|
10
|
-
|
|
11
|
-
- **Next-step buttons** — a last line of \`---\`, then up to 5 \`[label]\` tokens
|
|
12
|
-
separated by \`|\`: \`---\` / \`[Run it] | [Show the diff]\`. A click sends that
|
|
13
|
-
label as the user's next message. Only for short, obvious next moves, never
|
|
14
|
-
for anything destructive.
|
|
15
|
-
- **Attachments** — link a file you produced by absolute \`file://\` URL:
|
|
16
|
-
\`[report.md](file:///abs/path/report.md)\`. Images render as thumbnails,
|
|
17
|
-
other files as a download card, wherever on disk you wrote it. The same
|
|
18
|
-
convention runs inbound: a user message ending in \`[name](file:///…)\`
|
|
19
|
-
lines is carrying files the sender attached, already saved to disk — read
|
|
20
|
-
one only when it matters to the task; every read puts its content in your
|
|
21
|
-
context for good.
|
|
22
|
-
- **Staying silent** — \`<silent>why</silent>\` is stripped, and if nothing else
|
|
23
|
-
remains no message is sent. In a group chat you are handed every message,
|
|
24
|
-
including humans talking to each other: stay silent rather than acknowledge
|
|
25
|
-
what was not addressed to you.
|
|
26
|
-
|
|
27
|
-
A message may start with \`[name<id> time place]\` — the sender and the chat,
|
|
28
|
-
added by Pier, not typed by them. It appears only on a change — new speaker, a
|
|
29
|
-
~10-minute gap, a new day — so the last one still applies; a gap alone shows as
|
|
30
|
-
time only, like \`[14:23]\`. Use that \`id\` to mention someone; never ask for
|
|
31
|
-
their own. \`place\` is \`<platform>:<conversation>\` (Slack:
|
|
32
|
-
\`slack:<channel>/<thread_ts>\`), said once per session: the channel and thread a
|
|
33
|
-
script takes. Where no tool of yours takes that platform's ids, the header
|
|
34
|
-
carries neither and reads \`[name time platform]\`.
|
|
35
|
-
`;
|
|
36
|
-
/** Deployment facts an agent cannot discover: a guessed path is wrong wherever
|
|
37
|
-
* `PIER_HOME` moved and fails as "nothing is configured"; GPT models carry
|
|
38
|
-
* `apply_patch` from post-training and go hunting for it in the shell. */
|
|
39
|
-
export function surfacePrompt(instance) {
|
|
40
|
-
const reach = instance.publicUrl
|
|
41
|
-
? `Address: ${instance.publicUrl} — a board's link is that plus ` +
|
|
42
|
-
"`/boards/<slug>/`, or `/p/<slug>-<token>/` once published, where `token` " +
|
|
43
|
-
"is the random field the manifest carries beside `public`."
|
|
44
|
-
: "No public address is configured (the user sets one in Console → Settings), " +
|
|
45
|
-
"so give paths and never guess a host.";
|
|
46
|
-
return `${REPLY_SURFACE_PROMPT}
|
|
47
|
-
## This Pier instance
|
|
48
|
-
|
|
49
|
-
Boards: \`${instance.boardsDir}/<slug>/\` — this path, not \`~/.pier\`. ${reach}
|
|
50
|
-
|
|
51
|
-
Editing: files change through the \`edit\` tool (exact text replacement) or
|
|
52
|
-
\`write\`. There is no \`apply_patch\` here — not as a tool, not as a command —
|
|
53
|
-
so do not call one or go looking for one in the shell.
|
|
54
|
-
`;
|
|
55
|
-
}
|
|
2
|
+
// block syntax, silence, the open-item markers, completion stats and the
|
|
3
|
+
// emphasis repair. Web chat and every IM adapter render these, so the wording
|
|
4
|
+
// and units live here.
|
|
5
|
+
import { replaceOutsideCode } from "./inbound-file.js";
|
|
6
|
+
import { TASK_RUN_STATES } from "./types.js";
|
|
7
|
+
// The syntax is told in agent/roles.ts; this file parses it back.
|
|
56
8
|
/** Where a system input came from; wording every surface must spell the same. */
|
|
57
9
|
export function originLabel(origin) {
|
|
58
|
-
|
|
59
|
-
return "\u26a0 failed";
|
|
60
|
-
|
|
61
|
-
|
|
10
|
+
switch (origin.kind) {
|
|
11
|
+
case "error": return "\u26a0 failed";
|
|
12
|
+
case "task-delegation": return "\u25b6 delegated task";
|
|
13
|
+
case "task-callback": return "\u21a9 task callback";
|
|
14
|
+
case "session-seed": return `\u21ba new session \u00b7 ${origin.reason}`;
|
|
15
|
+
case "chat-command": return `/${origin.command}`;
|
|
16
|
+
case "task-message":
|
|
17
|
+
return `from a supervisor \u00b7 ${origin.messageKind === "steer" ? "\u270e steer" : "\uff0b follow-up"}`;
|
|
62
18
|
}
|
|
63
|
-
return `from a supervisor \u00b7 ${origin.messageKind === "steer" ? "\u270e steer" : "\uff0b follow-up"}`;
|
|
64
19
|
}
|
|
65
20
|
/** Is a turn coming once this note is posted? On IM the note is the only
|
|
66
|
-
* message that turn has to wear the 👀;
|
|
67
|
-
*
|
|
68
|
-
|
|
21
|
+
* message that turn has to wear the 👀; a receipt on any other would hang
|
|
22
|
+
* until the stale sweep: an error reports a turn that already ended, a chat
|
|
23
|
+
* command's answer starts none, and the message that made a seed wears its own. */
|
|
24
|
+
export const awaitsTurn = (origin) => origin.kind !== "error" && origin.kind !== "chat-command" && origin.kind !== "session-seed";
|
|
69
25
|
/** Punctuation that may be lifted out of a `**strong**` run: nothing a reader
|
|
70
26
|
* can see changes, and the delimiter comes off a character the parser refuses
|
|
71
27
|
* to close on. */
|
|
@@ -73,14 +29,9 @@ const LIFTABLE = /[\u201c\u201d"\u2018\u2019'()\uff08\uff09\u300c\u300d\u300e\u3
|
|
|
73
29
|
/** CommonMark's right-flanking rule does not count CJK punctuation, so
|
|
74
30
|
* `**“怎么做”**:` never closes (a spec hole, not a platform bug, and constant
|
|
75
31
|
* in model output). Moving the punctuation outside — `“**怎么做**”:` — lands
|
|
76
|
-
* the `**` against a letter. Fences and code spans are content,
|
|
32
|
+
* the `**` against a letter. Fences and code spans are content, never touched. */
|
|
77
33
|
export function cjkFriendly(markdown) {
|
|
78
|
-
|
|
79
|
-
const keep = (text) => `\uE010${stash.push(text) - 1}\uE011`;
|
|
80
|
-
let out = markdown
|
|
81
|
-
.replace(/```[\s\S]*?```/g, (m) => keep(m))
|
|
82
|
-
.replace(/`[^`\n]+`/g, (m) => keep(m));
|
|
83
|
-
out = out.replace(/\*\*(\S|\S[\s\S]*?\S)\*\*/g, (whole, inner) => {
|
|
34
|
+
return replaceOutsideCode(markdown, /\*\*(\S|\S[\s\S]*?\S)\*\*/g, ([whole, inner = ""]) => {
|
|
84
35
|
let lead = "";
|
|
85
36
|
let trail = "";
|
|
86
37
|
let body = inner;
|
|
@@ -94,7 +45,6 @@ export function cjkFriendly(markdown) {
|
|
|
94
45
|
}
|
|
95
46
|
return lead || trail ? `${lead}**${body}**${trail}` : whole;
|
|
96
47
|
});
|
|
97
|
-
return out.replace(/\uE010(\d+)\uE011/g, (_m, i) => stash[Number(i)] ?? "");
|
|
98
48
|
}
|
|
99
49
|
/** The reason arrives pre-escaped: each surface escapes for its own markup,
|
|
100
50
|
* and the label itself contains nothing any of them escape. */
|
|
@@ -105,6 +55,26 @@ export const isSilentReply = (reply) => !reply.text.trim() && reply.suggestions.
|
|
|
105
55
|
export const thinkingLabel = (level) => level === "xhigh" ? "Extra high" : level[0].toUpperCase() + level.slice(1);
|
|
106
56
|
/** `max` characters, the last one an ellipsis when something was cut. */
|
|
107
57
|
export const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
58
|
+
/** Compact age of `ts` at `now` ("now", "12m", "3h", "2d"): every surface ages things the same way. */
|
|
59
|
+
export function relTime(ts, now) {
|
|
60
|
+
const mins = Math.round((now - ts) / 60_000);
|
|
61
|
+
if (mins < 1)
|
|
62
|
+
return "now";
|
|
63
|
+
if (mins < 60)
|
|
64
|
+
return `${String(mins)}m`;
|
|
65
|
+
if (mins < 1440)
|
|
66
|
+
return `${String(Math.round(mins / 60))}h`;
|
|
67
|
+
return `${String(Math.round(mins / 1440))}d`;
|
|
68
|
+
}
|
|
69
|
+
/** The age as it reads beside a wall clock, where "now" would be a fragment. */
|
|
70
|
+
export const agoLabel = (ts, now) => {
|
|
71
|
+
const age = relTime(ts, now);
|
|
72
|
+
return age === "now" ? "just now" : `${age} ago`;
|
|
73
|
+
};
|
|
74
|
+
/** A run's state and its age: how long it has been going, or how long ago it ended. */
|
|
75
|
+
export const runStatus = (r, now) => r.finishedAt === null ? `${r.state} ${relTime(r.queuedAt, now)}` : `${r.state} ${agoLabel(r.finishedAt, now)}`;
|
|
76
|
+
/** A lead's launches by state, in the states' own order: "2 running, 1 failed". */
|
|
77
|
+
export const workerCounts = (workers) => TASK_RUN_STATES.filter((s) => workers[s] > 0).map((s) => `${String(workers[s])} ${s}`).join(", ") || "none";
|
|
108
78
|
/** 1200 → "1.2K", 12_000 → "12K" — absolute token counts read badly inline. */
|
|
109
79
|
export const compact = (n) => {
|
|
110
80
|
if (n < 1000)
|
|
@@ -134,7 +104,47 @@ const MAX_SUGGESTIONS = 5;
|
|
|
134
104
|
const ZW = String.raw `[\u200B-\u200D\u2060\uFEFF]*`;
|
|
135
105
|
const tag = (literal) => literal.split("").join(ZW);
|
|
136
106
|
const SILENT = new RegExp(`${tag("<silent>")}([\\s\\S]*?)${tag("</silent>")}`, "gi");
|
|
137
|
-
|
|
107
|
+
/** A tag that hides what it wraps: a streaming renderer never cuts inside one. */
|
|
108
|
+
const HIDDEN_TAG = new RegExp(`<(\\/?)(${["silent", "open", "done"].map(tag).join("|")})${ZW}>`, "gi");
|
|
109
|
+
const ZW_CHARS = new RegExp(ZW.slice(0, -1), "g");
|
|
110
|
+
/** Main's open-item markers (docs/design/10-continuous-session.md): stripped like
|
|
111
|
+
* `<silent>`, and read back by core/chain.ts on the head's turn end. Code is
|
|
112
|
+
* content, so a marker inside a fence or a code span is neither. Group 1 is an
|
|
113
|
+
* `<open>` body, group 2 a `<done>` body. */
|
|
114
|
+
const MARKER = new RegExp(`${tag("<open>")}([\\s\\S]*?)${tag("</open>")}[ \\t]*\\n?|${tag("<done>")}([\\s\\S]*?)${tag("</done>")}[ \\t]*\\n?`, "gi");
|
|
115
|
+
const RUN_TOKEN = /\s*\(run\s+([^\s()]+)\)\s*$/i;
|
|
116
|
+
const oneLine = (text) => text.replace(/\s+/g, " ").trim();
|
|
117
|
+
/** The markers in reply order; `dropped` holds the ones with no problem text,
|
|
118
|
+
* for the caller to log. */
|
|
119
|
+
export function openItemMarkers(markdown) {
|
|
120
|
+
const markers = [];
|
|
121
|
+
const dropped = [];
|
|
122
|
+
replaceOutsideCode(markdown, MARKER, (m) => {
|
|
123
|
+
if (m[1] === undefined) {
|
|
124
|
+
const problem = oneLine(m[2] ?? "");
|
|
125
|
+
if (problem)
|
|
126
|
+
markers.push({ op: "done", problem });
|
|
127
|
+
else
|
|
128
|
+
dropped.push(m[0].trim());
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
let rest = oneLine(m[1]);
|
|
132
|
+
const runIds = [];
|
|
133
|
+
for (let run = RUN_TOKEN.exec(rest); run; run = RUN_TOKEN.exec(rest)) {
|
|
134
|
+
runIds.unshift(run[1]);
|
|
135
|
+
rest = rest.slice(0, run.index);
|
|
136
|
+
}
|
|
137
|
+
const dash = rest.indexOf("\u2014");
|
|
138
|
+
const problem = (dash < 0 ? rest : rest.slice(0, dash)).trim();
|
|
139
|
+
const stage = dash < 0 ? "" : rest.slice(dash + 1).trim();
|
|
140
|
+
if (problem)
|
|
141
|
+
markers.push({ op: "open", problem, stage, runIds });
|
|
142
|
+
else
|
|
143
|
+
dropped.push(m[0].trim());
|
|
144
|
+
return "";
|
|
145
|
+
});
|
|
146
|
+
return { markers, dropped };
|
|
147
|
+
}
|
|
138
148
|
/** Why the agent stayed quiet; hidden from the chat, shown on the workbench,
|
|
139
149
|
* where a silent turn must not look like a broken one. */
|
|
140
150
|
export function silentReason(markdown) {
|
|
@@ -160,21 +170,22 @@ export function splitReply(rawMarkdown, meta) {
|
|
|
160
170
|
}
|
|
161
171
|
/** For rendering mid-turn: everything `splitReply` repairs, minus the
|
|
162
172
|
* next-step block, which is only one at the very end of a turn. */
|
|
163
|
-
export const streamBody = (markdown) => cjkFriendly(markdown.replace(SILENT, "").trim());
|
|
173
|
+
export const streamBody = (markdown) => cjkFriendly(replaceOutsideCode(markdown.replace(SILENT, ""), MARKER, () => "").trim());
|
|
164
174
|
/** Lines a blank line does not necessarily separate (a loose list is still one
|
|
165
175
|
* list). Over-matching is fine: one boundary too few costs only a repaint. */
|
|
166
176
|
const CONTINUES = /^(?:\s|[-*+>]|\d+[.)])/;
|
|
167
177
|
/** Offset past the last blank line after `from` that closes a block, so a
|
|
168
178
|
* streaming renderer can parse each closed prefix once (whole-reply reparse is
|
|
169
179
|
* O(N²) over a turn). A boundary is claimed only where the two sides render the
|
|
170
|
-
* same apart as together: never inside a fence, a `<silent>` block, or a loose
|
|
180
|
+
* same apart as together: never inside a fence, a `<silent>`/`<open>`/`<done>` block, or a loose
|
|
171
181
|
* list/quote. A ```` fence is not closed by the ``` it quotes. */
|
|
172
182
|
export function stableBlockEnd(markdown, from = 0) {
|
|
173
183
|
// The last line is still growing, so it decides nothing.
|
|
174
184
|
const end = markdown.lastIndexOf("\n") + 1;
|
|
175
185
|
/** Marker run of the fence currently open; empty = closed. */
|
|
176
186
|
let open = "";
|
|
177
|
-
|
|
187
|
+
/** The hiding tag currently open; empty = none. */
|
|
188
|
+
let hidden = "";
|
|
178
189
|
let cut = from;
|
|
179
190
|
/** Start of the block after a blank line, waiting for its first line. */
|
|
180
191
|
let pending = -1;
|
|
@@ -184,21 +195,26 @@ export function stableBlockEnd(markdown, from = 0) {
|
|
|
184
195
|
const line = markdown.slice(at, nl);
|
|
185
196
|
at = nl + 1;
|
|
186
197
|
const fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
|
|
187
|
-
if (!open && !
|
|
198
|
+
if (!open && !hidden && !line.trim()) {
|
|
188
199
|
if (pending < 0 && prev)
|
|
189
200
|
pending = at;
|
|
190
201
|
continue;
|
|
191
202
|
}
|
|
192
|
-
if (!open && !
|
|
203
|
+
if (!open && !hidden && pending >= 0 && !(CONTINUES.test(prev) && CONTINUES.test(line)))
|
|
193
204
|
cut = pending;
|
|
194
205
|
const run = fence?.[1] ?? "";
|
|
195
|
-
if (!open && run && !
|
|
206
|
+
if (!open && run && !hidden)
|
|
196
207
|
open = run;
|
|
197
208
|
else if (open && run[0] === open[0] && run.length >= open.length && !fence?.[2]?.trim())
|
|
198
209
|
open = "";
|
|
199
|
-
if (!open && (!run ||
|
|
200
|
-
for (const t of line.matchAll(
|
|
201
|
-
|
|
210
|
+
if (!open && (!run || hidden)) {
|
|
211
|
+
for (const t of line.matchAll(HIDDEN_TAG)) {
|
|
212
|
+
const name = t[2].replace(ZW_CHARS, "").toLowerCase();
|
|
213
|
+
if (!t[1] && !hidden)
|
|
214
|
+
hidden = name;
|
|
215
|
+
else if (t[1] && name === hidden)
|
|
216
|
+
hidden = "";
|
|
217
|
+
}
|
|
202
218
|
}
|
|
203
219
|
pending = -1;
|
|
204
220
|
prev = line;
|