@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.
Files changed (126) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/packages.js +6 -10
  5. package/dist/agent/pi.js +111 -68
  6. package/dist/agent/roles.js +103 -0
  7. package/dist/agent/types.js +78 -0
  8. package/dist/boards/boards.js +42 -32
  9. package/dist/channels/chains.js +0 -4
  10. package/dist/channels/commands.js +7 -12
  11. package/dist/channels/config.js +27 -1
  12. package/dist/channels/control.js +5 -16
  13. package/dist/channels/conversations.js +0 -5
  14. package/dist/channels/lark-api.js +20 -26
  15. package/dist/channels/lark-outbound.js +16 -29
  16. package/dist/channels/lark-panel.js +1 -1
  17. package/dist/channels/lark.js +61 -39
  18. package/dist/channels/lines.js +33 -1
  19. package/dist/channels/panel.js +17 -107
  20. package/dist/channels/routes.js +11 -21
  21. package/dist/channels/runtime.js +34 -25
  22. package/dist/channels/slack-outbound.js +6 -15
  23. package/dist/channels/slack-panel.js +1 -1
  24. package/dist/channels/slack-render.js +1 -1
  25. package/dist/channels/slack.js +63 -57
  26. package/dist/channels/types.js +1 -1
  27. package/dist/config-sync.js +42 -37
  28. package/dist/core/chain.js +202 -0
  29. package/dist/core/identity.js +34 -11
  30. package/dist/core/inbound-file.js +4 -3
  31. package/dist/core/reply.js +96 -80
  32. package/dist/core/router.js +111 -118
  33. package/dist/core/types.js +36 -77
  34. package/dist/db.js +33 -2
  35. package/dist/main.js +46 -37
  36. package/dist/settings.js +25 -18
  37. package/dist/tasks/agent.js +11 -5
  38. package/dist/tasks/callbacks.js +55 -5
  39. package/dist/tasks/cli.js +22 -11
  40. package/dist/tasks/definitions.js +30 -17
  41. package/dist/tasks/execution.js +2 -2
  42. package/dist/tasks/groups.js +5 -2
  43. package/dist/tasks/messages.js +7 -7
  44. package/dist/tasks/open-items.js +70 -0
  45. package/dist/tasks/operations.js +76 -39
  46. package/dist/tasks/outbox.js +19 -6
  47. package/dist/tasks/routes.js +19 -249
  48. package/dist/tasks/service.js +145 -34
  49. package/dist/tasks/store.js +119 -56
  50. package/dist/tasks/types.js +5 -0
  51. package/dist/tools.js +2 -2
  52. package/dist/web/auth.js +2 -2
  53. package/dist/web/config-sync.js +4 -5
  54. package/dist/web/config.js +9 -2
  55. package/dist/web/explorer.js +2 -2
  56. package/dist/web/fs.js +16 -1
  57. package/dist/web/instance.js +8 -5
  58. package/dist/web/packages.js +1 -1
  59. package/dist/web/passkeys.js +3 -3
  60. package/dist/web/providers.js +3 -3
  61. package/dist/web/public/assets/code-BZwvPani.js +2 -0
  62. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  63. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  64. package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
  65. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  67. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
  68. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  69. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  70. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  71. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  72. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  73. package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
  74. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  75. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  76. package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
  77. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  78. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  79. package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
  80. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  81. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  82. package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
  83. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  84. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
  85. package/dist/web/public/index.html +40 -109
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/public/manifest.webmanifest +0 -2
  89. package/dist/web/push.js +1 -1
  90. package/dist/web/server.js +91 -116
  91. package/dist/web/session-state.js +5 -40
  92. package/docs/deploy.md +5 -3
  93. package/package.json +2 -1
  94. package/skills/pier-boards/SKILL.md +14 -4
  95. package/skills/pier-help/SKILL.md +57 -23
  96. package/skills/pier-tasks/SKILL.md +58 -18
  97. package/dist/channels/handoff.js +0 -94
  98. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  99. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  100. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  101. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  102. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  103. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  104. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  105. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  106. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  107. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  108. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  109. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  110. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  111. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  112. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  113. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  114. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  115. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  116. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  117. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  118. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  119. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  120. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  121. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  122. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  123. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  124. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  125. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  126. 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
+ }
@@ -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
- next(sessionId, sender, at = Date.now(), conversation, opaqueIds = false) {
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
- this.seen.set(sessionId, { senderId: sender.id, at, conversation });
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
- if (!newSpeaker && !gap && !newDay && !newPlace)
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 WITH_ID = new RegExp(String.raw `^\[(?:(?<name>[^\n[\]<>]*)<(?<id>[^\n[\]<>]+)>)? ?${TIME}? ?(?<where>[a-z]+:[^\s[\]<>]+)?\]\n`);
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, a handoff): its
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
- const end = start + match[0].length;
78
- if (skip.some(([from, to]) => start < to && end > from))
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 = end;
82
+ last = start + match[0].length;
82
83
  }
83
84
  return out + text.slice(last);
84
85
  }
@@ -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 emphasis repair. Web chat and
3
- // every IM adapter render these, so the wording and units live here.
4
- /** The surface contract handed to every agent Pier launches (main.ts); the
5
- * syntax the agent is told to emit sits beside the parser that reads it back. */
6
- const REPLY_SURFACE_PROMPT = `## Pier chat surface
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
- if (origin.kind === "error")
59
- return "\u26a0 failed";
60
- if (origin.kind !== "task-message") {
61
- return origin.kind === "task-delegation" ? "\u25b6 delegated task" : "\u21a9 task callback";
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 👀; an error note reports a turn that
67
- * already ended, and a receipt on it would hang until the stale sweep. */
68
- export const awaitsTurn = (origin) => origin.kind !== "error";
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, protected first. */
32
+ * the `**` against a letter. Fences and code spans are content, never touched. */
77
33
  export function cjkFriendly(markdown) {
78
- const stash = [];
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
- const SILENT_TAG = new RegExp(`<(\\/?)${tag("silent>")}`, "gi");
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
- let silent = false;
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 && !silent && !line.trim()) {
198
+ if (!open && !hidden && !line.trim()) {
188
199
  if (pending < 0 && prev)
189
200
  pending = at;
190
201
  continue;
191
202
  }
192
- if (!open && !silent && pending >= 0 && !(CONTINUES.test(prev) && CONTINUES.test(line)))
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 && !silent)
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 || silent)) {
200
- for (const t of line.matchAll(SILENT_TAG))
201
- silent = !t[1];
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;