@timqi/pier 0.0.29 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/agent/config.js +22 -0
  2. package/dist/agent/events.js +4 -1
  3. package/dist/agent/listing.js +114 -16
  4. package/dist/agent/pi.js +158 -16
  5. package/dist/channels/lark-panel.js +1 -1
  6. package/dist/core/inbox.js +1 -1
  7. package/dist/core/router.js +4 -0
  8. package/dist/core/types.js +13 -1
  9. package/dist/db.js +30 -0
  10. package/dist/drain.js +1 -1
  11. package/dist/extensions/web/content.js +2 -2
  12. package/dist/main.js +8 -1
  13. package/dist/secrets.js +1 -1
  14. package/dist/service.js +2 -2
  15. package/dist/settings.js +31 -10
  16. package/dist/tasks/agent.js +109 -73
  17. package/dist/tasks/callbacks.js +1 -3
  18. package/dist/tasks/command.js +27 -8
  19. package/dist/tasks/execution.js +6 -4
  20. package/dist/tasks/groups.js +35 -26
  21. package/dist/tasks/messages.js +33 -27
  22. package/dist/tasks/outbox.js +48 -21
  23. package/dist/tasks/routes.js +1 -4
  24. package/dist/tasks/runs.js +10 -4
  25. package/dist/tasks/service.js +24 -12
  26. package/dist/tasks/store.js +17 -3
  27. package/dist/tasks/tool.js +97 -25
  28. package/dist/tools.js +2 -2
  29. package/dist/update.js +1 -1
  30. package/dist/web/instance.js +10 -3
  31. package/dist/web/providers.js +9 -2
  32. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-Bl3vZukb.js} +2 -2
  33. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  34. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  35. package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
  36. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  37. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  38. package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
  39. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  40. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  41. package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
  42. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  43. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  44. package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
  45. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  46. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  47. package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
  48. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  49. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  50. package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
  51. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  52. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  53. package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
  54. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  55. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  56. package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
  57. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  58. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
  59. package/dist/web/public/index.html +76 -120
  60. package/dist/web/public/index.html.br +0 -0
  61. package/dist/web/public/index.html.gz +0 -0
  62. package/dist/web/public/manifest.webmanifest +2 -2
  63. package/dist/web/public/manifest.webmanifest.br +0 -0
  64. package/dist/web/public/manifest.webmanifest.gz +0 -0
  65. package/dist/web/server.js +40 -8
  66. package/dist/web/session-state.js +5 -5
  67. package/package.json +2 -1
  68. package/skills/pier-tasks/SKILL.md +145 -160
  69. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  70. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  71. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  72. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  73. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  74. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  75. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  76. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  77. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  78. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  79. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  80. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  81. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  82. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  83. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  84. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  85. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  86. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  87. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  88. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  89. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  90. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  91. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  92. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  93. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  94. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -20,6 +20,21 @@ const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
20
20
  export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
21
21
  /** Stable mask: mapped back by field, without exposing key fragments. */
22
22
  const maskKey = (_key) => "••••••••";
23
+ /** Pi reads the two highest levels off the model's own map, so a ceiling is
24
+ * read back from it and written into it — leaving the rest of a hand-written
25
+ * map (an `off: null` that forbids thinking-off) alone, because the Console
26
+ * does not offer it and therefore may not drop it. */
27
+ const effortOf = (map) => typeof map?.max === "string" ? "max" : typeof map?.xhigh === "string" ? "xhigh" : undefined;
28
+ function withEffort(map, effort) {
29
+ const next = { ...map };
30
+ delete next.xhigh;
31
+ delete next.max;
32
+ if (effort === "xhigh" || effort === "max")
33
+ next.xhigh = "xhigh";
34
+ if (effort === "max")
35
+ next.max = "max";
36
+ return Object.keys(next).length ? next : undefined;
37
+ }
23
38
  const missing = (err) => err instanceof Error && "code" in err && err.code === "ENOENT";
