@timqi/pier 0.0.28 → 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 (95) hide show
  1. package/dist/agent/config-sync.js +12 -1
  2. package/dist/agent/config.js +53 -23
  3. package/dist/agent/events.js +27 -4
  4. package/dist/agent/listing.js +114 -16
  5. package/dist/agent/pi.js +158 -16
  6. package/dist/channels/lark-panel.js +1 -1
  7. package/dist/core/inbox.js +1 -1
  8. package/dist/core/router.js +14 -0
  9. package/dist/core/types.js +13 -1
  10. package/dist/db.js +48 -0
  11. package/dist/drain.js +1 -1
  12. package/dist/extensions/web/content.js +2 -2
  13. package/dist/main.js +8 -1
  14. package/dist/secrets.js +1 -1
  15. package/dist/service.js +2 -2
  16. package/dist/settings.js +31 -10
  17. package/dist/tasks/agent.js +109 -73
  18. package/dist/tasks/callbacks.js +1 -3
  19. package/dist/tasks/command.js +27 -8
  20. package/dist/tasks/execution.js +6 -4
  21. package/dist/tasks/groups.js +35 -26
  22. package/dist/tasks/messages.js +33 -27
  23. package/dist/tasks/outbox.js +48 -21
  24. package/dist/tasks/routes.js +1 -4
  25. package/dist/tasks/runs.js +10 -4
  26. package/dist/tasks/service.js +24 -12
  27. package/dist/tasks/store.js +17 -3
  28. package/dist/tasks/tool.js +97 -25
  29. package/dist/tools.js +2 -2
  30. package/dist/update.js +1 -1
  31. package/dist/web/instance.js +10 -3
  32. package/dist/web/providers.js +9 -2
  33. package/dist/web/public/assets/{activity-DVamP8fs.js → activity-Bl3vZukb.js} +2 -2
  34. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  35. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  36. package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
  37. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  38. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  39. package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
  40. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  41. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  42. package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
  43. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  44. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  45. package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
  46. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  47. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  48. package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
  49. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  50. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  51. package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
  52. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  53. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  54. package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
  55. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  56. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  57. package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
  58. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  59. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
  60. package/dist/web/public/index.html +103 -134
  61. package/dist/web/public/index.html.br +0 -0
  62. package/dist/web/public/index.html.gz +0 -0
  63. package/dist/web/public/manifest.webmanifest +2 -2
  64. package/dist/web/public/manifest.webmanifest.br +0 -0
  65. package/dist/web/public/manifest.webmanifest.gz +0 -0
  66. package/dist/web/server.js +55 -36
  67. package/dist/web/session-state.js +54 -33
  68. package/package.json +2 -1
  69. package/skills/pier-tasks/SKILL.md +146 -127
  70. package/dist/web/public/assets/activity-DVamP8fs.js.br +0 -0
  71. package/dist/web/public/assets/activity-DVamP8fs.js.gz +0 -0
  72. package/dist/web/public/assets/boards-BKtjPwus.js +0 -1
  73. package/dist/web/public/assets/boards-BKtjPwus.js.br +0 -0
  74. package/dist/web/public/assets/boards-BKtjPwus.js.gz +0 -0
  75. package/dist/web/public/assets/explorer-CjO9NAKQ.js +0 -4
  76. package/dist/web/public/assets/explorer-CjO9NAKQ.js.br +0 -0
  77. package/dist/web/public/assets/explorer-CjO9NAKQ.js.gz +0 -0
  78. package/dist/web/public/assets/index-C23sXyS3.css +0 -2
  79. package/dist/web/public/assets/index-C23sXyS3.css.br +0 -0
  80. package/dist/web/public/assets/index-C23sXyS3.css.gz +0 -0
  81. package/dist/web/public/assets/index-DzWnvhXb.js +0 -85
  82. package/dist/web/public/assets/index-DzWnvhXb.js.br +0 -0
  83. package/dist/web/public/assets/index-DzWnvhXb.js.gz +0 -0
  84. package/dist/web/public/assets/runs-CxIZAx1s.js +0 -1
  85. package/dist/web/public/assets/runs-CxIZAx1s.js.br +0 -0
  86. package/dist/web/public/assets/runs-CxIZAx1s.js.gz +0 -0
  87. package/dist/web/public/assets/settings-B8JWLbpL.js +0 -5
  88. package/dist/web/public/assets/settings-B8JWLbpL.js.br +0 -0
  89. package/dist/web/public/assets/settings-B8JWLbpL.js.gz +0 -0
  90. package/dist/web/public/assets/task-runs-BZ1brkLC.js +0 -3
  91. package/dist/web/public/assets/task-runs-BZ1brkLC.js.br +0 -0
  92. package/dist/web/public/assets/task-runs-BZ1brkLC.js.gz +0 -0
  93. package/dist/web/public/assets/tasks-BoYiBQpq.js +0 -4
  94. package/dist/web/public/assets/tasks-BoYiBQpq.js.br +0 -0
  95. package/dist/web/public/assets/tasks-BoYiBQpq.js.gz +0 -0
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
@@ -78,6 +78,7 @@ export class Router {
78
78
  * the process exits when the drain ends — `endDrain` exists for the one
79
79
  * caller that drains *speculatively* and may not get to exit. */
80
80
  draining = false;
81
+ spokenTo;
81
82
  constructor(hub,
82
83
  /** Create or resume the session owning a conversation (wired in main.ts). */
83
84
  resolve) {
@@ -87,6 +88,14 @@ export class Router {
87
88
  registerChannel(channel) {
88
89
  this.channels.set(channel.id, channel);
89
90
  }
91
+ /** Told which session a human just spoke to — every message from a chat or
92
+ * the workbench passes through `dispatch`, and nothing a task or a subagent
93
+ * starts does. Registered rather than a constructor argument: the one
94
+ * listener is the rail's working set (web/session-state.ts), built with the
95
+ * web surface long after the router. */
96
+ onSpokenTo(listener) {
97
+ this.spokenTo = listener;
98
+ }
90
99
  /**
91
100
  * Tell the conversation something went wrong, then the event stream.
92
101
  *
@@ -242,6 +251,10 @@ export class Router {
242
251
  state: payload.state,
243
252
  });
244
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" });
245
258
  // An error the session itself reported (a tool that threw, a model
246
259
  // refusal, a lost connection). Without this it lands only in the web
247
260
  // timeline and the IM side goes quiet for no visible reason.
@@ -602,6 +615,7 @@ export class Router {
602
615
  // closed must not start the turn the drain just declared finished with.
603
616
  if (this.draining)
604
617
  this.refuseDraining(msg.key);
618
+ this.spokenTo?.(session.id);
605
619
  const { action, text } = decide(msg, session.state);
606
620
  // A group chat is many people talking into one session; without a speaker
607
621
  // line the agent cannot tell them apart or mention anyone back. Emitted
@@ -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;
package/dist/db.js CHANGED
@@ -331,6 +331,54 @@ const MIGRATIONS = [
331
331
  -- land in the pinned section. project_sort stays as a column nothing reads
332
332
  -- — a SQLite column drop rewrites the table for a NULL nobody pays for.
333
333
  UPDATE session_state SET pinned = 0;
334
+ `,
335
+ // 20 — the top of the rail is maintained, not arranged: no pin, no drag.
336
+ `
337
+ -- sort was the place a hand dragged a pinned row to; it is now the rank in
338
+ -- the working set the rail keeps on top, which a session enters by being
339
+ -- spoken to and leaves by being pushed out of the last slot. The pinned rows
340
+ -- are that set's first members — they are what somebody was working on — in
341
+ -- the order they were arranged in; never-dragged ones sorted first, so -1 is
342
+ -- the rank that keeps them there.
343
+ UPDATE session_state SET sort = -1 WHERE pinned = 1 AND sort IS NULL;
344
+ UPDATE session_state SET sort = NULL WHERE pinned = 0;
345
+ -- The set has a size (web/session-state.ts); more pins than that is a list
346
+ -- the promotion rule would never have built.
347
+ UPDATE session_state SET sort = NULL WHERE session_id IN (
348
+ SELECT session_id FROM session_state WHERE sort IS NOT NULL
349
+ ORDER BY sort, session_id LIMIT -1 OFFSET 8
350
+ );
351
+ ALTER TABLE session_state DROP COLUMN pinned;
352
+ `,
353
+ // 21 — unread is the workbench's own attention, so it is only its own rows.
354
+ `
355
+ -- The flag was written for every session whose turn ended, including the
356
+ -- ones no browser is the reader of: an IM session answers its chat, a run's
357
+ -- session answers its supervisor. Both are now skipped at the write
358
+ -- (web/server.ts), and neither could ever be acked — that needs the session
359
+ -- on screen — so the rows they left would stay set forever. On the instance
360
+ -- this was decided on, 195 of 196 marks were those. Cleared wholesale rather
361
+ -- than by owner: the one real row is a turn from before an upgrade nobody
362
+ -- was watching for, and a false amber dot costs less than the join.
363
+ UPDATE session_state SET unread = 0;
364
+ `,
365
+ // 22 — the palette finds what was said, not only what a session is called.
366
+ `
367
+ -- One row per user message and per assistant reply, keyed by the transcript
368
+ -- it came from so a file that is rewritten or gone drops its rows in one
369
+ -- statement (agent/listing.ts). Trigram tokens: a substring match with no
370
+ -- word segmentation, which CJK text has none of and a path or an identifier
371
+ -- in a prompt has too much of. Steps stay out — tool calls, their output,
372
+ -- thinking are the bulk of a transcript and nobody searches for what ran.
373
+ CREATE VIRTUAL TABLE session_fts USING fts5(
374
+ text, session_id UNINDEXED, path UNINDEXED, role UNINDEXED, at UNINDEXED,
375
+ tokenize = 'trigram'
376
+ );
377
+ -- Derived and disposable (migration 7): every row goes, so the next scan
378
+ -- reads every transcript from its first byte and fills the table above.
379
+ -- Resetting parsed_bytes alone would not — a row whose (size, mtime) still
380
+ -- match is trusted without opening the file.
381
+ DELETE FROM session_index;
334
382
  `,
335
383
  ];
336
384
  /**
package/dist/drain.js CHANGED
@@ -11,7 +11,7 @@ import { logger } from "./log.js";
11
11
  const log = logger("drain");
12
12
  /** How long running turns may take before they are aborted. Generous: a turn
13
13
  * can be a subagent fan-out, and an abort still persists the partial work. */
14
- export const DRAIN_DEADLINE_MS = 5 * 60_000;
14
+ const DRAIN_DEADLINE_MS = 5 * 60_000;
15
15
  const POLL_MS = 1_000;
16
16
  /** Shared cleanup window after the deadline. All sessions use the same clock,
17
17
  * so N hung seams still cost at most this long rather than N times as long. */
@@ -49,7 +49,7 @@ export function sourcesFrom(content) {
49
49
  return [...sources.values()].map(({ title, url }) => ({ title, url }));
50
50
  }
51
51
  /** Only what the search itself returned, in the order it ranked them. */
52
- export function searchResultsFrom(content) {
52
+ function searchResultsFrom(content) {
53
53
  const results = new Map();
54
54
  for (const block of content) {
55
55
  if (!isObject(block) || block.type !== "web_search_tool_result")
@@ -61,7 +61,7 @@ export function searchResultsFrom(content) {
61
61
  }
62
62
  return [...results.values()];
63
63
  }
64
- export function searchQueriesFrom(content) {
64
+ function searchQueriesFrom(content) {
65
65
  const queries = [];
66
66
  for (const block of content) {
67
67
  if (!isObject(block) || block.type !== "server_tool_use" || block.name !== "web_search") {
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ import { normalizeAgentSnapshot } from "./agent/config-sync.js";
8
8
  import { ConfigSync } from "./config-sync.js";
9
9
  import { configSyncTask } from "./config-sync-task.js";
10
10
  import { CredentialStore } from "./agent/credentials.js";
11
+ import { IndexedListing } from "./agent/listing.js";
11
12
  import { PiAgentFactory } from "./agent/pi.js";
12
13
  import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
13
14
  import { ChannelStore } from "./channels/config.js";
@@ -20,6 +21,7 @@ import { SlackDirectory } from "./channels/slack-directory.js";
20
21
  import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
21
22
  import { parseConversation as parseSlackConversation } from "./channels/slack.js";
22
23
  import { EventHub } from "./core/hub.js";
24
+ import { splitSpeaker } from "./core/identity.js";
23
25
  import { pierDb } from "./db.js";
24
26
  import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
25
27
  import { bundledInfo } from "./extensions/index.js";
@@ -135,7 +137,12 @@ new CredentialStore(db, secrets), piConfig,
135
137
  () => settings.get().modelMenu,
136
138
  // Bundled extensions the Console switched on; read per session open, so the
137
139
  // toggle reaches the next session the same way an edited agent file does.
138
- () => settings.get().extensions);
140
+ () => settings.get().extensions,
141
+ // The title model, if the operator picked one; read when a first turn ends.
142
+ () => settings.get().titleModel,
143
+ // The search index reads Pi's transcripts, which carry the speaker header
144
+ // core wrote for the model; this is where the two areas meet.
145
+ new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
139
146
  const hub = new EventHub();
140
147
  const router = new Router(hub, (key) => {
141
148
  // Web conversation ids ARE session ids; an IM conversation id is a chat or a
package/dist/secrets.js CHANGED
@@ -172,7 +172,7 @@ function open(key, sealed, aad) {
172
172
  }
173
173
  /** The real vt CLI. Absent binary or denied approval both surface as the
174
174
  * spawn/exit error — unlock() records it and the operator reads it. */
175
- export const vtCli = {
175
+ const vtCli = {
176
176
  read: (record) => run("vt", ["read", record]),
177
177
  create: async (plaintext) => {
178
178
  const out = await run("vt", ["create"], plaintext);
package/dist/service.js CHANGED
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync }
15
15
  import { homedir, userInfo } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
17
  export const UNIT_NAME = "pier.service";
18
- export const UPDATE_UNIT_NAME = "pier-update.service";
18
+ const UPDATE_UNIT_NAME = "pier-update.service";
19
19
  /** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
20
20
  export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
21
21
  /** The drop-in Pier writes once and never touches again: what the unit may
@@ -146,7 +146,7 @@ ExecStopPost=systemctl --user start ${UNIT_NAME}
146
146
  * exists to protect the OS and sshd outside it. Written commented so the
147
147
  * operator tuning it can see what each line buys.
148
148
  */
149
- export function renderLimits() {
149
+ function renderLimits() {
150
150
  return `[Service]
151
151
  # Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
152
152
  # instead of killing anything. This is the one that should bite first.
package/dist/settings.js CHANGED
@@ -50,27 +50,40 @@ export function normalizeModelMenu(raw) {
50
50
  return null;
51
51
  const menu = [];
52
52
  for (const item of raw) {
53
- if (typeof item !== "object" || item === null)
53
+ const ref = normalizeModelRef(item);
54
+ if (!ref)
54
55
  return null;
55
- const { provider, id, thinking, note } = item;
56
- if (typeof provider !== "string" || !provider.trim())
57
- return null;
58
- if (typeof id !== "string" || !id.trim())
59
- return null;
60
- if (thinking !== undefined && !isThinkingLevel(thinking))
56
+ const { thinking, note } = item;
57
+ // The one thing repaired rather than rejected, because it is not input:
58
+ // entries stored (or exported by an instance) before the level was
59
+ // required have none, and dropping the menu over it would lose the pins.
60
+ const level = thinking === undefined ? "medium" : thinking;
61
+ if (!isThinkingLevel(level))
61
62
  return null;
62
63
  if (note !== undefined && typeof note !== "string")
63
64
  return null;
64
65
  const cleaned = note?.trim().slice(0, 200);
65
66
  menu.push({
66
- provider: provider.trim(),
67
- id: id.trim(),
68
- ...(thinking !== undefined ? { thinking } : {}),
67
+ ...ref,
68
+ thinking: level,
69
69
  ...(cleaned ? { note: cleaned } : {}),
70
70
  });
71
71
  }
72
72
  return menu;
73
73
  }
74
+ /** One model reference, rejecting rather than repairing — the menu above is
75
+ * built from these. Existence is not checked here: the catalog is the
76
+ * agent's, and a model that went away is reported by the call that fails. */
77
+ export function normalizeModelRef(raw) {
78
+ if (typeof raw !== "object" || raw === null)
79
+ return null;
80
+ const { provider, id } = raw;
81
+ if (typeof provider !== "string" || !provider.trim())
82
+ return null;
83
+ if (typeof id !== "string" || !id.trim())
84
+ return null;
85
+ return { provider: provider.trim(), id: id.trim() };
86
+ }
74
87
  /**
75
88
  * Shape only — an unknown name is not an error here. This file must not know
76
89
  * what Pier bundles (that catalog is code, and importing it would drag the Pi
@@ -107,9 +120,11 @@ export class SettingsStore {
107
120
  this.#db = db;
108
121
  }
109
122
  get() {
123
+ const titleModel = this.#json("titleModel", normalizeModelRef, "a {provider, id}");
110
124
  return {
111
125
  publicUrl: this.#value("publicUrl") ?? "",
112
126
  modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
127
+ ...(titleModel ? { titleModel } : {}),
113
128
  autoUpdate: this.#value("autoUpdate") === "1",
114
129
  extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
115
130
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
@@ -152,6 +167,12 @@ export class SettingsStore {
152
167
  this.#set("modelMenu", JSON.stringify(menu));
153
168
  return this.get();
154
169
  }
170
+ /** Same contract: hand this `normalizeModelRef`'s output; null switches
171
+ * auto-titling off. */
172
+ setTitleModel(ref) {
173
+ this.#set("titleModel", ref ? JSON.stringify(ref) : "");
174
+ return this.get();
175
+ }
155
176
  setAutoUpdate(on) {
156
177
  this.#set("autoUpdate", on ? "1" : "0");
157
178
  return this.get();