@timqi/pier 0.0.29 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  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 +8 -24
  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 +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  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 +6 -14
  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 +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  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 +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.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-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  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/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/dist/agent/pi.js CHANGED
@@ -1,41 +1,86 @@
1
1
  // The only file outside src/extensions allowed to import @earendil-works/pi-*.
2
2
  // Implements the AgentFactory/AgentSession seam from src/core/types.ts on the
3
3
  // Pi SDK. No Pi type may appear in an exported signature.
4
+ import { realpathSync } from "node:fs";
5
+ import { basename, dirname, join } from "node:path";
4
6
  import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
5
7
  import { inlineExtensions } from "../extensions/index.js";
8
+ import { SESSION_TITLE_MAX } from "../core/types.js";
6
9
  import { logger } from "../log.js";
7
- import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
10
+ import { textOf, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
8
11
  import { defaultAgentDir, PiConfigStore } from "./config.js";
9
12
  import { IndexedListing } from "./listing.js";
10
13
  import { curateModels, pinFirst } from "./models.js";
11
14
  const log = logger("agent");
12
- /** A listed record as the seam reports it. The one mapping, because `list` and
13
- * `find` answer with the same shape and drifting would mean two answers about
14
- * one session. */
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. */
18
+ const realPaths = new Map();
19
+ function realPath(cwd) {
20
+ const known = realPaths.get(cwd);
21
+ if (known !== undefined)
22
+ return known;
23
+ let real = cwd;
24
+ const missing = [];
25
+ for (let head = cwd;;) {
26
+ try {
27
+ real = join(realpathSync(head), ...missing);
28
+ break;
29
+ }
30
+ catch (err) {
31
+ const parent = dirname(head);
32
+ if (parent === head) {
33
+ log.debug(`cwd ${cwd} has no resolvable ancestor; using it as recorded`, err);
34
+ break;
35
+ }
36
+ missing.unshift(basename(head));
37
+ head = parent;
38
+ }
39
+ }
40
+ realPaths.set(cwd, real);
41
+ return real;
42
+ }
15
43
  const summaryOf = (s) => ({
16
44
  id: s.id,
17
- cwd: s.cwd,
45
+ cwd: realPath(s.cwd),
18
46
  createdAt: s.created,
19
47
  modified: s.modified,
20
48
  ...(s.title ? { title: s.title } : {}),
21
49
  });
22
- /** Pi's bash tool has no default timeout, so a hung command holds the turn
23
- * until someone aborts it — nobody is watching in a scheduled task. Kept below
24
- * the default task-run timeout so a stuck command comes back as
25
- * a tool error the agent can retry with an explicit longer timeout, instead of
26
- * 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. */
27
53
  const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
28
- /** How long a session listing stays usable: long enough that one workspace
29
- * event, which several surfaces answer at once, scans disk once; short enough
30
- * 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. */
31
56
  const LIST_TTL_MS = 3_000;
32
- /** A probe nobody is watching is a hung page: the Console waits on this. */
33
57
  const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
34
- /** An ordinary budget, not a token: a 1-token cap is a request no real turn
35
- * 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. */
36
59
  const PROVIDER_CHECK_MAX_TOKENS = 8192;
37
- /** Neither half of a probe is worth more than a screen. */
38
60
  const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
61
+ /** The subject of an exchange is in its opening lines; the answer is one line. */
62
+ const TITLE_INPUT_CHARS = 600;
63
+ const TITLE_MAX_TOKENS = 40;
64
+ const TITLE_TIMEOUT_MS = 20_000;
65
+ const TITLE_PROMPT = "Name this conversation for a session list. Reply with the title only: at most 12 Chinese characters " +
66
+ "or 6 English words, in the language the user wrote in, no quotes, no trailing period. " +
67
+ "A leading `[name<id> time]` on the user's message is a speaker header, not content.";
68
+ /** The halves are labelled so a reply quoting an instruction back is not
69
+ * mistaken for one. */
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>`;
71
+ /** A provider can decline as a message rather than a throw; only the stop
72
+ * reason tells that from an answer. */
73
+ function textOfAnswer(answer) {
74
+ if (answer.stopReason === "error" || answer.stopReason === "aborted") {
75
+ throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
76
+ }
77
+ return textOf(answer.content).trim();
78
+ }
79
+ /** Empty is a failure, not a cleared name — the caller reports it. */
80
+ export function titleFromAnswer(text) {
81
+ const line = text.trim().split("\n")[0]?.trim() ?? "";
82
+ return line.replace(/^["'“‘「]+|["'”’」.。]+$/g, "").trim().slice(0, SESSION_TITLE_MAX);
83
+ }
39
84
  /** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
40
85
  const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care — do the work and verify the result.
41
86
 
@@ -59,8 +104,7 @@ These rules govern conversational replies. A human reads them on a phone-sized s
59
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.
60
105
  - Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.`;
61
106
  export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
62
- /** Patching the call is cheaper than replacing the tool: the built-in keeps its
63
- * shell settings, and the agent spends no tokens deciding a timeout. */
107
+ /** Patching the call keeps the built-in's shell settings. */
64
108
  const bashTimeoutDefault = (pi) => {
65
109
  pi.on("tool_call", (event) => {
66
110
  if (event.toolName === "bash" && event.input.timeout === undefined) {
@@ -68,12 +112,8 @@ const bashTimeoutDefault = (pi) => {
68
112
  }
69
113
  });
70
114
  };
71
- /**
72
- * A bundled extension stands down when a copy on disk already registers one of
73
- * its tools. Pi loads both and reports the clash as a diagnostic nobody reads,
74
- * leaving two tools of the same name and no way to tell which one answered;
75
- * the copy the user put there wins, and the journal says so (§5b).
76
- */
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). */
77
117
  export const standDownShadowed = (base) => {
78
118
  const inline = (ext) => ext.path.startsWith("<inline:");
79
119
  const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
@@ -90,11 +130,8 @@ export const standDownShadowed = (base) => {
90
130
  }),
91
131
  };