24
39
  const readNullable = async (path) => {
25
40
  try {
@@ -131,9 +146,11 @@ export class PiConfigStore {
131
146
  if (typeof model !== "object" || model === null || typeof model.id !== "string") {
132
147
  return [];
133
148
  }
149
+ const effort = effortOf(asRecord(model.thinkingLevelMap));
134
150
  return [{
135
151
  id: model.id,
136
152
  reasoning: model.reasoning === true,
153
+ ...(effort ? { effort } : {}),
137
154
  }];
138
155
  })
139
156
  : undefined;
@@ -180,6 +197,11 @@ export class PiConfigStore {
180
197
  next.reasoning = true;
181
198
  else
182
199
  delete next.reasoning;
200
+ const map = withEffort(asRecord(next.thinkingLevelMap), model.effort);
201
+ if (map)
202
+ next.thinkingLevelMap = map;
203
+ else
204
+ delete next.thinkingLevelMap;
183
205
  return next;
184
206
  });
185
207
  }
@@ -3,7 +3,10 @@
3
3
  // and Pi types never leak past the seam. The golden-table test in
4
4
  // events.test.ts is the mapping's spec; extend types.ts before adding events.
5
5
  import { isThinkingLevel, MAX_STEP_OUTPUT } from "../core/types.js";
