@timqi/pier 0.1.4 → 0.1.5

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 (111) hide show
  1. package/README.md +4 -4
  2. package/dist/agent/events.js +12 -3
  3. package/dist/agent/listing.js +10 -2
  4. package/dist/agent/packages.js +3 -13
  5. package/dist/agent/pi.js +13 -40
  6. package/dist/channels/attach.js +2 -2
  7. package/dist/channels/commands.js +10 -8
  8. package/dist/channels/config.js +4 -6
  9. package/dist/channels/control.js +60 -14
  10. package/dist/channels/conversations.js +60 -13
  11. package/dist/channels/handoff.js +90 -0
  12. package/dist/channels/lark-api.js +7 -0
  13. package/dist/channels/lark-outbound.js +19 -0
  14. package/dist/channels/lark-panel.js +41 -26
  15. package/dist/channels/lark-render.js +2 -1
  16. package/dist/channels/lark.js +35 -26
  17. package/dist/channels/lines.js +1 -1
  18. package/dist/channels/panel.js +324 -115
  19. package/dist/channels/receipts.js +25 -12
  20. package/dist/channels/routes.js +21 -4
  21. package/dist/channels/runtime.js +21 -7
  22. package/dist/channels/slack-outbound.js +10 -0
  23. package/dist/channels/slack-panel.js +81 -40
  24. package/dist/channels/slack-render.js +3 -1
  25. package/dist/channels/slack.js +29 -14
  26. package/dist/channels/types.js +1 -2
  27. package/dist/cli.js +13 -5
  28. package/dist/core/identity.js +47 -12
  29. package/dist/core/reply.js +4 -1
  30. package/dist/core/router.js +20 -3
  31. package/dist/db.js +6 -1
  32. package/dist/main.js +42 -9
  33. package/dist/settings.js +3 -12
  34. package/dist/socket.js +10 -6
  35. package/dist/tasks/cli.js +17 -7
  36. package/dist/tasks/operations.js +24 -12
  37. package/dist/tools.js +1 -3
  38. package/dist/web/public/assets/{activity-NLS8W9yl.js → activity-CrybM-E8.js} +1 -1
  39. package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
  40. package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
  41. package/dist/web/public/assets/{boards-DleegoiC.js → boards-Cw7_6J6L.js} +1 -1
  42. package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
  43. package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
  44. package/dist/web/public/assets/{explorer-vJvV1Sx0.js → explorer-DV066dUD.js} +1 -1
  45. package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
  46. package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
  47. package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
  48. package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
  49. package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
  50. package/dist/web/public/assets/index-DLszkDUV.css +2 -0
  51. package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
  52. package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
  53. package/dist/web/public/assets/{runs-C5FveZ75.js → runs-DDagTNaM.js} +1 -1
  54. package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
  55. package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
  56. package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
  57. package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
  58. package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
  59. package/dist/web/public/assets/{task-runs-CJJ2j5Ks.js → task-runs-BnMack9t.js} +1 -1
  60. package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
  61. package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
  62. package/dist/web/public/assets/{tasks-R2Pv0Abg.js → tasks-BkW7YShZ.js} +1 -1
  63. package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
  64. package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
  65. package/dist/web/public/index.html +2 -2
  66. package/dist/web/public/index.html.br +0 -0
  67. package/dist/web/public/index.html.gz +0 -0
  68. package/dist/web/push.js +4 -10
  69. package/dist/{extensions/web → websearch}/artifacts.js +2 -2
  70. package/dist/websearch/cli.js +73 -0
  71. package/dist/websearch/run.js +273 -0
  72. package/docs/deploy.md +3 -3
  73. package/package.json +2 -3
  74. package/skills/pier-help/SKILL.md +22 -10
  75. package/skills/pier-tasks/SKILL.md +17 -8
  76. package/skills/pier-web/SKILL.md +50 -0
  77. package/dist/channels/telegram-api.js +0 -86
  78. package/dist/channels/telegram-panel.js +0 -97
  79. package/dist/channels/telegram-render.js +0 -69
  80. package/dist/channels/telegram.js +0 -421
  81. package/dist/extensions/index.js +0 -10
  82. package/dist/extensions/web/index.js +0 -9
  83. package/dist/extensions/web/tools.js +0 -265
  84. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  85. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  86. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  87. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  88. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  89. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  90. package/dist/web/public/assets/index-Bs-gol9o.css +0 -2
  91. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  92. package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
  93. package/dist/web/public/assets/index-Cyp2DKKF.js +0 -85
  94. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  95. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  96. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  97. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  98. package/dist/web/public/assets/settings-nJHIBbAz.js +0 -5
  99. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  100. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  101. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  102. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  103. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  104. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  105. /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
  106. /package/dist/{extensions/web → websearch}/content.js +0 -0
  107. /package/dist/{extensions/web → websearch}/http.js +0 -0
  108. /package/dist/{extensions/web → websearch}/json.js +0 -0
  109. /package/dist/{extensions/web → websearch}/language.js +0 -0
  110. /package/dist/{extensions/web → websearch}/openai.js +0 -0
  111. /package/dist/{extensions/web → websearch}/provider.js +0 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A self-hosted workspace for coding agents. Pier puts a web workbench and your
