@timqi/pier 0.1.0 → 0.1.2

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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
  91. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  92. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
  94. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  95. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
  97. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  98. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  99. package/dist/web/public/assets/index-DbFu15NN.js +85 -0
  100. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  101. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
  106. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
  109. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  110. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
  112. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
  115. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/agent/pi.js CHANGED
@@ -5,26 +5,16 @@ import { realpathSync } from "node:fs";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
7
7
  import { inlineExtensions } from "../extensions/index.js";
8
- import { SESSION_TITLE_MAX } from "../limits.js";
8
+ import { SESSION_TITLE_MAX } from "../core/types.js";
9
9
  import { logger } from "../log.js";
10
10
  import { textOf, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
11
11
  import { defaultAgentDir, PiConfigStore } from "./config.js";
12
12
  import { IndexedListing } from "./listing.js";
13
13
  import { curateModels, pinFirst } from "./models.js";
14
14
  const log = logger("agent");
15
- /** A directory is what a session is compared by — the rail groups on it, the
16
- * New-session menu drops a `<repo>.<branch>` worktree when the repository
17
- * itself is in the list — so two names for one directory are two projects. A
18
- * session opened under `/home/qiqi` (a symlink to `/essd/qiqi`) never matched
19
- * the worktrees recorded under the real path, and every branch checkout was
20
- * offered as a project of its own. Resolved once per distinct path: a symlink
21
- * that moves under a running instance is not a case we have.
22
- *
23
- * A directory that is gone — a worktree merged and removed — is still a
24
- * session's cwd, and its spelling still decides whether the rail reads it as a
25
- * branch of its repository. So the deepest ancestor that does resolve carries
26
- * the rest of the path, and only a cwd with no resolvable ancestor at all is
27
- * reported as recorded. */
15
+ /** Sessions are grouped by directory, so two names for one directory (a
16
+ * symlinked home) are two projects. A directory that is gone is still a cwd:
17
+ * the deepest resolvable ancestor carries the rest of the path. */
28
18
  const realPaths = new Map();
29
19
  function realPath(cwd) {
30
20
  const known = realPaths.get(cwd);
@@ -50,9 +40,6 @@ function realPath(cwd) {
50
40
  realPaths.set(cwd, real);
51
41
  return real;
52
42
  }
53
- /** A listed record as the seam reports it. The one mapping, because `list` and
54
- * `find` answer with the same shape and drifting would mean two answers about
55
- * one session. */
56
43
  const summaryOf = (s) => ({
57
44
  id: s.id,
58
45
  cwd: realPath(s.cwd),
@@ -60,49 +47,36 @@ const summaryOf = (s) => ({
60
47
  modified: s.modified,
61
48
  ...(s.title ? { title: s.title } : {}),
62
49
  });
63
- /** Pi's bash tool has no default timeout, so a hung command holds the turn
64
- * until someone aborts it — nobody is watching in a scheduled task. Kept below
65
- * the default task-run timeout so a stuck command comes back as
66
- * a tool error the agent can retry with an explicit longer timeout, instead of
67
- * killing the whole run. */
50
+ /** Pi's bash tool has no default timeout, and nobody is watching a scheduled
51
+ * task. Below the default task-run timeout, so a stuck command comes back as a
52
+ * tool error instead of killing the run. */
68
53
  const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
69
- /** How long a session listing stays usable: long enough that one workspace
70
- * event, which several surfaces answer at once, scans disk once; short enough
71
- * that a title no invalidation covers is never stale on screen. */
54
+ /** Long enough that one workspace event, answered by several surfaces, scans
55
+ * disk once; short enough that a title no invalidation covers is never stale. */
72
56
  const LIST_TTL_MS = 3_000;
73
- /** A probe nobody is watching is a hung page: the Console waits on this. */
74
57
  const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
75
- /** An ordinary budget, not a token: a 1-token cap is a request no real turn
76
- * ever makes, and answers about it are answers about a different request. */
58
+ /** An ordinary budget: a 1-token cap is a request no real turn ever makes. */
77
59
  const PROVIDER_CHECK_MAX_TOKENS = 8192;
78
- /** Neither half of a probe is worth more than a screen. */
79
60
  const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
80
- /** Naming a session: one bare request on the operator's title model, after the
81
- * first exchange. Each half of the exchange is cut to a few hundred
82
- * characters — a title is about the subject, and the subject is in the
83
- * opening lines — and the answer is a line, so the cap is a line's worth. */
61
+ /** The subject of an exchange is in its opening lines; the answer is one line. */
84
62
  const TITLE_INPUT_CHARS = 600;
85
63
  const TITLE_MAX_TOKENS = 40;
86
64
  const TITLE_TIMEOUT_MS = 20_000;
87
65
  const TITLE_PROMPT = "Name this conversation for a session list. Reply with the title only: at most 12 Chinese characters " +
88
66
  "or 6 English words, in the language the user wrote in, no quotes, no trailing period. " +
89
67
  "A leading `[name<id> time]` on the user's message is a speaker header, not content.";
90
- /** A subject, not the request: the model is asked to title the exchange, and
91
- * the two halves are labelled so a reply that quotes an instruction back is
92
- * not mistaken for one. */
68
+ /** The halves are labelled so a reply quoting an instruction back is not
69
+ * mistaken for one. */
93
70
  const titleRequest = (first, reply) => `${TITLE_PROMPT}\n\n<user>\n${first.slice(0, TITLE_INPUT_CHARS)}\n</user>\n\n<assistant>\n${reply.slice(0, TITLE_INPUT_CHARS)}\n</assistant>`;
94
- /** The text of a bare completion, or the refusal it was: a provider can
95
- * decline as a message rather than a throw, and only the stop reason tells
96
- * that from an answer. */
71
+ /** A provider can decline as a message rather than a throw; only the stop
72
+ * reason tells that from an answer. */
97
73
  function textOfAnswer(answer) {
98
74
  if (answer.stopReason === "error" || answer.stopReason === "aborted") {
99
75
  throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
100
76
  }
101
77
  return textOf(answer.content).trim();
102
78
  }
103
- /** What a model hands back as a title, made into one: the first line, quotes
104
- * off, capped where every other title is capped. Empty is a failure, not a
105
- * cleared name — the caller reports it. */
79
+ /** Empty is a failure, not a cleared name — the caller reports it. */
106
80
  export function titleFromAnswer(text) {
107
81
  const line = text.trim().split("\n")[0]?.trim() ?? "";
108
82
  return line.replace(/^["'“‘「]+|["'”’」.。]+$/g, "").trim().slice(0, SESSION_TITLE_MAX);
@@ -130,8 +104,7 @@ These rules govern conversational replies. A human reads them on a phone-sized s
130
104
  - Destructive or irreversible actions on things you didn't create — deleting user files, force push, migrations, deploys, service restarts: ask first; unattended, don't do them and report what you would have done.
131
105
  - Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.`;
132
106
  export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
133
- /** Patching the call is cheaper than replacing the tool: the built-in keeps its
134
- * shell settings, and the agent spends no tokens deciding a timeout. */
107
+ /** Patching the call keeps the built-in's shell settings. */
135
108
  const bashTimeoutDefault = (pi) => {
136
109
  pi.on("tool_call", (event) => {
137
110
  if (event.toolName === "bash" && event.input.timeout === undefined) {
@@ -139,12 +112,8 @@ const bashTimeoutDefault = (pi) => {
139
112
  }
140
113
  });
141
114
  };
142
- /**
143
- * A bundled extension stands down when a copy on disk already registers one of
144
- * its tools. Pi loads both and reports the clash as a diagnostic nobody reads,
145
- * leaving two tools of the same name and no way to tell which one answered;
146
- * the copy the user put there wins, and the journal says so (§5b).
147
- */
115
+ /** Pi loads a bundled extension and its on-disk twin both, leaving two tools
116
+ * of one name; the copy the user put there wins, and the journal says so (§5). */
148
117
  export const standDownShadowed = (base) => {
149
118
  const inline = (ext) => ext.path.startsWith("<inline:");
150
119
  const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
@@ -161,11 +130,8 @@ export const standDownShadowed = (base) => {
161
130
  }),
162
131
  };
163
132
  };
164
- /**
165
- * A bundled skill stands down with the tool it documents. A skill's
166
- * description is resident in every prompt, so one pointing at a tool this
167
- * session was not given is both a cost and a route the agent cannot take.
168
- */
133
+ /** A skill's description is resident in every prompt; one pointing at a tool
134
+ * this session was not given is a route the agent cannot take. */
169
135
  export const standDownUndocumented = (tools, skills) => {
170
136
  const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
171
137
  if (!gone.size)
@@ -179,17 +145,12 @@ export class PiSession {
179
145
  retention;
180
146
  suggestTitle;
181
147
  constructor(pi,
182
- /** Operator pins, read per call — the menu can change while we run. */
148
+ /** Read per call — the menu can change while we run. */
183
149
  pinned = () => [],
184
- /** "What I just wrote is not in your listing yet." The factory retains a
185
- * scan for a few seconds, which is exactly the window a rename lands in:
186
- * every surface would re-read the old title and keep it until some
187
- * unrelated event moved the list again. Same drop `create` does — a
188
- * callback only because the session is what knows it happened. */
150
+ /** Drops the factory's retained listing: a rename lands in exactly the
151
+ * window it covers, and every surface would keep the old title. */
189
152
  wrote = () => { }, retention = { value: "long" },
190
- /** The operator's title model asked to name a first exchange, or nothing
191
- * when auto-titling is off. Read per turn: switching it on names the next
192
- * session that finishes a first turn, without a restart. */
153
+ /** Read per turn: switching auto-titling on takes effect without a restart. */
193
154
  suggestTitle = () => undefined) {
194
155
  this.pi = pi;
195
156
  this.pinned = pinned;
@@ -197,11 +158,8 @@ export class PiSession {
197
158
  this.retention = retention;
198
159
  this.suggestTitle = suggestTitle;
199
160
  }
200
- /** Pi's dispose unhooks the one listener that persists and emits, so a turn
201
- * started after it runs for real — model call, tools and all — and lands
202
- * nowhere: no transcript entry, no event, and a promise that resolves as if
203
- * it worked. Refusing is what turns that silence into the failure it is;
204
- * callers already report or retry a rejection (§5b). */
161
+ /** A turn started after Pi's dispose runs for real and lands nowhere — no
162
+ * transcript, no event, a promise that resolves. Refusing makes it a failure (§5). */
205
163
  disposed = false;
206
164
  live() {
207
165
  if (this.disposed)
@@ -227,7 +185,6 @@ export class PiSession {
227
185
  async setModel(ref) {
228
186
  const m = this.pi.modelRuntime.getModel(ref.provider, ref.id);
229
187
  if (!m) {
230
- // Lazy discovery: the failure itself documents what is selectable.
231
188
  const available = (await this.availableModels())
232
189
  .slice(0, 8).map((entry) => `${entry.provider}/${entry.id}`).join(", ");
233
190
  throw new Error(`unknown model: ${ref.provider}/${ref.id}; available: ${available}`);
@@ -237,8 +194,7 @@ export class PiSession {
237
194
  async availableModels() {
238
195
  const available = await this.pi.modelRuntime.getAvailable();
239
196
  const curated = pinFirst(curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning }))), this.pinned());
240
- // The session's active model must stay selectable even when curation
241
- // (or an older catalog) would hide it.
197
+ // The active model must stay selectable even when curation would hide it.
242
198
  const current = this.model;
243
199
  if (current && !curated.some((m) => m.provider === current.provider && m.id === current.id)) {
244
200
  curated.unshift(current);
@@ -261,22 +217,20 @@ export class PiSession {
261
217
  };
262
218
  }
263
219
  /** A system input handed to a streaming session goes into Pi's *agent*
264
- * queue, and the arrays above are the *session's* — they only ever hold
265
- * text a `prompt`/`steer`/`followUp` call queued. So a task message sitting
266
- * in front of a running turn is in neither the queue nor the transcript,
267
- * and this list is the only thing that says it exists. */
220
+ * queue, which `pendingQueue` cannot see; this list is the only thing that
221
+ * says it exists. */
268
222
  queuedInputs = [];
269
223
  async pendingSystemInputs() {
270
- // Pi drains its follow-up queue before the turn ends, so an idle session
271
- // has nothing in flight. Anything still listed was aborted or cleared, and
272
- // calling that queued would leave its sender waiting on it forever (§5b).
224
+ // Pi drains its queue before the turn ends; anything still listed on an
225
+ // idle session was aborted, and calling it queued would leave its sender
226
+ // waiting forever (§5).
273
227
  if (!this.pi.isStreaming)
274
228
  this.queuedInputs.length = 0;
275
229
  return [...this.queuedInputs];
276
230
  }
277
231
  async clearQueue() {
278
- // Not among the texts returned — a system input is nobody's draft — but
279
- // dropped all the same, so its sender stops being told it is on its way.
232
+ // Not returned (a system input is nobody's draft), but dropped, so its
233
+ // sender stops being told it is on its way.
280
234
  this.queuedInputs.length = 0;
281
235
  return this.pi.clearQueue();
282
236
  }
@@ -286,8 +240,8 @@ export class PiSession {
286
240
  }
287
241
  async rewindToUserTurn(index) {
288
242
  const total = (await this.history()).filter((t) => t.role === "user").length;
289
- // Anchor at the tail: branch entries keep compacted-away history that
290
- // history() no longer shows, so only end-relative indices line up.
243
+ // Branch entries keep compacted-away history that history() no longer
244
+ // shows, so only end-relative indices line up.
291
245
  const back = total - index;
292
246
  const users = this.pi.sessionManager
293
247
  .getBranch()
@@ -295,27 +249,20 @@ export class PiSession {
295
249
  const target = back >= 1 ? users[users.length - back] : undefined;
296
250
  if (!target)
297
251
  throw new Error(`no user turn at index ${index}`);
298
- // navigateTree on a user message moves the leaf to its parent — the old
299
- // branch stays in the file but leaves the context.
252
+ // The old branch stays in the file but leaves the context.
300
253
  const { cancelled } = await this.pi.navigateTree(target.id);
301
254
  if (cancelled)
302
255
  throw new Error("rewind cancelled");
303
256
  }
304
- /** The compaction running right now, or null. Pi keeps no lock of its own —
305
- * a second `compact()` aborts the first's turn and summarizes a transcript
306
- * that is being replaced under it — and two POSTs a millisecond apart both
307
- * pass the route's idle check, so the gate has to be here. */
257
+ /** Pi keeps no lock of its own: a second `compact()` summarizes a transcript
258
+ * being replaced under it, and two POSTs a millisecond apart both pass the
259
+ * route's idle check. */
308
260
  compacting = null;
309
- /** Pi's own compaction, minus its `CompactionResult`: the numbers reach
310
- * surfaces as the `context-compacted` event the seam already emits for the
311
- * automatic one, so a caller has nothing to do with them. Refused while one
312
- * is running, rather than run twice over one context. */
313
261
  async compact() {
314
262
  this.live();
315
263
  if (this.compacting)
316
264
  throw new Error(`session ${this.pi.sessionId} is already compacting`);
317
- // Started and recorded in the same tick, with no await between: that is
318
- // what makes the check above a gate and not a hint.
265
+ // Recorded in the same tick, no await between: that is what makes the check a gate.
319
266
  const running = this.pi.compact().then(() => undefined);
320
267
  this.compacting = running;
321
268
  try {
@@ -325,48 +272,30 @@ export class PiSession {
325
272
  this.compacting = null;
326
273
  }
327
274
  }
328
- /** One `session_info` entry, which Pi's own reader takes the latest of — so
329
- * a rename is an append like everything else in a transcript, and nothing
330
- * has to be rewritten. Never refused for being busy: a name has nothing to
331
- * do with the turn running.
332
- *
333
- * Returns nothing, because the transcript is the answer: what the session is
334
- * called after this — the name, or the title a cleared one falls back to —
335
- * is what the next listing reads off the file, and deriving it here as well
336
- * was a second copy of a rule agent/listing.ts already owns.
337
- *
338
- * TODO: renaming a cold session costs a whole resume, because the route
339
- * reaches it through `ensure` and this method needs a live Pi session to
340
- * append through. The work is one line in a file. Revisit when Pi offers a
341
- * lightweight append to a session it has not loaded. */
275
+ /** An append: Pi's reader takes the latest `session_info`. Never refused for
276
+ * being busy. TODO: renaming a cold session costs a whole resume for one
277
+ * appended line; revisit when Pi offers a lightweight append. */
342
278
  async rename(name) {
343
279
  this.live();
344
280
  this.pi.sessionManager.appendSessionInfo(name);
345
281
  this.wrote();
346
282
  }
347
- /** Compaction replaces the context a turn would run against, so a dispatch
348
- * that lands mid-compaction waits for the summary instead of starting a turn
349
- * over it — the follow-up promise ("delivered when idle") without Pi's
350
- * follow-up queue, which is only drained by the *next* turn: a message
351
- * parked there while nothing is running would sit unsent, and Pi's own
352
- * `prompt()` guard would have thrown the user's message away (§5b). */
283
+ /** A dispatch landing mid-compaction waits for the summary instead of
284
+ * starting a turn over it. Not Pi's follow-up queue: that is drained only by
285
+ * the *next* turn, so a message parked there while idle would sit unsent. */
353
286
  async whenCompacted() {
354
287
  while (this.compacting)
355
288
  await this.compacting.catch(() => undefined);
356
289
  }
357
- // Async, so a refusal is a rejected promise: the seam promises callers they
358
- // may only `.catch()` (core/types.ts), and dispatch does exactly that.
290
+ // Async, so a refusal is a rejected promise the seam lets callers `.catch()`.
359
291
  async prompt(text) {
360
292
  this.live();
361
293
  await this.whenCompacted();
362
- // Re-checked: the wait above is long enough for a dispose to land.
294
+ // The wait above is long enough for a dispose to land.
363
295
  this.live();
364
- // A turn may have started since the caller read the state this prompt was
365
- // decided against — two messages arriving together, or several released at
366
- // once by the wait above. Bare, Pi throws that back as "already
367
- // processing" and the message is gone (§5b); queued, it is the same
368
- // "delivered when idle" the core's own policy picks for an auto message
369
- // that lands mid-turn (core/queue.ts).
296
+ // A turn may have started since the caller read the state. Bare, Pi throws
297
+ // "already processing" and the message is gone (§5); queued, it is the
298
+ // same "delivered when idle" core/queue.ts picks for a mid-turn message.
370
299
  return this.pi.prompt(text, { streamingBehavior: "followUp" });
371
300
  }
372
301
  async steer(text) {
@@ -379,13 +308,10 @@ export class PiSession {
379
308
  }
380
309
  async systemInput(text, origin, mode) {
381
310
  this.live();
382
- // Same gate as prompt(): an idle session takes a system input as a turn
383
- // whatever the mode says, so a callback landing mid-compaction would race
384
- // the summary too.
311
+ // Same gate as prompt(): an idle session takes a system input as a turn.
385
312
  await this.whenCompacted();
386
313
  this.live();
387
- // Read here, not before the wait, and whatever the mode says: a turn is
388
- // running, so the call below queues this input rather than starting one.
314
+ // Read after the wait: a running turn means the call below queues.
389
315
  const queued = this.pi.isStreaming;
390
316
  if (queued)
391
317
  this.queuedInputs.push(origin);
@@ -393,8 +319,7 @@ export class PiSession {
393
319
  return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
394
320
  }
395
321
  catch (error) {
396
- // Refused: nothing is in flight, and the caller is about to retry. The
397
- // turn may have ended while we waited, so the entry may be gone already.
322
+ // Refused: nothing is in flight. The entry may be gone already.
398
323
  const at = this.queuedInputs.indexOf(origin);
399
324
  if (at >= 0)
400
325
  this.queuedInputs.splice(at, 1);
@@ -411,9 +336,7 @@ export class PiSession {
411
336
  if (piEvent.type === "agent_end")
412
337
  retryPending = piEvent.willRetry === true;
413
338
  if (piEvent.type === "agent_settled" && retryPending) {
414
- // Aborting Pi during retry backoff produces no final agent_end, so the
415
- // turn has to be ended here. The idle is not: `agent_settled` carries
416
- // it through the table below like any other settle.
339
+ // Aborting Pi during retry backoff produces no final agent_end.
417
340
  retryPending = false;
418
341
  fn({ type: "turn-end", text: "", meta: this.lastTurnMeta() });
419
342
  }
@@ -424,23 +347,16 @@ export class PiSession {
424
347
  }
425
348
  });
426
349
  }
427
- /** Whether naming has been decided for this session — done, started, or
428
- * never: a session that already has a name (a task's, a rename during the
429
- * turn) or more than one exchange behind it keeps what it has. Several
430
- * subscribers see every turn-end; this is what makes the request one. */
350
+ /** Several subscribers see every turn-end; this is what makes the request one. */
431
351
  titleDecided = false;
432
- /** Name the session after its first exchange, on the operator's title model.
433
- * Off by default (settings.titleModel), in which case the title stays the
434
- * first prompt that agent/listing.ts derives. The result is a rename like
435
- * any other — an append the next listing reads — announced as `renamed` so
436
- * every surface re-lists; a failure is announced too, because a title that
437
- * silently stayed the prompt looks like the setting did nothing (§5b). */
352
+ /** A failure is announced too: a title that silently stayed the prompt looks
353
+ * like the setting did nothing (§5). */
438
354
  autoTitle(reply, fn) {
439
355
  if (this.titleDecided)
440
356
  return;
441
357
  const suggest = this.suggestTitle();
442
358
  if (!suggest)
443
- return; // off — not decided: switched on later, the next turn still counts
359
+ return; // off is not decided: switched on later, the next turn still counts
444
360
  this.titleDecided = true;
445
361
  if (this.pi.sessionManager.getSessionName())
446
362
  return;
@@ -449,7 +365,7 @@ export class PiSession {
449
365
  if (!first.trim())
450
366
  return;
451
367
  void suggest(first, reply).then((title) => {
452
- // Named while we waited — by a person, whose word beats the model's.
368
+ // Named while we waited, by a person: their word beats the model's.
453
369
  if (this.disposed || this.pi.sessionManager.getSessionName())
454
370
  return;
455
371
  this.pi.sessionManager.appendSessionInfo(title);
@@ -460,7 +376,6 @@ export class PiSession {
460
376
  fn({ type: "error", message: `session title: ${err instanceof Error ? err.message : String(err)} — the first message stays the title` });
461
377
  });
462
378
  }
463
- /** Meta of the just-finished turn; live path, so "now" is the completion. */
464
379
  lastTurnMeta() {
465
380
  const messages = this.pi.messages;
466
381
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -485,31 +400,16 @@ export class PiAgentFactory {
485
400
  titleModel;
486
401
  listings;
487
402
  constructor(extraTools = [],
488
- /** Appended as a virtual context file, so Pi's own prompt stays intact.
489
- * Read per session, not captured once: it carries settings a user can
490
- * change while the process runs, and the next session must say the new
491
- * ones. */
403
+ /** Getters are read per session open, so a Console change reaches the next
404
+ * session without a restart. Appended as a context file so the user's own
405
+ * instructions still win. */
492
406
  instructions = () => "",
493
- /** Skills documenting Pier's own tools: loaded per session, never installed
494
- * into the user's global or project skill directories. */
407
+ /** Loaded per session, never installed into the user's skill directories. */
495
408
  skillPaths = [],
496
- /** Provider credentials from pier.db instead of <agentDir>/auth.json.
497
- * Optional only for bare test factories; main.ts always passes one, and
498
- * every runtime built here reads and writes through it — an OAuth refresh
499
- * persists to the database, never to a plaintext file. */
500
- credentials, providerConfig = new PiConfigStore(),
501
- /** Operator-pinned models (Console → Settings → Models), surfaced first in
502
- * every picker. A getter for the same reason `instructions` is one. */
503
- pinned = () => [],
504
- /** Which bundled extensions the Console has switched on. A getter for the
505
- * same reason again: the toggle takes effect on the next session open. */
506
- enabledExtensions = () => [],
507
- /** The model that names a session after its first exchange (Console →
508
- * Settings → Models); unset means the first prompt stays the title. A
509
- * getter, read per turn end. */
510
- titleModel = () => undefined,
511
- /** What exists on disk. Injected so a test can hand this factory a listing
512
- * instead of a session directory and a database. */
409
+ /** Optional only for bare test factories; an OAuth refresh persists here,
410
+ * never to a plaintext auth.json. */
411
+ credentials, providerConfig = new PiConfigStore(), pinned = () => [], enabledExtensions = () => [], titleModel = () => undefined,
412
+ /** Injected so a test needs no session directory or database. */
513
413
  listings = new IndexedListing()) {
514
414
  this.extraTools = extraTools;
515
415
  this.instructions = instructions;
@@ -521,22 +421,16 @@ export class PiAgentFactory {
521
421
  this.titleModel = titleModel;
522
422
  this.listings = listings;
523
423
  }
524
- /** One runtime for the whole process; catalogs are global, not per session. */
424
+ /** Catalogs are global, not per session. */
525
425
  catalog;
526
- /** Where each listed session lives. A scan still stats every session file on
527
- * disk, which `resume` would pay on every cold open — web selection, an IM
528
- * message, a task run. The sidebar's own listing keeps this warm; a miss
529
- * still lists. */
426
+ /** A scan stats every session file, which `resume` would otherwise pay on
427
+ * every cold open. */
530
428
  located = new Map();
531
- /** That same scan, retained for LIST_TTL_MS instead of paid once per asking
532
- * surface — one workspace event has three (sidebar, Activity, task lookups).
533
- * Dropped on create; ids appear for reasons this factory never sees, so
534
- * a miss that decides something re-lists rather than trusts it (`resume`). */
429
+ /** Retained for LIST_TTL_MS; one workspace event has three asking surfaces. */
535
430
  listing;
536
431
  refreshQueue = Promise.resolve();
537
432
  builtinProviderIds;
538
- /** Structural fit: CredentialStore mirrors pi-ai's interface of the same
539
- * name, so the SDK accepts it without this file exporting any SDK type. */
433
+ /** CredentialStore mirrors pi-ai's interface structurally, so no SDK type is exported. */
540
434
  createRuntime() {
541
435
  return ModelRuntime.create(this.credentials ? { credentials: this.credentials } : {});
542
436
  }
@@ -568,8 +462,8 @@ export class PiAgentFactory {
568
462
  this.builtinIds(),
569
463
  this.providerConfig.providerStructures(),
570
464
  ]);
571
- // Refresh after queued config writes settle, so runtime and structure never
572
- // combine an older catalog with a newer models.json snapshot.
465
+ // After queued config writes settle, so runtime and structure never
466
+ // combine an older catalog with a newer models.json.
573
467
  const runtime = await this.refreshedRuntime();
574
468
  const stored = new Map((await runtime.listCredentials()).map((c) => [c.providerId, c.type]));
575
469
  return runtime.getProviders().map((provider) => {
@@ -601,17 +495,9 @@ export class PiAgentFactory {
601
495
  };
602
496
  });
603
497
  }
604
- /**
605
- * One real request on the model the operator named. `configured` only ever
606
- * meant "a credential is stored", and a wrong base URL, a revoked key, a
607
- * gateway rewriting the request and a model this endpoint has never heard of
608
- * all look identical until a turn fails hours later.
609
- *
610
- * The request goes out through a fetch of our own for one reason: what a
611
- * provider (or a proxy in front of it) was actually sent, and what it
612
- * actually said, is the answer here — a summary of either would be Pier's
613
- * word for someone else's.
614
- */
498
+ /** A fetch of our own records what the provider (or a proxy in front of it)
499
+ * was actually sent and actually said; a summary would be Pier's word for
500
+ * someone else's. */
615
501
  async check(providerId, modelId) {
616
502
  const started = Date.now();
617
503
  const signal = AbortSignal.timeout(PROVIDER_CHECK_TIMEOUT_MS);
@@ -620,7 +506,7 @@ export class PiAgentFactory {
620
506
  const recorded = async (input, init) => {
621
507
  request = typeof init?.body === "string" ? init.body : "";
622
508
  const response = await globalThis.fetch(input, init);
623
- // Cloned, not consumed: the SDK still needs to read the real stream.
509
+ // Cloned: the SDK still needs the real stream.
624
510
  body = response.clone().text().then(clip, () => "");
625
511
  return response;
626
512
  };
@@ -638,7 +524,6 @@ export class PiAgentFactory {
638
524
  throw new Error(`unknown model: ${providerId}/${modelId}`);
639
525
  const answer = await runtime.completeSimple(model, { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, { maxTokens: PROVIDER_CHECK_MAX_TOKENS, signal, fetch: recorded });
640
526
  const text = textOfAnswer(answer);
641
- // An empty answer is still an answer; say which kind of nothing it was.
642
527
  return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
643
528
  }
644
529
  catch (err) {
@@ -650,17 +535,13 @@ export class PiAgentFactory {
650
535
  : raw || error, false);
651
536
  }
652
537
  }
653
- /** One bare completion — no session, no tools, no history — on the title
654
- * model. Rejects on anything but a usable line: the session reports it. */
655
538
  async suggestTitle(model, first, reply) {
656
539
  const runtime = await this.refreshedRuntime();
657
540
  const resolved = runtime.getModel(model.provider, model.id);
658
541
  if (!resolved)
659
542
  throw new Error(`title model ${model.provider}/${model.id} is not in the catalog`);
660
- // The least reasoning the model does, said explicitly: unset means the
661
- // provider's default, which on a reasoning model is not "none" (gpt-5:
662
- // medium) and spends the whole output cap thinking about a title. Anthropic
663
- // is the exception — its default is thinking off, and any level turns it on.
543
+ // Unset means the provider's default, which on gpt-5 is medium and spends
544
+ // the whole output cap thinking. Anthropic's default is off, and any level turns it on.
664
545
  const reasoning = resolved.reasoning && resolved.api !== "anthropic-messages" ? "minimal" : undefined;
665
546
  const answer = await runtime.completeSimple(resolved, { messages: [{ role: "user", content: titleRequest(first, reply), timestamp: Date.now() }] }, { maxTokens: TITLE_MAX_TOKENS, reasoning, signal: AbortSignal.timeout(TITLE_TIMEOUT_MS) });
666
547
  const title = titleFromAnswer(textOfAnswer(answer));
@@ -723,17 +604,11 @@ export class PiAgentFactory {
723
604
  async logout(providerId) {
724
605
  await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
725
606
  }
726
- /**
727
- * Pi discovers AGENTS.md itself; we append one more, in memory, telling the
728
- * agent what the surface it is talking to can render. Layered as a context
729
- * file (not a systemPromptOverride) so the user's own instructions still win.
730
- */
731
607
  async resourceLoader(cwd) {
732
608
  const loader = new DefaultResourceLoader({
733
609
  cwd,
734
- agentDir: defaultAgentDir(), // same discovery Pi would have done itself
735
- // SYSTEM.md remains user-owned: append it after Pier's replacement for
736
- // Pi's generic default, preserving the user's later instruction layer.
610
+ agentDir: defaultAgentDir(),
611
+ // The user's SYSTEM.md is appended after Pier's baseline, so it still wins.
737
612
  systemPromptOverride: pierSystemPrompt,
738
613
  additionalSkillPaths: this.skillPaths,
739
614
  skillsOverride: (base) => ({
@@ -762,11 +637,8 @@ export class PiAgentFactory {
762
637
  }
763
638
  async openSnapshot(cwd, sessionManager, opts) {
764
639
  let live;
765
- // Asked per open, not captured at wiring: a tool whose channel is not
766
- // configured yet would otherwise cost context on every turn of every
767
- // session and be able to answer nothing.
640
+ // Per open: an unconfigured tool would cost context on every turn and answer nothing.
768
641
  const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
769
- // Generic translation only — tool contracts are data owned by their feature.
770
642
  const customTools = active.map((tool) => defineTool({
771
643
  name: tool.name,
772
644
  label: tool.label,
@@ -780,8 +652,7 @@ export class PiAgentFactory {
780
652
  content: [
781
653
  {
782
654
  type: "text",
783
- // Compact, not indented: pretty-printing a nested result
784
- // costs ~20% more tokens and buys the model nothing.
655
+ // Pretty-printing costs ~20% more tokens and buys the model nothing.
785
656
  text: JSON.stringify(await tool.execute(params, caller, signal)),
786
657
  },
787
658
  ],
@@ -789,25 +660,20 @@ export class PiAgentFactory {
789
660
  };
790
661
  }
791
662
  catch (err) {
792
- // Pi turns this into tool-result text the model reads, which is the
793
- // right recovery and the wrong record: rethrown, but logged first.
663
+ // Pi turns this into tool-result text: the right recovery, the wrong record.
794
664
  log.warn(`tool ${tool.name} failed for ${caller}`, err);
795
665
  throw err;
796
666
  }
797
667
  },
798
668
  }));
799
- // Locked Secrets is a refusal with a reason, here — not "provider is not
800
- // configured" three calls later, and never a fall back to auth.json. Before
801
- // appendSessionInfo, so the refused open writes nothing to disk.
669
+ // A locked store is a refusal with a reason here, not "provider not
670
+ // configured" later. Before appendSessionInfo, so nothing is written.
802
671
  this.credentials?.assertUnlocked();
803
672
  if (opts.name)
804
673
  sessionManager.appendSessionInfo(opts.name);
805
- // This runtime serves exactly this one session, so shadowing its
806
- // streamSimple is the per-session seam for the Anthropic cache TTL:
807
- // interactive sessions keep "long" (1h — turns arrive minutes apart),
808
- // tasks downgrade to "short" (5m) via setCacheRetention. The default sits
809
- // before the spread so an explicit per-request value still wins —
810
- // compaction passes cacheRetention: "none" and must keep it.
674
+ // This runtime serves one session, so shadowing streamSimple is the
675
+ // per-session seam for the cache TTL. Default before the spread: compaction
676
+ // passes cacheRetention: "none" and must keep it.
811
677
  const runtime = await this.createRuntime();
812
678
  const retention = { value: "long" };
813
679
  const stream = runtime.streamSimple.bind(runtime);
@@ -820,12 +686,9 @@ export class PiAgentFactory {
820
686
  resourceLoader: await this.resourceLoader(cwd),
821
687
  });
822
688
  live = created.session;
823
- // Pi's queue defaults to handing over one follow-up per turn boundary, so N
824
- // queued messages cost N model turns and anything behind them waits them
825
- // out — 11 progress reports drained over 2.5 minutes and put a guidance
826
- // message 11 minutes late (2026-09-07). The agent's own setter only flips
827
- // the in-memory queue; `session.setFollowUpMode` would persist it to Pi's
828
- // settings.json on every open and make it global forever.
689
+ // Pi defaults to one follow-up per turn boundary, so N queued messages cost
690
+ // N turns. The agent's setter flips only the in-memory queue;
691
+ // `session.setFollowUpMode` would persist it to Pi's settings.json.
829
692
  live.agent.followUpMode = "all";
830
693
  const session = new PiSession(live, this.pinned, () => {
831
694
  this.listing = undefined;
@@ -842,8 +705,7 @@ export class PiAgentFactory {
842
705
  }
843
706
  async create(opts) {
844
707
  this.listing = undefined;
845
- // Resolved before Pi records it, so the transcript, the session directory
846
- // and every later listing all name the directory the same way.
708
+ // Resolved before Pi records it, so every later listing names it the same way.
847
709
  const cwd = realPath(opts.cwd);
848
710
  return this.open(cwd, SessionManager.create(cwd), { ...opts, cwd });
849
711
  }
@@ -854,8 +716,6 @@ export class PiAgentFactory {
854
716
  return await this.open(known.cwd, SessionManager.open(known.path));
855
717
  }
856
718
  catch (err) {
857
- // The file moved or went away under us: the cache was the only thing
858
- // that claimed otherwise, so drop it and take the slow, true path.
859
719
  log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
860
720
  this.located.delete(sessionId);
861
721
  }
@@ -865,14 +725,9 @@ export class PiAgentFactory {
865
725
  throw new Error(`unknown session: ${sessionId}`);
866
726
  return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
867
727
  }
868
- /** The listed record for one id, and the one place "no such session" is
869
- * decided. A retained listing is not evidence that a session is gone: it may
870
- * have been written since — by another Pier, or by the first turn of a
871
- * session this factory opened. The miss is what earns a fresh scan, because
872
- * callers read it as permission to start a replacement session
873
- * (channels/conversations.ts), which costs a conversation its history, or as
874
- * a session that no longer exists (tasks/, web/server.ts). `reused` is how we
875
- * know a scan is owed: same entry back, same disk state. */
728
+ /** The one place "no such session" is decided. A retained listing is not
729
+ * evidence a session is gone, and callers read a miss as permission to start
730
+ * a replacement, so a miss earns a fresh scan. */
876
731
  async locate(sessionId) {
877
732
  const find = (infos) => infos.find((s) => s.id === sessionId);
878
733
  const reused = this.listing;
@@ -883,11 +738,9 @@ export class PiAgentFactory {
883
738
  const info = await this.locate(sessionId);
884
739
  return info ? summaryOf(info) : undefined;
885
740
  }
886
- /** Once per process, after the first listing: agent/listing.ts reads Pi's
887
- * transcripts with a parser of its own, and only a comparison notices when
888
- * that format moves under it. */
741
+ /** agent/listing.ts parses Pi's transcripts itself; only a comparison
742
+ * notices when that format moves under it. */
889
743
  audited = false;
890
- /** Every listing goes through here, so it also refreshes `located`. */
891
744
  listed(force = false) {
892
745
  const now = Date.now();
893
746
  if (!force && this.listing && now - this.listing.at < LIST_TTL_MS)
@@ -913,9 +766,7 @@ export class PiAgentFactory {
913
766
  async list() {
914
767
  return (await this.listed()).map(summaryOf);
915
768
  }
916
- /** After a listing, so a transcript that grew since the last one is indexed
917
- * before it is asked about; the listing owns the index. One that cannot
918
- * search — a test's — has nothing to say. */
769
+ /** After a listing, so a transcript that grew is indexed before it is asked about. */
919
770
  async search(query) {
920
771
  await this.listed();
921
772
  return this.listings.search?.(query) ?? [];