6
- const hasToolCalls = (message) => Array.isArray(message?.content) && message.content.some((part) => part.type === "toolCall");
6
+ /** An assistant message that calls a tool is work in progress, not a reply —
7
+ * the rule `toChatTurns` rebuilds a transcript by, and agent/listing.ts
8
+ * indexes one by. */
9
+ export const hasToolCalls = (message) => Array.isArray(message?.content) && message.content.some((part) => part.type === "toolCall");
7
10
  export function textOf(content) {
8
11
  if (typeof content === "string")
9
12
  return content;
@@ -11,6 +11,10 @@
11
11
  // path and validated by (size, mtime), and a file that grew is picked up at the
12
12
  // byte the last scan stopped on.
13
13
  //
14
+ // The same pass indexes what was said. Every user message and every reply
15
+ // goes into `session_fts` beside the index row, in the same transaction, so
16
+ // the palette can search messages without a second reader of the same bytes.
17
+ //
14
18
  // Reading Pi's on-disk entries is the cost of that; agent/ is where the
15
19
  // knowledge of Pi's formats already lives, and Pi's own reader is not
16
20
  // incremental.
@@ -20,7 +24,7 @@ import { pierDb, statements, transact } from "../db.js";
20
24
  import { SESSION_TITLE_MAX } from "../limits.js";
21
25
  import { logger } from "../log.js";
22
26
  import { defaultAgentDir } from "./config.js";
23
- import { textOf } from "./events.js";
27
+ import { hasToolCalls, textOf } from "./events.js";
24
28
  const log = logger("agent");
25
29
  /** How many of the newest sessions a cross-check reads: enough that a format
26
30
  * change surfaces within a boot or two, few enough to cost nothing. */
@@ -30,17 +34,35 @@ const AUDIT_SAMPLE = 5;
30
34
  * differently and an object would silently accept either. */
31
35
  const titleOf = (name, first) => name || first || undefined;
32
36
  const str = (v) => (typeof v === "string" ? v : undefined);
37
+ /** What a search may find: what the person said and what the agent answered.
38
+ * A reply that calls a tool is work in progress, not a reply (events.ts
39
+ * `toChatTurns`) — its text is a step, and steps are not indexed; neither is
40
+ * thinking, a tool result, or a system input. `clean` takes off what was
41
+ * written for the model rather than said — the speaker header a channel
42
+ * prefixes (core/identity.ts), which as text would make "operator" hit every
43
+ * session the workbench ever opened. */
44
+ function saidIn(message, entryAt, clean) {
45
+ if (message?.role !== "user" && message?.role !== "assistant")
46
+ return undefined;
47
+ if (message.role === "assistant" && hasToolCalls(message))
48
+ return undefined;
49
+ const text = clean(textOf(message.content)).trim();
50
+ if (!text)
51
+ return undefined;
52
+ const at = typeof message.timestamp === "number" ? message.timestamp : Date.parse(entryAt ?? "");
53
+ return { role: message.role, at: Number.isFinite(at) ? at : 0, text };
54
+ }
33
55
  /**
34
56
  * One entry folded into what a listing needs. Three answers, not two:
35
57
  * `undefined` is "no header yet" (blank and unparseable lines before it are
36
58
  * skipped, as Pi's own reader does), `null` is "not a session file" — a first
37
59
  * entry that is not a session header — and anything else is the state so far.
38
60
  */
39
- function fold(acc, line) {
40
- // The bulk of a transcript is message entries, each longer than this file.
41
- // Once the first user message is in hand only a rename still matters, and a
42
- // substring test costs a fraction of parsing one of them.
43
- if (acc?.first && !line.includes('"session_info"'))
61
+ function fold(acc, line, clean) {
62
+ // The bulk of a transcript is entries this has no use for — tool results,
63
+ // model changes, compaction — each longer than this file. Two substring tests
64
+ // say whether a line could matter, at a fraction of parsing one that does not.
65
+ if (acc && !line.includes('"message"') && !line.includes('"session_info"'))
44
66
  return acc;
45
67
  let entry;
46
68
  try {
@@ -58,26 +80,55 @@ function fold(acc, line) {
58
80
  const timestamp = str(entry.timestamp);
59
81
  if (entry.type !== "session" || !id || !timestamp)
60
82
  return null;
61
- return { id, cwd: str(entry.cwd) ?? "", created: Date.parse(timestamp), at: 0 };
83
+ return { id, cwd: str(entry.cwd) ?? "", created: Date.parse(timestamp), said: [], at: 0 };
62
84
  }
63
85
  if (entry?.type === "session_info")
64
86
  return { ...acc, name: str(entry.name)?.trim() || undefined };
65
- if (entry?.type === "message" && !acc.first) {
87
+ if (entry?.type === "message") {
66
88
  const message = entry.message;
67
- if (message?.role !== "user")
89
+ const said = saidIn(message, str(entry.timestamp), clean);
90
+ if (!said)
91
+ return acc;
92
+ acc.said.push(said);
93
+ // The title keeps the header the index drops: every surface reads it back
94
+ // through core/identity.ts, and a second rule here would be a second rule.
95
+ if (acc.first || said.role !== "user")
68
96
  return acc;
69
- const text = textOf(message.content).trim();
70
- return text ? { ...acc, first: text.slice(0, SESSION_TITLE_MAX) } : acc;
97
+ return { ...acc, first: textOf(message?.content).trim().slice(0, SESSION_TITLE_MAX) };
71
98
  }
72
99
  return acc;
73
100
  }
101
+ /** Hits a search answers with at most: a picker, not a results page. */
102
+ const SEARCH_LIMIT = 8;
103
+ /** Characters a snippet runs to. A trigram token is one character, so FTS's
104
+ * `snippet()` is asked for its ceiling of 64 tokens — the default 12 fits one
105
+ * word and its neighbours — and the LIKE path centres the same span itself. */
106
+ const SNIPPET_CHARS = 64;
107
+ /** The match and its neighbourhood, delimited like `snippet()` does it
108
+ * (\u0001 … \u0002, `…` for a cut), so a surface draws one shape for both
109
+ * paths. Case folds ASCII only — LIKE found the row by the same rule. */
110
+ function around(text, query) {
111
+ const hit = text.toLowerCase().indexOf(query.toLowerCase());
112
+ if (hit < 0)
113
+ return text.slice(0, SNIPPET_CHARS);
114
+ const start = Math.max(0, hit - SNIPPET_CHARS / 2);
115
+ const end = Math.min(text.length, hit + query.length + SNIPPET_CHARS / 2);
116
+ return `${start ? "…" : ""}${text.slice(start, hit)}\u0001${text.slice(hit, hit + query.length)}\u0002${text.slice(hit + query.length, end)}${end < text.length ? "…" : ""}`;
117
+ }
74
118
  /** The listing Pier runs on: Pi's session directory, remembered in pier.db. */
75
119
  export class IndexedListing {
76
120
  dir;
121
+ clean;
77
122
  #db;
78
123
  #statements;
79
- constructor(dir = join(defaultAgentDir(), "sessions"), db) {
124
+ constructor(dir = join(defaultAgentDir(), "sessions"), db,
125
+ /** What to take off a message before it is indexed as said. Handed in
126
+ * rather than imported: the header rule is core's (identity.ts), and
127
+ * agent/ does not import core at runtime — main.ts, which imports both,
128
+ * joins them. */
129
+ clean = (text) => text) {
80
130
  this.dir = dir;
131
+ this.clean = clean;
81
132
  this.#db = db;
82
133
  }
83
134
  /** Opened on the first scan rather than in the constructor: building an
@@ -114,13 +165,15 @@ export class IndexedListing {
114
165
  // length with a different mtime, which resuming would answer with the
115
166
  // old derived data stamped with the new mtime: a row that then matches on
116
167
  // every later scan and is never corrected.
117
- const parsed = await this.#read(file.path, row && file.size > row.size
168
+ const resumed = row !== undefined && file.size > row.size;
169
+ const parsed = await this.#read(file.path, resumed
118
170
  ? {
119
171
  id: row.id,
120
172
  cwd: row.cwd,
121
173
  created: row.created_at,
122
174
  ...(row.name === null ? {} : { name: row.name }),
123
175
  ...(row.first_message === null ? {} : { first: row.first_message }),
176
+ said: [],
124
177
  at: row.parsed_bytes,
125
178
  }
126
179
  : undefined);
@@ -136,6 +189,8 @@ export class IndexedListing {
136
189
  size: file.size,
137
190
  mtime: file.modified,
138
191
  parsed_bytes: parsed.at,
192
+ said: parsed.said,
193
+ whole: !resumed,
139
194
  });
140
195
  const title = titleOf(parsed.name, parsed.first);
141
196
  records.push({
@@ -181,6 +236,39 @@ export class IndexedListing {
181
236
  this.#save([], stale);
182
237
  return stale.length;
183
238
  }
239
+ /**
240
+ * Sessions by what was said in them, best hit first and one per session.
241
+ * Three characters or more is a trigram phrase, ranked by FTS; shorter is a
242
+ * substring scan, newest first — the tokenizer has nothing to match under
243
+ * three, and a two-character query is what a CJK word often is. Counted in
244
+ * code points: the tokenizer counts characters, not UTF-16 units.
245
+ */
246
+ search(query, limit = SEARCH_LIMIT) {
247
+ const sql = this.#sql();
248
+ const rows = [...query].length >= 3
249
+ // A quoted phrase: the query is a string to find, never FTS syntax.
250
+ ? sql(`SELECT session_id, role, at, snippet(session_fts, 0, char(1), char(2), '…', ${SNIPPET_CHARS}) AS snippet
251
+ FROM session_fts WHERE text MATCH ? ORDER BY bm25(session_fts), at DESC`).iterate(`"${query.replaceAll('"', '""')}"`)
252
+ : sql(`SELECT session_id, role, at, text FROM session_fts WHERE text LIKE ? ESCAPE '\\' ORDER BY at DESC`).iterate(`%${query.replaceAll(/[\\%_]/g, "\\$&")}%`);
253
+ const hits = [];
254
+ const seen = new Set();
255
+ // Walked, not fetched: a chatty session has hundreds of rows for one hit,
256
+ // and the walk stops at the first row of the `limit`th session.
257
+ for (const row of rows) {
258
+ if (seen.has(row.session_id))
259
+ continue;
260
+ seen.add(row.session_id);
261
+ hits.push({
262
+ sessionId: row.session_id,
263
+ role: row.role,
264
+ at: row.at,
265
+ snippet: row.snippet ?? around(row.text ?? "", query),
266
+ });
267
+ if (hits.length >= limit)
268
+ break;
269
+ }
270
+ return hits;
271
+ }
184
272
  /** Every session file with its size and mtime — the only syscalls a scan
185
273
  * where nothing changed makes. */
186
274
  async #files() {
@@ -211,7 +299,7 @@ export class IndexedListing {
211
299
  const line = buffer.slice(0, nl);
212
300
  buffer = buffer.slice(nl + 1);
213
301
  at += Buffer.byteLength(line) + 1;
214
- const next = fold(acc, line);
302
+ const next = fold(acc, line, this.clean);
215
303
  if (next === null)
216
304
  return null;
217
305
  acc = next;
@@ -227,12 +315,16 @@ export class IndexedListing {
227
315
  return acc ? { ...acc, at } : null;
228
316
  }
229
317
  /** One transaction, after all reading: a half-written index would hand the
230
- * next scan a size it never parsed to. */
318
+ * next scan a size it never parsed to — or a search rows the index row does
319
+ * not account for. A file read whole drops what it had said before, since
320
+ * its bytes are all being said again; a resumed read only adds. */
231
321
  #save(rows, gone) {
232
322
  if (!rows.length && !gone.length)
233
323
  return;
234
324
  const db = this.#store();
235
325
  const sql = this.#sql();
326
+ const forget = sql("DELETE FROM session_fts WHERE path = ?");
327
+ const say = sql("INSERT INTO session_fts(text, session_id, path, role, at) VALUES (?, ?, ?, ?, ?)");
236
328
  const upsert = sql(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
237
329
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
238
330
  ON CONFLICT(path) DO UPDATE SET
@@ -243,10 +335,16 @@ export class IndexedListing {
243
335
  const drop = sql("DELETE FROM session_index WHERE path = ?");
244
336
  transact(db, () => {
245
337
  for (const r of rows) {
338
+ if (r.whole)
339
+ forget.run(r.path);
340
+ for (const s of r.said)
341
+ say.run(s.text, r.id, r.path, s.role, s.at);
246
342
  upsert.run(r.path, r.id, r.cwd, r.created_at, r.name, r.first_message, r.size, r.mtime, r.parsed_bytes);
247
343
  }
248
- for (const path of gone)
344
+ for (const path of gone) {
249
345
  drop.run(path);
346
+ forget.run(path);
347
+ }
250
348
  });
251
349
  }
252
350
  }
package/dist/agent/pi.js CHANGED
@@ -1,20 +1,61 @@
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 "../limits.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");
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. */
28
+ const realPaths = new Map();
29
+ function realPath(cwd) {
30
+ const known = realPaths.get(cwd);
31
+ if (known !== undefined)
32
+ return known;
33
+ let real = cwd;
34
+ const missing = [];
35
+ for (let head = cwd;;) {
36
+ try {
37
+ real = join(realpathSync(head), ...missing);
38
+ break;
39
+ }
40
+ catch (err) {
41
+ const parent = dirname(head);
42
+ if (parent === head) {
43
+ log.debug(`cwd ${cwd} has no resolvable ancestor; using it as recorded`, err);
44
+ break;
45
+ }
46
+ missing.unshift(basename(head));
47
+ head = parent;
48
+ }
49
+ }
50
+ realPaths.set(cwd, real);
51
+ return real;
52
+ }
12
53
  /** A listed record as the seam reports it. The one mapping, because `list` and
13
54
  * `find` answer with the same shape and drifting would mean two answers about
14
55
  * one session. */
15
56
  const summaryOf = (s) => ({
16
57
  id: s.id,
17
- cwd: s.cwd,
58
+ cwd: realPath(s.cwd),
18
59
  createdAt: s.created,
19
60
  modified: s.modified,
20
61
  ...(s.title ? { title: s.title } : {}),
@@ -36,6 +77,36 @@ const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
36
77
  const PROVIDER_CHECK_MAX_TOKENS = 8192;
37
78
  /** Neither half of a probe is worth more than a screen. */
38
79
  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. */
84
+ const TITLE_INPUT_CHARS = 600;
85
+ const TITLE_MAX_TOKENS = 40;
86
+ const TITLE_TIMEOUT_MS = 20_000;
87
+ const TITLE_PROMPT = "Name this conversation for a session list. Reply with the title only: at most 12 Chinese characters " +
88
+ "or 6 English words, in the language the user wrote in, no quotes, no trailing period. " +
89
+ "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. */
93
+ 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. */
97
+ function textOfAnswer(answer) {
98
+ if (answer.stopReason === "error" || answer.stopReason === "aborted") {
99
+ throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
100
+ }
101
+ return textOf(answer.content).trim();
102
+ }
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. */
106
+ export function titleFromAnswer(text) {
107
+ const line = text.trim().split("\n")[0]?.trim() ?? "";
108
+ return line.replace(/^["'“‘「]+|["'”’」.。]+$/g, "").trim().slice(0, SESSION_TITLE_MAX);
109
+ }
39
110
  /** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
40
111
  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
112
 
@@ -106,6 +177,7 @@ export class PiSession {
106
177
  pinned;
107
178
  wrote;
108
179
  retention;
180
+ suggestTitle;
109
181
  constructor(pi,
110
182
  /** Operator pins, read per call — the menu can change while we run. */
111
183
  pinned = () => [],
@@ -114,11 +186,16 @@ export class PiSession {
114
186
  * every surface would re-read the old title and keep it until some
115
187
  * unrelated event moved the list again. Same drop `create` does — a
116
188
  * callback only because the session is what knows it happened. */
117
- wrote = () => { }, retention = { value: "long" }) {
189
+ 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. */
193
+ suggestTitle = () => undefined) {
118
194
  this.pi = pi;
119
195
  this.pinned = pinned;
120
196
  this.wrote = wrote;
121
197
  this.retention = retention;
198
+ this.suggestTitle = suggestTitle;
122
199
  }
123
200
  /** Pi's dispose unhooks the one listener that persists and emits, so a turn
124
201
  * started after it runs for real — model call, tools and all — and lands
@@ -342,9 +419,47 @@ export class PiSession {
342
419
  }
343
420
  for (const payload of toSessionEvents(piEvent)) {
344
421
  fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
422
+ if (payload.type === "turn-end" && !payload.error)
423
+ this.autoTitle(payload.text, fn);
345
424
  }
346
425
  });
347
426
  }
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. */
431
+ 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). */
438
+ autoTitle(reply, fn) {
439
+ if (this.titleDecided)
440
+ return;
441
+ const suggest = this.suggestTitle();
442
+ if (!suggest)
443
+ return; // off — not decided: switched on later, the next turn still counts
444
+ this.titleDecided = true;
445
+ if (this.pi.sessionManager.getSessionName())
446
+ return;
447
+ const users = this.pi.messages.filter((m) => m.role === "user");
448
+ const first = users.length === 1 ? textOf(users[0]?.content) : "";
449
+ if (!first.trim())
450
+ return;
451
+ void suggest(first, reply).then((title) => {
452
+ // Named while we waited — by a person, whose word beats the model's.
453
+ if (this.disposed || this.pi.sessionManager.getSessionName())
454
+ return;
455
+ this.pi.sessionManager.appendSessionInfo(title);
456
+ this.wrote();
457
+ fn({ type: "renamed", title });
458
+ }, (err) => {
459
+ log.warn(`session ${this.pi.sessionId} could not be titled`, err);
460
+ fn({ type: "error", message: `session title: ${err instanceof Error ? err.message : String(err)} — the first message stays the title` });
461
+ });
462
+ }
348
463
  /** Meta of the just-finished turn; live path, so "now" is the completion. */
349
464
  lastTurnMeta() {
350
465
  const messages = this.pi.messages;
@@ -367,6 +482,7 @@ export class PiAgentFactory {
367
482
  providerConfig;
368
483
  pinned;
369
484
  enabledExtensions;
485
+ titleModel;
370
486
  listings;
371
487
  constructor(extraTools = [],
372
488
  /** Appended as a virtual context file, so Pi's own prompt stays intact.
@@ -388,6 +504,10 @@ export class PiAgentFactory {
388
504
  /** Which bundled extensions the Console has switched on. A getter for the
389
505
  * same reason again: the toggle takes effect on the next session open. */
390
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,
391
511
  /** What exists on disk. Injected so a test can hand this factory a listing
392
512
  * instead of a session directory and a database. */
393
513
  listings = new IndexedListing()) {
@@ -398,6 +518,7 @@ export class PiAgentFactory {
398
518
  this.providerConfig = providerConfig;
399
519
  this.pinned = pinned;
400
520
  this.enabledExtensions = enabledExtensions;
521
+ this.titleModel = titleModel;
401
522
  this.listings = listings;
402
523
  }
403
524
  /** One runtime for the whole process; catalogs are global, not per session. */
@@ -516,17 +637,7 @@ export class PiAgentFactory {
516
637
  if (!model)
517
638
  throw new Error(`unknown model: ${providerId}/${modelId}`);
518
639
  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();
640
+ const text = textOfAnswer(answer);
530
641
  // An empty answer is still an answer; say which kind of nothing it was.
531
642
  return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
532
643
  }
@@ -539,6 +650,24 @@ export class PiAgentFactory {
539
650
  : raw || error, false);
540
651
  }
541
652
  }
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
+ async suggestTitle(model, first, reply) {
656
+ const runtime = await this.refreshedRuntime();
657
+ const resolved = runtime.getModel(model.provider, model.id);
658
+ if (!resolved)
659
+ 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.
664
+ const reasoning = resolved.reasoning && resolved.api !== "anthropic-messages" ? "minimal" : undefined;
665
+ 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
+ const title = titleFromAnswer(textOfAnswer(answer));
667
+ if (!title)
668
+ throw new Error(`${model.provider}/${model.id} answered with no title`);
669
+ return title;
670
+ }
542
671
  async setup(input) {
543
672
  const builtins = await this.builtinIds();
544
673
  if (input.kind === "builtin" && !builtins.has(input.id)) {
@@ -700,7 +829,10 @@ export class PiAgentFactory {
700
829
  live.agent.followUpMode = "all";
701
830
  const session = new PiSession(live, this.pinned, () => {
702
831
  this.listing = undefined;
703
- }, retention);
832
+ }, retention, () => {
833
+ const model = this.titleModel();
834
+ return model && ((first, reply) => this.suggestTitle(model, first, reply));
835
+ });
704
836
  if (opts.model)
705
837
  await session.setModel(opts.model);
706
838
  if (opts.thinking)
@@ -710,7 +842,10 @@ export class PiAgentFactory {
710
842
  }
711
843
  async create(opts) {
712
844
  this.listing = undefined;
713
- return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
845
+ // Resolved before Pi records it, so the transcript, the session directory
846
+ // and every later listing all name the directory the same way.
847
+ const cwd = realPath(opts.cwd);
848
+ return this.open(cwd, SessionManager.create(cwd), { ...opts, cwd });
714
849
  }
715
850
  async resume(sessionId) {
716
851
  const known = this.located.get(sessionId);
@@ -778,4 +913,11 @@ export class PiAgentFactory {
778
913
  async list() {
779
914
  return (await this.listed()).map(summaryOf);
780
915
  }
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. */
919
+ async search(query) {
920
+ await this.listed();
921
+ return this.listings.search?.(query) ?? [];
922
+ }
781
923
  }
@@ -12,7 +12,7 @@ import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js"
12
12
  /** A form-submit button name: `cwdgo:<thread root>`. */
13
13
  export const CWD_SUBMIT_PREFIX = "cwdgo:";
14
14
  /** The form input's field name, the key `form_value` answers under. */
15
- export const CWD_FIELD = "cwd";
15
+ const CWD_FIELD = "cwd";
16
16
  export class LarkPanel extends ChatPanel {
17
17
  deps;
18
18
  platform = "lark";
@@ -12,7 +12,7 @@ import { basename, join } from "node:path";
12
12
  import { pierPath } from "../paths.js";
13
13
  import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
14
14
  /** Where every inbound file lives; the attachment route allowlists this root. */
15
- export const INBOX_DIR = pierPath("inbox");
15
+ const INBOX_DIR = pierPath("inbox");
16
16
  /**
17
17
  * Write one inbound file and return its absolute path. The timestamp-random
18
18
  * prefix keeps concurrent saves collision-free (`wx` turns the impossible
@@ -251,6 +251,10 @@ export class Router {
251
251
  state: payload.state,
252
252
  });
253
253
  }
254
+ // A title the session gave itself: every list reads the transcript, so
255
+ // the same re-list a rename route broadcasts.
256
+ if (payload.type === "renamed")
257
+ this.hub.emitWorkspace({ type: "sessions-changed" });
254
258
  // An error the session itself reported (a tool that threw, a model
255
259
  // refusal, a lost connection). Without this it lands only in the web
256
260
  // timeline and the IM side goes quiet for no visible reason.
@@ -11,13 +11,17 @@ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhig
11
11
  export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);
12
12
  // Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
13
13
  // non-Pi backend is bound to them by this seam.
14
- export const PROVIDER_APIS = [
14
+ const PROVIDER_APIS = [
15
15
  "openai-completions",
16
16
  "openai-responses",
17
17
  "anthropic-messages",
18
18
  "google-generative-ai",
19
19
  ];
20
20
  export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
21
+ /** How far a model's reasoning goes. Every reasoning model offers up to
22
+ * "high"; the two levels above it exist only for a model whose catalog entry
23
+ * says so, which is the one thing a Console-defined model could not say. */
24
+ export const MODEL_EFFORTS = ["high", "xhigh", "max"];
21
25
  /** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
22
26
  * write and web/ pre-checks them at its HTTP boundary, and neither may import
23
27
  * the other. Throws the message the surface shows. */
@@ -46,6 +50,14 @@ export function validateProviderSetup(input) {
46
50
  if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
47
51
  throw new Error("model ids must be non-empty, trimmed and unique");
48
52
  }
53
+ // An effort ceiling on a model that does not reason would be written into
54
+ // the catalog and never offered — a setting that lies about itself.
55
+ if (input.models.some((model) => model.effort !== undefined && !model.reasoning)) {
56
+ throw new Error("effort requires reasoning");
57
+ }
58
+ if (input.models.some((model) => model.effort !== undefined && !MODEL_EFFORTS.includes(model.effort))) {
59
+ throw new Error("unsupported model effort");
60
+ }
49
61
  }
50
62
  export function validateEndpoint(endpoint) {
51
63
  let url;