92
132
  };
93
- /**
94
- * A bundled skill stands down with the tool it documents. A skill's
95
- * description is resident in every prompt, so one pointing at a tool this
96
- * session was not given is both a cost and a route the agent cannot take.
97
- */
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. */
98
135
  export const standDownUndocumented = (tools, skills) => {
99
136
  const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
100
137
  if (!gone.size)
@@ -106,25 +143,23 @@ export class PiSession {
106
143
  pinned;
107
144
  wrote;
108
145
  retention;
146
+ suggestTitle;
109
147
  constructor(pi,
110
- /** Operator pins, read per call — the menu can change while we run. */
148
+ /** Read per call — the menu can change while we run. */
111
149
  pinned = () => [],
112
- /** "What I just wrote is not in your listing yet." The factory retains a
113
- * scan for a few seconds, which is exactly the window a rename lands in:
114
- * every surface would re-read the old title and keep it until some
115
- * unrelated event moved the list again. Same drop `create` does — a
116
- * callback only because the session is what knows it happened. */
117
- wrote = () => { }, retention = { value: "long" }) {
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. */
152
+ wrote = () => { }, retention = { value: "long" },
153
+ /** Read per turn: switching auto-titling on takes effect without a restart. */
154
+ suggestTitle = () => undefined) {
118
155
  this.pi = pi;
119
156
  this.pinned = pinned;
120
157
  this.wrote = wrote;
121
158
  this.retention = retention;
159
+ this.suggestTitle = suggestTitle;
122
160
  }
123
- /** Pi's dispose unhooks the one listener that persists and emits, so a turn
124
- * started after it runs for real — model call, tools and all — and lands
125
- * nowhere: no transcript entry, no event, and a promise that resolves as if
126
- * it worked. Refusing is what turns that silence into the failure it is;
127
- * 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). */
128
163
  disposed = false;
129
164
  live() {
130
165
  if (this.disposed)
@@ -150,7 +185,6 @@ export class PiSession {
150
185
  async setModel(ref) {
151
186
  const m = this.pi.modelRuntime.getModel(ref.provider, ref.id);
152
187
  if (!m) {
153
- // Lazy discovery: the failure itself documents what is selectable.
154
188
  const available = (await this.availableModels())
155
189
  .slice(0, 8).map((entry) => `${entry.provider}/${entry.id}`).join(", ");
156
190
  throw new Error(`unknown model: ${ref.provider}/${ref.id}; available: ${available}`);
@@ -160,8 +194,7 @@ export class PiSession {
160
194
  async availableModels() {
161
195
  const available = await this.pi.modelRuntime.getAvailable();
162
196
  const curated = pinFirst(curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning }))), this.pinned());
163
- // The session's active model must stay selectable even when curation
164
- // (or an older catalog) would hide it.
197
+ // The active model must stay selectable even when curation would hide it.
165
198
  const current = this.model;
166
199
  if (current && !curated.some((m) => m.provider === current.provider && m.id === current.id)) {
167
200
  curated.unshift(current);
@@ -184,22 +217,20 @@ export class PiSession {
184
217
  };
185
218
  }
186
219
  /** A system input handed to a streaming session goes into Pi's *agent*
187
- * queue, and the arrays above are the *session's* — they only ever hold
188
- * text a `prompt`/`steer`/`followUp` call queued. So a task message sitting
189
- * in front of a running turn is in neither the queue nor the transcript,
190
- * 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. */
191
222
  queuedInputs = [];
192
223
  async pendingSystemInputs() {
193
- // Pi drains its follow-up queue before the turn ends, so an idle session
194
- // has nothing in flight. Anything still listed was aborted or cleared, and
195
- // 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).
196
227
  if (!this.pi.isStreaming)
197
228
  this.queuedInputs.length = 0;
198
229
  return [...this.queuedInputs];
199
230
  }
200
231
  async clearQueue() {
201
- // Not among the texts returned — a system input is nobody's draft — but
202
- // 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.
203
234
  this.queuedInputs.length = 0;
204
235
  return this.pi.clearQueue();
205
236
  }
@@ -209,8 +240,8 @@ export class PiSession {
209
240
  }
210
241
  async rewindToUserTurn(index) {
211
242
  const total = (await this.history()).filter((t) => t.role === "user").length;
212
- // Anchor at the tail: branch entries keep compacted-away history that
213
- // 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.
214
245
  const back = total - index;
215
246
  const users = this.pi.sessionManager
216
247
  .getBranch()
@@ -218,27 +249,20 @@ export class PiSession {
218
249
  const target = back >= 1 ? users[users.length - back] : undefined;
219
250
  if (!target)
220
251
  throw new Error(`no user turn at index ${index}`);
221
- // navigateTree on a user message moves the leaf to its parent — the old
222
- // branch stays in the file but leaves the context.
252
+ // The old branch stays in the file but leaves the context.
223
253
  const { cancelled } = await this.pi.navigateTree(target.id);
224
254
  if (cancelled)
225
255
  throw new Error("rewind cancelled");
226
256
  }
227
- /** The compaction running right now, or null. Pi keeps no lock of its own —
228
- * a second `compact()` aborts the first's turn and summarizes a transcript
229
- * that is being replaced under it — and two POSTs a millisecond apart both
230
- * 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. */
231
260
  compacting = null;
232
- /** Pi's own compaction, minus its `CompactionResult`: the numbers reach
233
- * surfaces as the `context-compacted` event the seam already emits for the
234
- * automatic one, so a caller has nothing to do with them. Refused while one
235
- * is running, rather than run twice over one context. */
236
261
  async compact() {
237
262
  this.live();
238
263
  if (this.compacting)
239
264
  throw new Error(`session ${this.pi.sessionId} is already compacting`);
240
- // Started and recorded in the same tick, with no await between: that is
241
- // 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.
242
266
  const running = this.pi.compact().then(() => undefined);
243
267
  this.compacting = running;
244
268
  try {
@@ -248,48 +272,30 @@ export class PiSession {
248
272
  this.compacting = null;
249
273
  }
250
274
  }
251
- /** One `session_info` entry, which Pi's own reader takes the latest of — so
252
- * a rename is an append like everything else in a transcript, and nothing
253
- * has to be rewritten. Never refused for being busy: a name has nothing to
254
- * do with the turn running.
255
- *
256
- * Returns nothing, because the transcript is the answer: what the session is
257
- * called after this — the name, or the title a cleared one falls back to —
258
- * is what the next listing reads off the file, and deriving it here as well
259
- * was a second copy of a rule agent/listing.ts already owns.
260
- *
261
- * TODO: renaming a cold session costs a whole resume, because the route
262
- * reaches it through `ensure` and this method needs a live Pi session to
263
- * append through. The work is one line in a file. Revisit when Pi offers a
264
- * 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. */
265
278
  async rename(name) {
266
279
  this.live();
267
280
  this.pi.sessionManager.appendSessionInfo(name);
268
281
  this.wrote();
269
282
  }
270
- /** Compaction replaces the context a turn would run against, so a dispatch
271
- * that lands mid-compaction waits for the summary instead of starting a turn
272
- * over it — the follow-up promise ("delivered when idle") without Pi's
273
- * follow-up queue, which is only drained by the *next* turn: a message
274
- * parked there while nothing is running would sit unsent, and Pi's own
275
- * `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. */
276
286
  async whenCompacted() {
277
287
  while (this.compacting)
278
288
  await this.compacting.catch(() => undefined);
279
289
  }
280
- // Async, so a refusal is a rejected promise: the seam promises callers they
281
- // 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()`.
282
291
  async prompt(text) {
283
292
  this.live();
284
293
  await this.whenCompacted();
285
- // 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.
286
295
  this.live();
287
- // A turn may have started since the caller read the state this prompt was
288
- // decided against — two messages arriving together, or several released at
289
- // once by the wait above. Bare, Pi throws that back as "already
290
- // processing" and the message is gone (§5b); queued, it is the same
291
- // "delivered when idle" the core's own policy picks for an auto message
292
- // 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.
293
299
  return this.pi.prompt(text, { streamingBehavior: "followUp" });
294
300
  }
295
301
  async steer(text) {
@@ -302,13 +308,10 @@ export class PiSession {
302
308
  }
303
309
  async systemInput(text, origin, mode) {
304
310
  this.live();
305
- // Same gate as prompt(): an idle session takes a system input as a turn
306
- // whatever the mode says, so a callback landing mid-compaction would race
307
- // the summary too.
311
+ // Same gate as prompt(): an idle session takes a system input as a turn.
308
312
  await this.whenCompacted();
309
313
  this.live();
310
- // Read here, not before the wait, and whatever the mode says: a turn is
311
- // 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.
312
315
  const queued = this.pi.isStreaming;
313
316
  if (queued)
314
317
  this.queuedInputs.push(origin);
@@ -316,8 +319,7 @@ export class PiSession {
316
319
  return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
317
320
  }
318
321
  catch (error) {
319
- // Refused: nothing is in flight, and the caller is about to retry. The
320
- // 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.
321
323
  const at = this.queuedInputs.indexOf(origin);
322
324
  if (at >= 0)
323
325
  this.queuedInputs.splice(at, 1);
@@ -334,18 +336,46 @@ export class PiSession {
334
336
  if (piEvent.type === "agent_end")
335
337
  retryPending = piEvent.willRetry === true;
336
338
  if (piEvent.type === "agent_settled" && retryPending) {
337
- // Aborting Pi during retry backoff produces no final agent_end, so the
338
- // turn has to be ended here. The idle is not: `agent_settled` carries
339
- // it through the table below like any other settle.
339
+ // Aborting Pi during retry backoff produces no final agent_end.
340
340
  retryPending = false;
341
341
  fn({ type: "turn-end", text: "", meta: this.lastTurnMeta() });
342
342
  }
343
343
  for (const payload of toSessionEvents(piEvent)) {
344
344
  fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
345
+ if (payload.type === "turn-end" && !payload.error)
346
+ this.autoTitle(payload.text, fn);
345
347
  }
346
348
  });
347
349
  }
348
- /** Meta of the just-finished turn; live path, so "now" is the completion. */
350
+ /** Several subscribers see every turn-end; this is what makes the request one. */
351
+ titleDecided = false;
352
+ /** A failure is announced too: a title that silently stayed the prompt looks
353
+ * like the setting did nothing (§5). */
354
+ autoTitle(reply, fn) {
355
+ if (this.titleDecided)
356
+ return;
357
+ const suggest = this.suggestTitle();
358
+ if (!suggest)
359
+ return; // off is not decided: switched on later, the next turn still counts
360
+ this.titleDecided = true;
361
+ if (this.pi.sessionManager.getSessionName())
362
+ return;
363
+ const users = this.pi.messages.filter((m) => m.role === "user");
364
+ const first = users.length === 1 ? textOf(users[0]?.content) : "";
365
+ if (!first.trim())
366
+ return;
367
+ void suggest(first, reply).then((title) => {
368
+ // Named while we waited, by a person: their word beats the model's.
369
+ if (this.disposed || this.pi.sessionManager.getSessionName())
370
+ return;
371
+ this.pi.sessionManager.appendSessionInfo(title);
372
+ this.wrote();
373
+ fn({ type: "renamed", title });
374
+ }, (err) => {
375
+ log.warn(`session ${this.pi.sessionId} could not be titled`, err);
376
+ fn({ type: "error", message: `session title: ${err instanceof Error ? err.message : String(err)} — the first message stays the title` });
377
+ });
378
+ }
349
379
  lastTurnMeta() {
350
380
  const messages = this.pi.messages;
351
381
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -367,29 +397,19 @@ export class PiAgentFactory {
367
397
  providerConfig;
368
398
  pinned;
369
399
  enabledExtensions;
400
+ titleModel;
370
401
  listings;
371
402
  constructor(extraTools = [],
372
- /** Appended as a virtual context file, so Pi's own prompt stays intact.
373
- * Read per session, not captured once: it carries settings a user can
374
- * change while the process runs, and the next session must say the new
375
- * 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. */
376
406
  instructions = () => "",
377
- /** Skills documenting Pier's own tools: loaded per session, never installed
378
- * into the user's global or project skill directories. */
407
+ /** Loaded per session, never installed into the user's skill directories. */
379
408
  skillPaths = [],
380
- /** Provider credentials from pier.db instead of <agentDir>/auth.json.
381
- * Optional only for bare test factories; main.ts always passes one, and
382
- * every runtime built here reads and writes through it — an OAuth refresh
383
- * persists to the database, never to a plaintext file. */
384
- credentials, providerConfig = new PiConfigStore(),
385
- /** Operator-pinned models (Console → Settings → Models), surfaced first in
386
- * every picker. A getter for the same reason `instructions` is one. */
387
- pinned = () => [],
388
- /** Which bundled extensions the Console has switched on. A getter for the
389
- * same reason again: the toggle takes effect on the next session open. */
390
- enabledExtensions = () => [],
391
- /** What exists on disk. Injected so a test can hand this factory a listing
392
- * 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. */
393
413
  listings = new IndexedListing()) {
394
414
  this.extraTools = extraTools;
395
415
  this.instructions = instructions;
@@ -398,24 +418,19 @@ export class PiAgentFactory {
398
418
  this.providerConfig = providerConfig;
399
419
  this.pinned = pinned;
400
420
  this.enabledExtensions = enabledExtensions;
421
+ this.titleModel = titleModel;
401
422
  this.listings = listings;
402
423
  }
403
- /** One runtime for the whole process; catalogs are global, not per session. */
424
+ /** Catalogs are global, not per session. */
404
425
  catalog;
405
- /** Where each listed session lives. A scan still stats every session file on
406
- * disk, which `resume` would pay on every cold open — web selection, an IM
407
- * message, a task run. The sidebar's own listing keeps this warm; a miss
408
- * still lists. */
426
+ /** A scan stats every session file, which `resume` would otherwise pay on
427
+ * every cold open. */
409
428
  located = new Map();
410
- /** That same scan, retained for LIST_TTL_MS instead of paid once per asking
411
- * surface — one workspace event has three (sidebar, Activity, task lookups).
412
- * Dropped on create; ids appear for reasons this factory never sees, so
413
- * 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. */
414
430
  listing;
415
431
  refreshQueue = Promise.resolve();
416
432
  builtinProviderIds;
417
- /** Structural fit: CredentialStore mirrors pi-ai's interface of the same
418
- * 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. */
419
434
  createRuntime() {
420
435
  return ModelRuntime.create(this.credentials ? { credentials: this.credentials } : {});
421
436
  }
@@ -447,8 +462,8 @@ export class PiAgentFactory {
447
462
  this.builtinIds(),
448
463
  this.providerConfig.providerStructures(),
449
464
  ]);
450
- // Refresh after queued config writes settle, so runtime and structure never
451
- // 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.
452
467
  const runtime = await this.refreshedRuntime();
453
468
  const stored = new Map((await runtime.listCredentials()).map((c) => [c.providerId, c.type]));
454
469
  return runtime.getProviders().map((provider) => {
@@ -480,17 +495,9 @@ export class PiAgentFactory {
480
495
  };
481
496
  });
482
497
  }
483
- /**
484
- * One real request on the model the operator named. `configured` only ever
485
- * meant "a credential is stored", and a wrong base URL, a revoked key, a
486
- * gateway rewriting the request and a model this endpoint has never heard of
487
- * all look identical until a turn fails hours later.
488
- *
489
- * The request goes out through a fetch of our own for one reason: what a
490
- * provider (or a proxy in front of it) was actually sent, and what it
491
- * actually said, is the answer here — a summary of either would be Pier's
492
- * word for someone else's.
493
- */
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. */
494
501
  async check(providerId, modelId) {
495
502
  const started = Date.now();
496
503
  const signal = AbortSignal.timeout(PROVIDER_CHECK_TIMEOUT_MS);
@@ -499,7 +506,7 @@ export class PiAgentFactory {
499
506
  const recorded = async (input, init) => {
500
507
  request = typeof init?.body === "string" ? init.body : "";
501
508
  const response = await globalThis.fetch(input, init);
502
- // Cloned, not consumed: the SDK still needs to read the real stream.
509
+ // Cloned: the SDK still needs the real stream.
503
510
  body = response.clone().text().then(clip, () => "");
504
511
  return response;
505
512
  };
@@ -516,18 +523,7 @@ export class PiAgentFactory {
516
523
  if (!model)
517
524
  throw new Error(`unknown model: ${providerId}/${modelId}`);
518
525
  const answer = await runtime.completeSimple(model, { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, { maxTokens: PROVIDER_CHECK_MAX_TOKENS, signal, fetch: recorded });
519
- // A refusal can arrive as a message rather than a throw; the stop reason
520
- // is the only thing separating it from an answer.
521
- const refused = answer.stopReason === "error" || answer.stopReason === "aborted";
522
- if (refused) {
523
- throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
524
- }
525
- const text = answer.content
526
- .filter((part) => part.type === "text")
527
- .map((part) => part.text)
528
- .join("")
529
- .trim();
530
- // An empty answer is still an answer; say which kind of nothing it was.
526
+ const text = textOfAnswer(answer);
531
527
  return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
532
528
  }
533
529
  catch (err) {
@@ -539,6 +535,20 @@ export class PiAgentFactory {
539
535
  : raw || error, false);
540
536
  }
541
537
  }
538
+ async suggestTitle(model, first, reply) {
539
+ const runtime = await this.refreshedRuntime();
540
+ const resolved = runtime.getModel(model.provider, model.id);
541
+ if (!resolved)
542
+ throw new Error(`title model ${model.provider}/${model.id} is not in the catalog`);
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.
545
+ const reasoning = resolved.reasoning && resolved.api !== "anthropic-messages" ? "minimal" : undefined;
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) });
547
+ const title = titleFromAnswer(textOfAnswer(answer));
548
+ if (!title)
549
+ throw new Error(`${model.provider}/${model.id} answered with no title`);
550
+ return title;
551
+ }
542
552
  async setup(input) {
543
553
  const builtins = await this.builtinIds();
544
554
  if (input.kind === "builtin" && !builtins.has(input.id)) {
@@ -594,17 +604,11 @@ export class PiAgentFactory {
594
604
  async logout(providerId) {
595
605
  await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
596
606
  }
597
- /**
598
- * Pi discovers AGENTS.md itself; we append one more, in memory, telling the
599
- * agent what the surface it is talking to can render. Layered as a context
600
- * file (not a systemPromptOverride) so the user's own instructions still win.
601
- */
602
607
  async resourceLoader(cwd) {
603
608
  const loader = new DefaultResourceLoader({
604
609
  cwd,
605
- agentDir: defaultAgentDir(), // same discovery Pi would have done itself
606
- // SYSTEM.md remains user-owned: append it after Pier's replacement for
607
- // 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.
608
612
  systemPromptOverride: pierSystemPrompt,
609
613
  additionalSkillPaths: this.skillPaths,
610
614
  skillsOverride: (base) => ({
@@ -633,11 +637,8 @@ export class PiAgentFactory {
633
637
  }
634
638
  async openSnapshot(cwd, sessionManager, opts) {
635
639
  let live;
636
- // Asked per open, not captured at wiring: a tool whose channel is not
637
- // configured yet would otherwise cost context on every turn of every
638
- // session and be able to answer nothing.
640
+ // Per open: an unconfigured tool would cost context on every turn and answer nothing.
639
641
  const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
640
- // Generic translation only — tool contracts are data owned by their feature.
641
642
  const customTools = active.map((tool) => defineTool({
642
643
  name: tool.name,
643
644
  label: tool.label,
@@ -651,8 +652,7 @@ export class PiAgentFactory {
651
652
  content: [
652
653
  {
653
654
  type: "text",
654
- // Compact, not indented: pretty-printing a nested result
655
- // costs ~20% more tokens and buys the model nothing.
655
+ // Pretty-printing costs ~20% more tokens and buys the model nothing.
656
656
  text: JSON.stringify(await tool.execute(params, caller, signal)),
657
657
  },
658
658
  ],
@@ -660,25 +660,20 @@ export class PiAgentFactory {
660
660
  };
661
661
  }
662
662
  catch (err) {
663
- // Pi turns this into tool-result text the model reads, which is the
664
- // right recovery and the wrong record: rethrown, but logged first.
663
+ // Pi turns this into tool-result text: the right recovery, the wrong record.
665
664
  log.warn(`tool ${tool.name} failed for ${caller}`, err);
666
665
  throw err;
667
666
  }
668
667
  },
669
668
  }));
670
- // Locked Secrets is a refusal with a reason, here — not "provider is not
671
- // configured" three calls later, and never a fall back to auth.json. Before
672
- // 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.
673
671
  this.credentials?.assertUnlocked();
674
672
  if (opts.name)
675
673
  sessionManager.appendSessionInfo(opts.name);
676
- // This runtime serves exactly this one session, so shadowing its
677
- // streamSimple is the per-session seam for the Anthropic cache TTL:
678
- // interactive sessions keep "long" (1h — turns arrive minutes apart),
679
- // tasks downgrade to "short" (5m) via setCacheRetention. The default sits
680
- // before the spread so an explicit per-request value still wins —
681
- // 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.
682
677
  const runtime = await this.createRuntime();
683
678
  const retention = { value: "long" };
684
679
  const stream = runtime.streamSimple.bind(runtime);
@@ -691,16 +686,16 @@ export class PiAgentFactory {
691
686
  resourceLoader: await this.resourceLoader(cwd),
692
687
  });
693
688
  live = created.session;
694
- // Pi's queue defaults to handing over one follow-up per turn boundary, so N
695
- // queued messages cost N model turns and anything behind them waits them
696
- // out — 11 progress reports drained over 2.5 minutes and put a guidance
697
- // message 11 minutes late (2026-09-07). The agent's own setter only flips
698
- // the in-memory queue; `session.setFollowUpMode` would persist it to Pi's
699
- // 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.
700
692
  live.agent.followUpMode = "all";
701
693
  const session = new PiSession(live, this.pinned, () => {
702
694
  this.listing = undefined;
703
- }, retention);
695
+ }, retention, () => {
696
+ const model = this.titleModel();
697
+ return model && ((first, reply) => this.suggestTitle(model, first, reply));
698
+ });
704
699
  if (opts.model)
705
700
  await session.setModel(opts.model);
706
701
  if (opts.thinking)
@@ -710,7 +705,9 @@ export class PiAgentFactory {
710
705
  }
711
706
  async create(opts) {
712
707
  this.listing = undefined;
713
- return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
708
+ // Resolved before Pi records it, so every later listing names it the same way.
709
+ const cwd = realPath(opts.cwd);
710
+ return this.open(cwd, SessionManager.create(cwd), { ...opts, cwd });
714
711
  }
715
712
  async resume(sessionId) {
716
713
  const known = this.located.get(sessionId);
@@ -719,8 +716,6 @@ export class PiAgentFactory {
719
716
  return await this.open(known.cwd, SessionManager.open(known.path));
720
717
  }
721
718
  catch (err) {
722
- // The file moved or went away under us: the cache was the only thing
723
- // that claimed otherwise, so drop it and take the slow, true path.
724
719
  log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
725
720
  this.located.delete(sessionId);
726
721
  }
@@ -730,14 +725,9 @@ export class PiAgentFactory {
730
725
  throw new Error(`unknown session: ${sessionId}`);
731
726
  return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
732
727
  }
733
- /** The listed record for one id, and the one place "no such session" is
734
- * decided. A retained listing is not evidence that a session is gone: it may
735
- * have been written since — by another Pier, or by the first turn of a
736
- * session this factory opened. The miss is what earns a fresh scan, because
737
- * callers read it as permission to start a replacement session
738
- * (channels/conversations.ts), which costs a conversation its history, or as
739
- * a session that no longer exists (tasks/, web/server.ts). `reused` is how we
740
- * 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. */
741
731
  async locate(sessionId) {
742
732
  const find = (infos) => infos.find((s) => s.id === sessionId);
743
733
  const reused = this.listing;
@@ -748,11 +738,9 @@ export class PiAgentFactory {
748
738
  const info = await this.locate(sessionId);
749
739
  return info ? summaryOf(info) : undefined;
750
740
  }
751
- /** Once per process, after the first listing: agent/listing.ts reads Pi's
752
- * transcripts with a parser of its own, and only a comparison notices when
753
- * 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. */
754
743
  audited = false;
755
- /** Every listing goes through here, so it also refreshes `located`. */
756
744
  listed(force = false) {
757
745
  const now = Date.now();
758
746
  if (!force && this.listing && now - this.listing.at < LIST_TTL_MS)
@@ -778,4 +766,9 @@ export class PiAgentFactory {
778
766
  async list() {
779
767
  return (await this.listed()).map(summaryOf);
780
768
  }
769
+ /** After a listing, so a transcript that grew is indexed before it is asked about. */
770
+ async search(query) {
771
+ await this.listed();
772
+ return this.listings.search?.(query) ?? [];
773
+ }
781
774
  }