4
4
  IM channels in front of [Pi](https://github.com/earendil-works/pi) sessions:
5
- talk to the same agent from a browser, Slack, Telegram or Lark, steer a running
5
+ talk to the same agent from a browser, Slack or Lark, steer a running
6
6
  turn, schedule tasks, watch every session, and publish a static page when
7
7
  something is worth showing.
8
8
 
@@ -62,9 +62,9 @@ Console → Settings:
62
62
  `models.json` structure, globally or per project scope (with that project's
63
63
  `.pi/skills` and `.pi/extensions` listed). Changes apply when a session next
64
64
  opens; saving recycles idle sessions; **Settings → Instance → Reload** does
65
- the same for files changed elsewhere. Also here: the bundled extensions
66
- (`web`: `web_search` on Anthropic or OpenAI, `web_fetch` on Anthropic; a
67
- copy of yours registering the same tool makes Pier's stand down) and the
65
+ the same for files changed elsewhere. Also here: Pier's own skills
66
+ (`pier web search|fetch` reaches the web through the provider's hosted
67
+ tools — Anthropic or OpenAI, no key of your own) and the
68
68
  managed CLI tools (`rtk`, `rg`, `fd`, `wt`, `jq`, or your own as a
69
69
  [ubix](https://github.com/timqi/ubix) block), installed into
70
70
  `~/.pier/tools/bin`, first on every session's PATH beside the `pier` shim
@@ -200,9 +200,14 @@ export function toSessionEvents(e) {
200
200
  return [];
201
201
  // Carried twice: on turn-end because it is how the turn ended (what a
202
202
  // task run settles on), and as the error event every chat surface reports.
203
+ // `toolUse` without a tool call is a provider protocol violation (the
204
+ // block was billed but never delivered); Pi ends the run on it, so it
205
+ // reaches the conversation as the failure it is, not as an empty turn.
203
206
  const failure = final?.stopReason === "error"
204
207
  ? final.errorMessage || "unknown agent error"
205
- : undefined;
208
+ : final?.stopReason === "toolUse" && !hasToolCalls(final)
209
+ ? "the provider stopped on tool_use but sent no tool call"
210
+ : undefined;
206
211
  const out = [
207
212
  { type: "turn-end", text: hasToolCalls(final) ? "" : textOf(final?.content), ...(failure ? { error: failure } : {}) },
208
213
  ];
@@ -228,8 +233,12 @@ export function toSessionEvents(e) {
228
233
  // On the message, not its text: an attachment with no caption is still a turn.
229
234
  const out = [{ type: "turn-start" }];
230
235
  const text = textOf(m.content);
231
- if (text)
232
- out.push(origin ? { type: "system-input", text, origin } : { type: "user-message", text });
236
+ if (text) {
237
+ out.push(origin
238
+ // `at` is what `turnMetaAt` will read back as this turn's start.
239
+ ? { type: "system-input", text, origin, at: m.timestamp }
240
+ : { type: "user-message", text });
241
+ }
233
242
  return out;
234
243
  }
235
244
  case "message_update": {
@@ -15,6 +15,13 @@ const log = logger("agent");
15
15
  const AUDIT_SAMPLE = 5;
16
16
  const titleOf = (name, first) => name || first || undefined;
17
17
  const str = (v) => (typeof v === "string" ? v : undefined);
18
+ /** The budget is the words, not the header: a 34-character Lark id would spend
19
+ * it all and leave a clip that no longer parses back as a header. */
20
+ function clipTitle(whole, clean) {
21
+ const body = clean(whole);
22
+ const header = whole.slice(0, whole.length - body.length);
23
+ return header + body.slice(0, SESSION_TITLE_MAX);
24
+ }
18
25
  /** A reply that calls a tool is a step, not a reply (events.ts), and steps are
19
26
  * not indexed. `clean` takes off the speaker header (core/identity.ts), which
20
27
  * as text would make "operator" hit every session. */
@@ -67,7 +74,7 @@ function fold(acc, line, clean) {
67
74
  // through core/identity.ts.
68
75
  if (acc.first || said.role !== "user")
69
76
  return acc;
70
- return { ...acc, first: textOf(message?.content).trim().slice(0, SESSION_TITLE_MAX) };
77
+ return { ...acc, first: clipTitle(textOf(message?.content).trim(), clean) };
71
78
  }
72
79
  return acc;
73
80
  }
@@ -201,7 +208,8 @@ export class IndexedListing {
201
208
  const want = info && {
202
209
  cwd: info.cwd,
203
210
  created: info.created.getTime(),
204
- title: info.name ?? info.firstMessage?.slice(0, SESSION_TITLE_MAX),
211
+ title: info.name ??
212
+ (info.firstMessage ? clipTitle(info.firstMessage.trim(), this.clean) : undefined),
205
213
  };
206
214
  if (want && want.cwd === seen.cwd && want.created === seen.created &&
207
215
  want.title === seen.title)
@@ -6,10 +6,8 @@ import { existsSync, promises as fs } from "node:fs";
6
6
  import { basename, dirname, join, relative, resolve } from "node:path";
7
7
  import { DefaultPackageManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
8
8
  import { PackageError, } from "../core/types.js";
9
- import { BUNDLED } from "../extensions/index.js";
10
9
  import { logger } from "../log.js";
11
10
  import { defaultAgentDir } from "./config.js";
12
- import { shadowedBuiltin } from "./pi.js";
13
11
  const log = logger("packages");
14
12
  /** Set by how long a released fix may sit unnoticed, like update.ts. */
15
13
  const CHECK_EVERY_MS = 24 * 60 * 60_000;
@@ -152,12 +150,8 @@ export class PiPackageStore {
152
150
  }
153
151
  async #fillPier(pkg) {
154
152
  pkg.version = this.pier.version;
155
- const { extensions: on, skillsOff: off } = this.pier.settings.get();
156
- // Off is off: a stand-down recorded while it was on is not this switch's state.
157
- pkg.resources = BUNDLED.map(({ name }) => ({
158
- kind: "extension", name, path: `<inline:${name}>`, enabled: on.includes(name),
159
- state: on.includes(name) ? shadowedBuiltin(name) : null,
160
- }));
153
+ const { skillsOff: off } = this.pier.settings.get();
154
+ pkg.resources = [];
161
155
  for (const dir of this.skillDirs) {
162
156
  for (const entry of (await fs.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory())) {
163
157
  const path = join(dir, entry.name, "SKILL.md");
@@ -265,11 +259,7 @@ export class PiPackageStore {
265
259
  if (resource.locked)
266
260
  throw new PackageError("refused", `${resource.name} is ${resource.state} — its switch is under Tools`);
267
261
  if (pkg.kind === "pier") {
268
- const { extensions, skillsOff } = this.pier.settings.get();
269
- if (kind === "skill")
270
- this.pier.settings.setSkillsOff(withName(skillsOff, resource.name, !enabled));
271
- else
272
- this.pier.settings.setExtensions(withName(extensions, resource.name, enabled));
262
+ this.pier.settings.setSkillsOff(withName(this.pier.settings.get().skillsOff, resource.name, !enabled));
273
263
  }
274
264
  else {
275
265
  const key = kind === "extension" ? "extensions" : "skills";
package/dist/agent/pi.js CHANGED
@@ -1,10 +1,9 @@
1
- // The only file outside src/extensions allowed to import @earendil-works/pi-*.
1
+ // One of the two files (with packages.ts) 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
4
  import { realpathSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
- import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
7
- import { inlineExtensions } from "../extensions/index.js";
6
+ import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
8
7
  import { SESSION_TITLE_MAX } from "../core/types.js";
9
8
  import { logger } from "../log.js";
10
9
  import { textOf, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
@@ -64,7 +63,7 @@ const TITLE_MAX_TOKENS = 40;
64
63
  const TITLE_TIMEOUT_MS = 20_000;
65
64
  const TITLE_PROMPT = "Name this conversation for a session list. Reply with the title only: at most 12 Chinese characters " +
66
65
  "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.";
66
+ "A leading `[name time]` or `[name<id> time]` on the user's message is a speaker header, not content.";
68
67
  /** The halves are labelled so a reply quoting an instruction back is not
69
68
  * mistaken for one. */
70
69
  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>`;
@@ -112,34 +111,6 @@ const bashTimeoutDefault = (pi) => {
112
111
  }
113
112
  });
114
113
  };
115
- /** What the last session open stood down, by bundled extension name: the
116
- * registry (agent/packages.ts) shows it as the row's state. Known only at
117
- * open, so a built-in no session has loaded since reads nothing. */
118
- const shadowed = new Map();
119
- export const shadowedBuiltin = (name) => shadowed.get(name) ?? null;
120
- /** Pi loads a bundled extension and its on-disk twin both, leaving two tools
121
- * of one name; the copy the user put there wins, and the journal says so (§5). */
122
- export const standDownShadowed = (base) => {
123
- const inline = (ext) => ext.path.startsWith("<inline:");
124
- const onDisk = new Map(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()].map((tool) => [tool, ext.path])));
125
- return {
126
- ...base,
127
- extensions: base.extensions.filter((ext) => {
128
- if (!inline(ext))
129
- return true;
130
- const name = ext.path.slice("<inline:".length, -1);
131
- const clash = [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
132
- if (!clash.length) {
133
- shadowed.delete(name);
134
- return true;
135
- }
136
- const from = onDisk.get(clash[0]);
137
- shadowed.set(name, `stood down — ${clash.join(", ")} from ${from}`);
138
- log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from ${from}`);
139
- return false;
140
- }),
141
- };
142
- };
143
114
  export class PiSession {
144
115
  pi;
145
116
  pinned;
@@ -410,8 +381,8 @@ export class PiAgentFactory {
410
381
  /** Optional only for bare test factories; an OAuth refresh persists here,
411
382
  * never to a plaintext auth.json. */
412
383
  credentials, providerConfig = new PiConfigStore(), pinned = () => [],
413
- /** The built-in `pier` package's switches: bundled extensions on, Pier's own skills off. */
414
- pier = () => ({ extensions: [], skillsOff: [] }), titleModel = () => undefined,
384
+ /** The built-in `pier` package's one switch list: Pier's own skills switched off. */
385
+ pier = () => ({ skillsOff: [] }), titleModel = () => undefined,
415
386
  /** Injected so a test needs no session directory or database. */
416
387
  listings = new IndexedListing()) {
417
388
  this.instructions = instructions;
@@ -606,8 +577,14 @@ export class PiAgentFactory {
606
577
  async logout(providerId) {
607
578
  await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
608
579
  }
580
+ /** Pi's registry over the shared runtime, so an OAuth refresh lands in the
581
+ * credential store like every other request's. */
582
+ async webContext(active) {
583
+ const modelRegistry = new ModelRegistry(await this.refreshedRuntime());
584
+ return { modelRegistry, model: active && modelRegistry.find(active.provider, active.id) };
585
+ }
609
586
  async resourceLoader(cwd) {
610
- const { extensions, skillsOff } = this.pier();
587
+ const { skillsOff } = this.pier();
611
588
  const loader = new DefaultResourceLoader({
612
589
  cwd,
613
590
  agentDir: defaultAgentDir(),
@@ -620,11 +597,7 @@ export class PiAgentFactory {
620
597
  ...base,
621
598
  skills: base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep))),
622
599
  }),
623
- extensionFactories: [
624
- { name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
625
- ...inlineExtensions(extensions),
626
- ],
627
- extensionsOverride: standDownShadowed,
600
+ extensionFactories: [{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true }],
628
601
  agentsFilesOverride: (current) => {
629
602
  const content = this.instructions();
630
603
  return {
@@ -5,8 +5,8 @@
5
5
  import { readFile, stat } from "node:fs/promises";
6
6
  import { basename, extname } from "node:path";
7
7
  import { lostMarker, replaceOutsideCode } from "../core/inbound-file.js";
8
- /** Telegram refuses a photo past 10 MB, the smallest of the three; one cap so
9
- * a turn does not land on one chat and not another. */
8
+ /** Lark refuses an image past 10 MB, the smaller of the two; one cap so a
9
+ * turn does not land on one chat and not another. */
10
10
  export const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
11
11
  const MAX_ATTACHMENTS = 5;
12
12
  /** svg excluded deliberately: it is markup, and renders as a file. */
@@ -1,17 +1,19 @@
1
- // Slash commands in IM text, parsed once for every platform: Telegram appends
2
- // `@botname` when several bots share a group, clients add whitespace, users capitalise.
1
+ // Slash commands in IM text, parsed once for every platform: clients add
2
+ // whitespace and capitalise.
3
+ /** The configure-first trigger, one spelling on both platforms: `s <text>`
4
+ * (Lark also `/s <text>`), the text being the panel's pending question. A bare
5
+ * `s` carries no question and is prose. */
6
+ export function settingsDraft(text) {
7
+ return /^\/?s[ \t]+(\S[\s\S]*)$/i.exec(text.trim())?.[1];
8
+ }
3
9
  export function parseCommand(text) {
4
10
  const trimmed = text.trim();
5
11
  if (!trimmed.startsWith("/"))
6
12
  return null;
7
13
  // Args verbatim: a path or a sentence must keep its own spacing.
8
14
  const match = /^\/(\S+)[ \t]*([\s\S]*)$/.exec(trimmed);
9
- const [name = "", target] = (match?.[1] ?? "").split("@");
15
+ const name = match?.[1] ?? "";
10
16
  if (!name)
11
17
  return null; // a bare "/" is not a command
12
- return {
13
- name: name.toLowerCase(),
14
- args: match?.[2] ?? "",
15
- ...(target ? { target } : {}),
16
- };
18
+ return { name: name.toLowerCase(), args: match?.[2] ?? "" };
17
19
  }
@@ -9,9 +9,8 @@ const BIND_CODE_TRIES = 5;
9
9
  const BIND_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
10
10
  /** Where each platform's credentials live in the vault — fixed names, so a
11
11
  * skill and the Console agree without a setting. Migration 24 spells the same
12
- * table in SQL. Telegram authenticates with one token. */
12
+ * table in SQL. */
13
13
  const CREDENTIAL_NAMES = {
14
- telegram: { token: "TELEGRAM_TOKEN" },
15
14
  slack: { token: "SLACK_TOKEN", appToken: "SLACK_APP_TOKEN" },
16
15
  lark: { token: "LARK_APP_ID", appToken: "LARK_APP_SECRET" },
17
16
  };
@@ -69,9 +68,9 @@ export class ChannelStore {
69
68
  chat(platform, chatId) {
70
69
  return this.get(platform).chats.find((c) => c.id === chatId);
71
70
  }
72
- /** Telegram has no "list my chats" API, so discovery is passive and happens
73
- * on every message: the unchanged case must cost no clone. A new chat copies
74
- * the platform defaults and owns them from then on. */
71
+ /** No platform reliably lists every chat a bot is in, so discovery is passive
72
+ * and happens on every message: the unchanged case must cost no clone. A new
73
+ * chat copies the platform defaults and owns them from then on. */
75
74
  discoverChat(platform, chat) {
76
75
  const cached = this.cached(platform).chats.find((c) => c.id === chat.id);
77
76
  if (cached && cached.name === chat.name && cached.kind === chat.kind)
@@ -102,7 +101,6 @@ export class ChannelStore {
102
101
  enabled: true,
103
102
  requireMention: config.requireMention,
104
103
  requireBind: config.requireBind,
105
- topicMode: config.topicMode,
106
104
  cwd: config.cwd,
107
105
  model: config.model,
108
106
  thinking: config.thinking,
@@ -1,8 +1,11 @@
1
1
  // What an adapter may do to a session beyond handing it a prompt. Not part of
2
2
  // the `Channel` seam: the channel layer owns the router and hands adapters this
3
3
  // narrow interface instead. Thin wrappers over core; no policy.
4
+ import { projectCwds } from "../core/identity.js";
4
5
  import { chatOf, isChannelPlatform } from "./types.js";
5
- export function createControl({ router, factory, conversations, store }) {
6
+ export const NO_SESSION = "No session in this thread yet — start one first (Start in the panel).";
7
+ export const HAS_SESSION = "This thread already has a session — send your question as a message.";
8
+ export function createControl({ router, factory, conversations, store, modelMenu }) {
6
9
  const launchFor = (key) => {
7
10
  if (!isChannelPlatform(key.channelId))
8
11
  return {};
@@ -13,44 +16,87 @@ export function createControl({ router, factory, conversations, store }) {
13
16
  thinking: policy.thinking ?? undefined,
14
17
  };
15
18
  };
19
+ /** The thread's session, resumed if evicted; undefined when the thread has
20
+ * none. Never creates: a look at the panel must not open a session. */
21
+ const live = async (key) => {
22
+ if (!conversations.get(key))
23
+ return undefined;
24
+ return router.sessionOf(key) ?? router.ensure(key);
25
+ };
16
26
  return {
17
27
  launchFor,
18
28
  knows: (key) => conversations.get(key) !== undefined,
19
29
  abort: (key) => router.abortConversation(key),
30
+ working: (key) => router.sessionOf(key)?.state === "streaming",
20
31
  async status(key) {
21
- const session = router.sessionOf(key);
32
+ const session = await live(key);
22
33
  if (!session)
23
34
  return null;
24
35
  const summary = await factory.find(session.id);
25
36
  const usage = session.contextUsage;
26
37
  return {
27
38
  sessionId: session.id,
28
- cwd: summary?.cwd ?? "",
39
+ // Not on disk until its first reply; the launch record knows where it is.
40
+ cwd: summary?.cwd ?? conversations.launchOf(key)?.cwd ?? "",
29
41
  state: session.state,
42
+ empty: (await session.history()).length === 0,
30
43
  model: session.model,
31
44
  thinking: session.thinkingLevel,
32
- thinkingLevels: session.availableThinkingLevels(),
33
45
  tokens: usage?.tokens ?? null,
34
46
  contextWindow: usage?.contextWindow ?? null,
35
47
  };
36
48
  },
37
- models: () => factory.availableModels(),
49
+ pins: modelMenu,
38
50
  async setModel(key, model) {
39
- await router.sessionOf(key)?.setModel(model);
51
+ const session = await live(key);
52
+ if (!session)
53
+ throw new Error(NO_SESSION);
54
+ await session.setModel(model);
55
+ conversations.amendLaunch(key, { model });
40
56
  },
41
57
  async setThinking(key, level) {
42
- router.sessionOf(key)?.setThinkingLevel(level);
58
+ const session = await live(key);
59
+ if (!session)
60
+ throw new Error(NO_SESSION);
61
+ session.setThinkingLevel(level);
62
+ conversations.amendLaunch(key, { thinking: level });
43
63
  },
44
- async newSession(key, cwd) {
45
- const launch = launchFor(key);
46
- const session = await factory.create({
47
- ...launch,
48
- cwd: cwd || launch.cwd || process.cwd(),
49
- });
64
+ async newSession(key, over = {}) {
65
+ const defaults = launchFor(key);
66
+ const launch = { ...defaults, ...over, cwd: over.cwd || defaults.cwd || process.cwd() };
67
+ const session = await factory.create(launch);
68
+ // A message already inside the router's resolve can write the row while Pi
69
+ // was opening: the first row wins, and the session nobody routes to goes.
70
+ if (conversations.get(key)) {
71
+ await session.dispose();
72
+ throw new Error(HAS_SESSION);
73
+ }
50
74
  // Persist before attaching: a crash between must not leave an unrecorded session.
51
- conversations.set(key, session.id);
75
+ conversations.set(key, session.id, launch);
52
76
  router.attach(key, session);
53
77
  return session.id;
54
78
  },
79
+ async recentDirs(key, limit = 6) {
80
+ const own = launchFor(key).cwd;
81
+ // The web's rule (worktrees folded into their project); task runs' directories count too.
82
+ const seen = new Set([...(own ? [own] : []), ...projectCwds(await factory.list())]);
83
+ return [...seen].slice(0, limit);
84
+ },
85
+ async recent(key, exchanges) {
86
+ const session = await live(key);
87
+ if (!session)
88
+ return [];
89
+ const pairs = [];
90
+ for (const turn of await session.history()) {
91
+ const last = pairs.at(-1);
92
+ if (turn.role === "user")
93
+ pairs.push({ user: turn.text });
94
+ // The first reply after a user turn; later assistant turns of the same
95
+ // exchange are continuations of the same answer.
96
+ else if (turn.role === "assistant" && last && last.assistant === undefined)
97
+ last.assistant = turn.text;
98
+ }
99
+ return pairs.slice(-exchanges);
100
+ },
55
101
  };
56
102
  }
@@ -13,21 +13,44 @@ export class ConversationStore {
13
13
  `).get(key.channelId, key.conversationId);
14
14
  return row?.session_id;
15
15
  }
16
- set(key, sessionId) {
16
+ /** `launch`: what the session was created with, for a re-create when Pi has
17
+ * no transcript (a session never prompted was never written); omitted for
18
+ * one launched from the chat defaults, which a re-create reads again. */
19
+ set(key, sessionId, launch) {
17
20
  this.db.prepare(`
18
- INSERT INTO conversations(channel_id, conversation_id, session_id, updated_at)
19
- VALUES (?, ?, ?, ?)
21
+ INSERT INTO conversations(channel_id, conversation_id, session_id, updated_at, launch)
22
+ VALUES (?, ?, ?, ?, ?)
20
23
  ON CONFLICT(channel_id, conversation_id) DO UPDATE SET
21
- session_id = excluded.session_id, updated_at = excluded.updated_at
22
- `).run(key.channelId, key.conversationId, sessionId, Date.now());
24
+ session_id = excluded.session_id, updated_at = excluded.updated_at, launch = excluded.launch
25
+ `).run(key.channelId, key.conversationId, sessionId, Date.now(), launch ? JSON.stringify(launch) : null);
26
+ }
27
+ launchOf(key) {
28
+ const row = this.db.prepare(`
29
+ SELECT launch FROM conversations WHERE channel_id = ? AND conversation_id = ?
30
+ `).get(key.channelId, key.conversationId);
31
+ return row?.launch ? JSON.parse(row.launch) : undefined;
32
+ }
33
+ /** No record: nothing to amend. */
34
+ amendLaunch(key, patch) {
35
+ const launch = this.launchOf(key);
36
+ if (!launch)
37
+ return;
38
+ this.db.prepare(`
39
+ UPDATE conversations SET launch = ? WHERE channel_id = ? AND conversation_id = ?
40
+ `).run(JSON.stringify({ ...launch, ...patch }), key.channelId, key.conversationId);
23
41
  }
24
42
  /** Durable, unlike the router's answer, which is gone once an idle session
25
43
  * is evicted. No row: nobody's conversation. */
26
- channelOf(sessionId) {
44
+ keyOf(sessionId) {
27
45
  const row = this.db.prepare(`
28
- SELECT channel_id FROM conversations WHERE session_id = ? LIMIT 1
46
+ SELECT channel_id, conversation_id FROM conversations WHERE session_id = ? LIMIT 1
29
47
  `).get(sessionId);
30
- return row?.channel_id;
48
+ return row && { channelId: row.channel_id, conversationId: row.conversation_id };
49
+ }
50
+ /** Every session some IM conversation answers for, in one query. */
51
+ boundSessions() {
52
+ const rows = this.db.prepare(`SELECT session_id FROM conversations`).all();
53
+ return new Set(rows.map((r) => r.session_id));
31
54
  }
32
55
  forget(key) {
33
56
  this.db.prepare(`
@@ -35,24 +58,48 @@ export class ConversationStore {
35
58
  `).run(key.channelId, key.conversationId);
36
59
  }
37
60
  }
61
+ /** The backend's "no such session" (`agent/pi.ts`), also read in `web/server.ts`. */
62
+ const UNKNOWN_SESSION = "unknown session";
63
+ /** A note reaches the chat through `channels.notify`, which does not cut for
64
+ * itself the way `Router.report` does. */
65
+ const ERROR_CHARS = 200;
38
66
  /** The IM half of the router's session factory, wired in main.ts so neither
39
67
  * core nor an adapter learns where the mapping lives. */
40
- export function resolveConversation(store, factory, launchFor, onStale) {
68
+ export function resolveConversation(store, factory, launchFor,
69
+ /** The thread is told, not only the log: its next answer comes from a
70
+ * session that remembers nothing. */
71
+ onStale) {
41
72
  return async (key) => {
42
73
  const known = store.get(key);
74
+ let stale;
75
+ let recorded;
43
76
  if (known) {
44
77
  try {
45
78
  return await factory.resume(known);
46
79
  }
47
80
  catch (err) {
48
81
  // Never persisted, or deleted: re-route rather than fail every message.
49
- onStale?.(`${key.channelId}:${key.conversationId} lost session ${known}: ${String(err)}`);
82
+ // main.ts notifies the chat directly, so the cause is cut here.
83
+ stale = String(err).slice(0, ERROR_CHARS);
84
+ recorded = store.launchOf(key);
50
85
  store.forget(key);
51
86
  }
52
87
  }
53
- const launch = launchFor(key);
54
- const session = await factory.create({ ...launch, cwd: launch.cwd ?? process.cwd() });
55
- store.set(key, session.id);
88
+ const defaults = launchFor(key);
89
+ const launch = recorded ?? { ...defaults, cwd: defaults.cwd ?? process.cwd() };
90
+ const session = await factory.create(launch);
91
+ store.set(key, session.id, recorded);
92
+ if (stale) {
93
+ const was = known.slice(0, 8);
94
+ const now = session.id.slice(0, 8);
95
+ // Pi writes nothing before the first reply, so a recorded session the
96
+ // backend never knew lost no message: that is not data loss to report.
97
+ onStale?.(key, recorded && stale.includes(UNKNOWN_SESSION)
98
+ ? `Session ${was} had no messages yet — continuing as ${now}.`
99
+ : recorded
100
+ ? `Session ${was} is gone from disk (${stale}); re-created as ${now} with its own settings in ${launch.cwd}.`
101
+ : `Session ${was} is gone from disk (${stale}); this thread continues in a new session with the chat defaults in ${launch.cwd}.`);
102
+ }
56
103
  return session;
57
104
  };
58
105
  }
@@ -0,0 +1,90 @@
1
+ // Web ↔ IM handoff: the only code that binds an existing session to a thread it
2
+ // did not create — pushed from the web (a new thread, the only root message
3
+ // Pier ever posts; post before row, since a row for a thread that does not
4
+ // exist is worse than a message with no row) or pulled from a thread's panel.
5
+ import { sessionLabel } from "../core/identity.js";
6
+ import { chatOf, isChannelPlatform, } from "./types.js";
7
+ export class HandoffError extends Error {
8
+ status;
9
+ constructor(status, message) {
10
+ super(message);
11
+ this.status = status;
12
+ }
13
+ }
14
+ const platformName = (platform) => platform[0].toUpperCase() + platform.slice(1);
15
+ export function createHandoff(deps) {
16
+ const { store, runtime, conversations, factory, router, hub, publicUrl, log } = deps;
17
+ /** Where a session already answers, for the refusal: the chat's name, else its id. */
18
+ const nameOf = (key) => {
19
+ const chatId = chatOf(key.conversationId);
20
+ return (isChannelPlatform(key.channelId) && store.chat(key.channelId, chatId)?.name) || chatId;
21
+ };
22
+ /** The guards both directions share: on disk, and nobody's yet. */
23
+ const claimable = async (sessionId) => {
24
+ const summary = await factory.find(sessionId);
25
+ if (!summary) {
26
+ throw new HandoffError(404, `Session ${sessionId.slice(0, 8)} has no transcript yet — send it one message first.`);
27
+ }
28
+ const bound = conversations.keyOf(sessionId);
29
+ if (bound)
30
+ throw new HandoffError(409, `Already answers in ${bound.channelId} · ${nameOf(bound)}.`);
31
+ return summary;
32
+ };
33
+ /** The one binding. Loaded on the web right now: the next turn answers in
34
+ * the thread without waiting for the thread to speak. Not loaded: the row
35
+ * is enough. */
36
+ const bind = (key, sessionId) => {
37
+ conversations.set(key, sessionId);
38
+ const live = router.sessionOf({ channelId: "web", conversationId: sessionId });
39
+ if (live)
40
+ router.attach(key, live);
41
+ hub.emitWorkspace({ type: "sessions-changed" });
42
+ log(`session ${sessionId} continued in ${key.channelId}:${key.conversationId}`);
43
+ };
44
+ return {
45
+ targets: () => runtime.running().flatMap((platform) => store.get(platform).chats
46
+ .filter((chat) => chat.enabled)
47
+ .map((chat) => ({ platform, chatId: chat.id, name: chat.name, kind: chat.kind }))),
48
+ async continueIn({ sessionId, platform, chatId }) {
49
+ if (!runtime.running().includes(platform)) {
50
+ throw new HandoffError(409, `${platformName(platform)} is not running — enable it in Settings → Channels.`);
51
+ }
52
+ const chat = store.chat(platform, chatId);
53
+ if (!chat?.enabled)
54
+ throw new HandoffError(chat ? 409 : 404, "That chat is not enabled for the bot.");
55
+ const summary = await claimable(sessionId);
56
+ const base = publicUrl();
57
+ const note = {
58
+ title: sessionLabel(summary),
59
+ url: base ? `${base}/#/session/${encodeURIComponent(sessionId)}` : "",
60
+ };
61
+ let conversationId;
62
+ try {
63
+ conversationId = await runtime.openThread(platform, chatId, note);
64
+ }
65
+ catch (err) {
66
+ throw new HandoffError(502, String(err));
67
+ }
68
+ bind({ channelId: platform, conversationId }, sessionId);
69
+ return { conversationId };
70
+ },
71
+ async unbound(limit) {
72
+ const bound = conversations.boundSessions();
73
+ const owned = deps.taskSessions();
74
+ const ranks = deps.workingSet();
75
+ const rank = (id) => ranks.get(id)?.rank ?? Infinity;
76
+ return (await factory.list())
77
+ .filter((s) => !bound.has(s.id) && !owned.has(s.id))
78
+ .sort((a, b) => rank(a.id) - rank(b.id) || b.createdAt - a.createdAt)
79
+ .slice(0, limit);
80
+ },
81
+ async continueHere(key, sessionId) {
82
+ // The panel hides the button once a row exists, but a stale panel or a
83
+ // message that raced the tap must not orphan the thread's session.
84
+ if (conversations.get(key))
85
+ throw new HandoffError(409, "This thread already has a session.");
86
+ await claimable(sessionId);
87
+ bind(key, sessionId);
88
+ },
89
+ };
90
+ }
@@ -112,6 +112,13 @@ export class LarkApi {
112
112
  });
113
113
  return { messageId: ok("message.reply", res).data?.message_id ?? "" };
114
114
  }
115
+ async createCard(chatId, card) {
116
+ const res = await this.client.im.v1.message.create({
117
+ params: { receive_id_type: "chat_id" },
118
+ data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
119
+ });
120
+ return { messageId: ok("message.create", res).data?.message_id ?? "" };
121
+ }
115
122
  /** Images take the image endpoint so they render inline; `stream` is Lark's
116
123
  * "type unknown". The SDK unwraps an upload response to its `data`, so a
117
124
  * failure arrives as a missing key, not a code. */
@@ -50,6 +50,25 @@ export class LarkOutbound {
50
50
  if (lost)
51
51
  await this.api.replyCard(root, card([markdown(lost)]));
52
52
  }
53
+ /** The root, then one card inside its topic: on the phone a topic has its
54
+ * own composer only once it has a reply. Answers the root's id. The handoff
55
+ * writes no row when this throws, so a root left behind would invite a reply
56
+ * into a thread nobody answers: it is taken back down. */
57
+ async open(chatId, note) {
58
+ const link = note.url ? `[Open on the web](${note.url})` : "(no public URL set — Settings → Instance)";
59
+ const { messageId: root } = await this.api.createCard(chatId, card([
60
+ markdown(`**Continued from web: ${note.title}**\n${link}\nReply in this thread to continue.`),
61
+ ]));
62
+ try {
63
+ await this.api.replyCard(root, card([markdown("Reply here to continue.")]));
64
+ }
65
+ catch (err) {
66
+ await this.api.deleteMessage(root)
67
+ .catch((e) => this.log(`orphan handoff root ${root} not deleted: ${String(e)}`));
68
+ throw err;
69
+ }
70
+ return root;
71
+ }
53
72
  /** Best-effort: a card sent before a restart keeps its row, logged. */
54
73
  async retire(messageId) {
55
74
  const known = this.sent.get(messageId);