@timqi/pier 0.1.3 → 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 (136) hide show
  1. package/README.md +12 -7
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +13 -5
  4. package/dist/agent/listing.js +10 -2
  5. package/dist/agent/packages.js +4 -20
  6. package/dist/agent/pi.js +18 -86
  7. package/dist/channels/attach.js +2 -2
  8. package/dist/channels/commands.js +10 -8
  9. package/dist/channels/config.js +32 -24
  10. package/dist/channels/control.js +60 -14
  11. package/dist/channels/conversations.js +60 -13
  12. package/dist/channels/handoff.js +90 -0
  13. package/dist/channels/lark-api.js +7 -0
  14. package/dist/channels/lark-outbound.js +19 -0
  15. package/dist/channels/lark-panel.js +41 -26
  16. package/dist/channels/lark-render.js +2 -1
  17. package/dist/channels/lark.js +35 -26
  18. package/dist/channels/lines.js +1 -1
  19. package/dist/channels/panel.js +324 -115
  20. package/dist/channels/receipts.js +25 -12
  21. package/dist/channels/routes.js +21 -7
  22. package/dist/channels/runtime.js +21 -7
  23. package/dist/channels/slack-api.js +19 -10
  24. package/dist/channels/slack-cli.js +503 -0
  25. package/dist/channels/slack-directory.js +2 -2
  26. package/dist/channels/slack-outbound.js +11 -1
  27. package/dist/channels/slack-panel.js +81 -40
  28. package/dist/channels/slack-render.js +3 -1
  29. package/dist/channels/slack-thread.js +41 -0
  30. package/dist/channels/slack-transcript.js +107 -0
  31. package/dist/channels/slack.js +38 -31
  32. package/dist/channels/types.js +1 -3
  33. package/dist/cli.js +166 -7
  34. package/dist/core/identity.js +61 -17
  35. package/dist/core/reply.js +19 -15
  36. package/dist/core/router.js +23 -3
  37. package/dist/db.js +33 -0
  38. package/dist/main.js +61 -41
  39. package/dist/paths.js +3 -0
  40. package/dist/secrets.js +2 -1
  41. package/dist/settings.js +3 -12
  42. package/dist/socket.js +99 -0
  43. package/dist/tasks/agent.js +5 -6
  44. package/dist/tasks/callbacks.js +2 -2
  45. package/dist/tasks/cli.js +225 -0
  46. package/dist/tasks/definitions.js +3 -3
  47. package/dist/tasks/execution.js +1 -3
  48. package/dist/tasks/groups.js +2 -5
  49. package/dist/tasks/messages.js +18 -158
  50. package/dist/tasks/operations.js +335 -0
  51. package/dist/tasks/routes.js +1 -11
  52. package/dist/tasks/runs.js +5 -20
  53. package/dist/tasks/service.js +18 -43
  54. package/dist/tasks/store.js +15 -15
  55. package/dist/tools.js +18 -3
  56. package/dist/vault.js +107 -0
  57. package/dist/web/auth.js +2 -1
  58. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
  59. package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
  60. package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
  61. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
  62. package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
  63. package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
  64. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
  65. package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
  67. package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
  68. package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
  69. package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
  70. package/dist/web/public/assets/index-DLszkDUV.css +2 -0
  71. package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
  72. package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
  73. package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
  74. package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
  75. package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
  76. package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
  77. package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
  78. package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
  79. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
  80. package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
  81. package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
  82. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
  83. package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
  84. package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
  85. package/dist/web/public/index.html +2 -2
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/push.js +4 -10
  89. package/dist/web/vault.js +68 -0
  90. package/dist/{extensions/web → websearch}/artifacts.js +2 -2
  91. package/dist/websearch/cli.js +73 -0
  92. package/dist/websearch/run.js +273 -0
  93. package/docs/deploy.md +34 -11
  94. package/package.json +2 -3
  95. package/skills/pier-help/SKILL.md +32 -16
  96. package/skills/pier-slack/SKILL.md +52 -71
  97. package/skills/pier-tasks/SKILL.md +60 -148
  98. package/skills/pier-vault/SKILL.md +36 -0
  99. package/skills/pier-web/SKILL.md +50 -0
  100. package/dist/channels/slack-tool.js +0 -416
  101. package/dist/channels/telegram-api.js +0 -86
  102. package/dist/channels/telegram-panel.js +0 -97
  103. package/dist/channels/telegram-render.js +0 -69
  104. package/dist/channels/telegram.js +0 -421
  105. package/dist/extensions/index.js +0 -10
  106. package/dist/extensions/web/index.js +0 -9
  107. package/dist/extensions/web/tools.js +0 -265
  108. package/dist/tasks/tool.js +0 -416
  109. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  110. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  111. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  112. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  113. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  114. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  115. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  116. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  117. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  118. package/dist/web/public/assets/index-DVIt5Gio.css +0 -2
  119. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  120. package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
  121. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  122. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  123. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  124. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  125. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  126. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  129. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
  130. /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
  131. /package/dist/{extensions/web → websearch}/content.js +0 -0
  132. /package/dist/{extensions/web → websearch}/http.js +0 -0
  133. /package/dist/{extensions/web → websearch}/json.js +0 -0
  134. /package/dist/{extensions/web → websearch}/language.js +0 -0
  135. /package/dist/{extensions/web → websearch}/openai.js +0 -0
  136. /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
 
@@ -37,8 +37,8 @@ otherwise).
37
37
  **The first start generates a password and prints it once.** Lost it?
38
38
  `sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart. Open
39
39
  `http://localhost:3141`, sign in; **Console → Settings** configures Models,
40
- Agent, Channels, the public URL, password and master key; **New session** picks
41
- the directory the agent's shell runs in.
40
+ Agent, Channels, Vault, the public URL, password and master key; **New session**
41
+ picks the directory the agent's shell runs in.
42
42
 
43
43
  ## Configure Pi
44
44
 
@@ -62,12 +62,17 @@ 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
- `~/.pier/tools/bin`, first on every session's PATH.
70
+ `~/.pier/tools/bin`, first on every session's PATH beside the `pier` shim
71
+ Pier writes there at start.
72
+ - **Vault** — named secrets an agent's commands receive through `pier vault
73
+ run` without the value entering its context (`docs/design/07-vault.md`);
74
+ `pier slack` and `pier task` reach the running Pier the same way
75
+ (`docs/design/08-cli-socket.md`).
71
76
 
72
77
  On first credential access an existing `auth.json` is imported into the sealed
73
78
  store and renamed `auth.json.imported`; literal keys in `models.json` likewise
@@ -15,9 +15,15 @@ const PROJECT_FILES = ["AGENTS.md"];
15
15
  const READONLY_FILES = ["settings.json"];
16
16
  const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
17
17
  /** What a first boot writes (docs/deploy.md says why each key): the list the
18
- * Console installs into, and Pi's attribution headers off — a server is not a
19
- * person to survey. Every other key stays Pi's default by omission. */
20
- const SEED_SETTINGS = { packages: [], enableInstallTelemetry: false };
18
+ * Console installs into, Pi's attribution headers off — a server is not a
19
+ * person to survey — and a retry budget of minutes, because an unattended
20
+ * instance has nobody to re-ask after Pi's 14-second default gives up. Every
21
+ * other key stays Pi's default by omission. */
22
+ const SEED_SETTINGS = {
23
+ packages: [],
24
+ enableInstallTelemetry: false,
25
+ retry: { maxRetries: 5, baseDelayMs: 5000 },
26
+ };
21
27
  /** Pier owns the Pi runtime dir; main.ts exports it as PI_CODING_AGENT_DIR. */
22
28
  export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
23
29
  /** Stable mask: mapped back by field, without exposing key fragments. */
@@ -33,8 +33,7 @@ function systemOrigin(message) {
33
33
  }
34
34
  if (origin.kind === "task-message" &&
35
35
  typeof origin.messageId === "string" &&
36
- (origin.messageKind === "steer" || origin.messageKind === "follow_up" ||
37
- origin.messageKind === "progress" || origin.messageKind === "decision" || origin.messageKind === "reply"))
36
+ (origin.messageKind === "steer" || origin.messageKind === "follow_up"))
38
37
  return shape;
39
38
  return null;
40
39
  }
@@ -201,9 +200,14 @@ export function toSessionEvents(e) {
201
200
  return [];
202
201
  // Carried twice: on turn-end because it is how the turn ended (what a
203
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.
204
206
  const failure = final?.stopReason === "error"
205
207
  ? final.errorMessage || "unknown agent error"
206
- : undefined;
208
+ : final?.stopReason === "toolUse" && !hasToolCalls(final)
209
+ ? "the provider stopped on tool_use but sent no tool call"
210
+ : undefined;
207
211
  const out = [
208
212
  { type: "turn-end", text: hasToolCalls(final) ? "" : textOf(final?.content), ...(failure ? { error: failure } : {}) },
209
213
  ];
@@ -229,8 +233,12 @@ export function toSessionEvents(e) {
229
233
  // On the message, not its text: an attachment with no caption is still a turn.
230
234
  const out = [{ type: "turn-start" }];
231
235
  const text = textOf(m.content);
232
- if (text)
233
- 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
+ }
234
242
  return out;
235
243
  }
236
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,16 +6,12 @@ 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;
16
14
  const KINDS = { extensions: "extension", skills: "skill" };
17
- /** The state line for a skill whose tool this instance is not given (docs/design/03). */
18
- const FOLLOWS_TOOL = "follows Channels → agent tool";
19
15
  /** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook): a `local` file whose
20
16
  * switch is the rtk tool's, so a settings.json pattern would fight the tool. */
21
17
  const RTK_FILE = join("extensions", "rtk.ts");
@@ -154,22 +150,14 @@ export class PiPackageStore {
154
150
  }
155
151
  async #fillPier(pkg) {
156
152
  pkg.version = this.pier.version;
157
- const { extensions: on, skillsOff: off } = this.pier.settings.get();
158
- // Off is off: a stand-down recorded while it was on is not this switch's state.
159
- pkg.resources = BUNDLED.map(({ name }) => ({
160
- kind: "extension", name, path: `<inline:${name}>`, enabled: on.includes(name),
161
- state: on.includes(name) ? shadowedBuiltin(name) : null,
162
- }));
163
- const gone = new Set(this.pier.tools.filter((t) => t.skill && !(t.available?.() ?? true)).map((t) => t.skill));
153
+ const { skillsOff: off } = this.pier.settings.get();
154
+ pkg.resources = [];
164
155
  for (const dir of this.skillDirs) {
165
156
  for (const entry of (await fs.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory())) {
166
157
  const path = join(dir, entry.name, "SKILL.md");
167
158
  if (!existsSync(path))
168
159
  continue;
169
- pkg.resources.push({
170
- kind: "skill", name: entry.name, path, enabled: !off.includes(entry.name),
171
- state: gone.has(entry.name) ? FOLLOWS_TOOL : null,
172
- });
160
+ pkg.resources.push({ kind: "skill", name: entry.name, path, enabled: !off.includes(entry.name), state: null });
173
161
  }
174
162
  }
175
163
  }
@@ -271,11 +259,7 @@ export class PiPackageStore {
271
259
  if (resource.locked)
272
260
  throw new PackageError("refused", `${resource.name} is ${resource.state} — its switch is under Tools`);
273
261
  if (pkg.kind === "pier") {
274
- const { extensions, skillsOff } = this.pier.settings.get();
275
- if (kind === "skill")
276
- this.pier.settings.setSkillsOff(withName(skillsOff, resource.name, !enabled));
277
- else
278
- this.pier.settings.setExtensions(withName(extensions, resource.name, enabled));
262
+ this.pier.settings.setSkillsOff(withName(this.pier.settings.get().skillsOff, resource.name, !enabled));
279
263
  }
280
264
  else {
281
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, defineTool, 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,42 +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
- /** A skill's description is resident in every prompt; one pointing at a tool
144
- * this session was not given is a route the agent cannot take. */
145
- export const standDownUndocumented = (tools, skills) => {
146
- const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
147
- if (!gone.size)
148
- return skills;
149
- return skills.filter((skill) => !gone.has(skill.name));
150
- };
151
114
  export class PiSession {
152
115
  pi;
153
116
  pinned;
@@ -400,7 +363,6 @@ export class PiSession {
400
363
  }
401
364
  }
402
365
  export class PiAgentFactory {
403
- extraTools;
404
366
  instructions;
405
367
  skillPaths;
406
368
  credentials;
@@ -409,9 +371,9 @@ export class PiAgentFactory {
409
371
  pier;
410
372
  titleModel;
411
373
  listings;
412
- constructor(extraTools = [],
413
- /** Getters are read per session open, so a Console change reaches the next
414
- * session without a restart. Appended as a context file so the user's own
374
+ constructor(
375
+ /** Read per session open, so a Console change reaches the next session
376
+ * without a restart; appended as a context file so the user's own
415
377
  * instructions still win. */
416
378
  instructions = () => "",
417
379
  /** Loaded per session, never installed into the user's skill directories. */
@@ -419,11 +381,10 @@ export class PiAgentFactory {
419
381
  /** Optional only for bare test factories; an OAuth refresh persists here,
420
382
  * never to a plaintext auth.json. */
421
383
  credentials, providerConfig = new PiConfigStore(), pinned = () => [],
422
- /** The built-in `pier` package's switches: bundled extensions on, Pier's own skills off. */
423
- 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,
424
386
  /** Injected so a test needs no session directory or database. */
425
387
  listings = new IndexedListing()) {
426
- this.extraTools = extraTools;
427
388
  this.instructions = instructions;
428
389
  this.skillPaths = skillPaths;
429
390
  this.credentials = credentials;
@@ -616,8 +577,14 @@ export class PiAgentFactory {
616
577
  async logout(providerId) {
617
578
  await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
618
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
+ }
619
586
  async resourceLoader(cwd) {
620
- const { extensions, skillsOff } = this.pier();
587
+ const { skillsOff } = this.pier();
621
588
  const loader = new DefaultResourceLoader({
622
589
  cwd,
623
590
  agentDir: defaultAgentDir(),
@@ -628,13 +595,9 @@ export class PiAgentFactory {
628
595
  // same name is Pi's to switch (settings.json).
629
596
  skillsOverride: (base) => ({
630
597
  ...base,
631
- skills: standDownUndocumented(this.extraTools, base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep)))),
598
+ skills: base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep))),
632
599
  }),
633
- extensionFactories: [
634
- { name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
635
- ...inlineExtensions(extensions),
636
- ],
637
- extensionsOverride: standDownShadowed,
600
+ extensionFactories: [{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true }],
638
601
  agentsFilesOverride: (current) => {
639
602
  const content = this.instructions();
640
603
  return {
@@ -651,36 +614,6 @@ export class PiAgentFactory {
651
614
  return this.providerConfig.withWrite(() => this.openSnapshot(cwd, sessionManager, opts));
652
615
  }
653
616
  async openSnapshot(cwd, sessionManager, opts) {
654
- let live;
655
- // Per open: an unconfigured tool would cost context on every turn and answer nothing.
656
- const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
657
- const customTools = active.map((tool) => defineTool({
658
- name: tool.name,
659
- label: tool.label,
660
- description: tool.description,
661
- parameters: tool.parameters,
662
- execute: async (_id, params, signal) => {
663
- const caller = live?.sessionId ?? "unknown";
664
- log.debug(`tool ${tool.name} called by ${caller}`);
665
- try {
666
- return {
667
- content: [
668
- {
669
- type: "text",
670
- // Pretty-printing costs ~20% more tokens and buys the model nothing.
671
- text: JSON.stringify(await tool.execute(params, caller, signal)),
672
- },
673
- ],
674
- details: {},
675
- };
676
- }
677
- catch (err) {
678
- // Pi turns this into tool-result text: the right recovery, the wrong record.
679
- log.warn(`tool ${tool.name} failed for ${caller}`, err);
680
- throw err;
681
- }
682
- },
683
- }));
684
617
  // A locked store is a refusal with a reason here, not "provider not
685
618
  // configured" later. Before appendSessionInfo, so nothing is written.
686
619
  this.credentials?.assertUnlocked();
@@ -696,11 +629,10 @@ export class PiAgentFactory {
696
629
  const created = await createAgentSession({
697
630
  cwd,
698
631
  sessionManager,
699
- customTools,
700
632
  modelRuntime: runtime,
701
633
  resourceLoader: await this.resourceLoader(cwd),
702
634
  });
703
- live = created.session;
635
+ const live = created.session;
704
636
  // Pi defaults to one follow-up per turn boundary, so N queued messages cost
705
637
  // N turns. The agent's setter flips only the in-memory queue;
706
638
  // `session.setFollowUpMode` would persist it to Pi's settings.json.
@@ -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
  }
@@ -1,23 +1,30 @@
1
1
  // Channel config persistence and the permission gate every adapter shares: one
2
2
  // JSON document per platform, so a surface configuring one reads and writes one row.
3
3
  import { randomInt } from "node:crypto";
4
- import { pierDb } from "../db.js";
5
- import { isSealed } from "../secrets.js";
6
4
  import { defaultChannelConfig, } from "./types.js";
7
5
  const BIND_CODE_TTL_MS = 10 * 60_000;
8
6
  /** Six symbols out of 36 is 2 billion, but a caller who may retry forever only
9
7
  * needs the TTL; five wrong tries void the code instead. */
10
8
  const BIND_CODE_TRIES = 5;
11
9
  const BIND_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
10
+ /** Where each platform's credentials live in the vault — fixed names, so a
11
+ * skill and the Console agree without a setting. Migration 24 spells the same
12
+ * table in SQL. */
13
+ const CREDENTIAL_NAMES = {
14
+ slack: { token: "SLACK_TOKEN", appToken: "SLACK_APP_TOKEN" },
15
+ lark: { token: "LARK_APP_ID", appToken: "LARK_APP_SECRET" },
16
+ };
17
+ const credentials = (platform) => Object.entries(CREDENTIAL_NAMES[platform]);
12
18
  export class ChannelStore {
13
- secrets;
14
19
  db;
20
+ vault;
15
21
  cache = new Map();
16
- /** Without `secrets` (tests), tokens persist as given. A locked store throws
17
- * rather than serving a token it cannot read. */
18
- constructor(db = pierDb(), secrets) {
19
- this.secrets = secrets;
22
+ /** The row never holds a credential: `token`/`appToken` are filled from the
23
+ * vault on read and filed there on save. A locked vault throws rather than
24
+ * serving a token it cannot read. */
25
+ constructor(db, vault) {
20
26
  this.db = db;
27
+ this.vault = vault;
21
28
  }
22
29
  /** Private: handed out, a caller could mutate config without saving. */
23
30
  cached(platform) {
@@ -28,11 +35,8 @@ export class ChannelStore {
28
35
  const config = row
29
36
  ? { ...defaultChannelConfig(), ...JSON.parse(row.json) }
30
37
  : defaultChannelConfig();
31
- if (this.secrets) {
32
- for (const key of ["token", "appToken"]) {
33
- if (isSealed(config[key]))
34
- config[key] = this.secrets.decrypt(config[key]);
35
- }
38
+ for (const [key, name] of credentials(platform)) {
39
+ config[key] = this.vault.get(name) ?? "";
36
40
  }
37
41
  this.cache.set(platform, config);
38
42
  return config;
@@ -41,16 +45,21 @@ export class ChannelStore {
41
45
  return structuredClone(this.cached(platform));
42
46
  }
43
47
  save(platform, config) {
48
+ // The cache holds plaintext; the row holds neither key. Only a changed
49
+ // credential touches the vault, so its `updated` is the rotation, not the
50
+ // last chat discovered; an emptied field removes the row.
51
+ const before = this.cached(platform);
52
+ for (const [key, name] of credentials(platform)) {
53
+ if (config[key] === before[key])
54
+ continue;
55
+ if (config[key])
56
+ this.vault.seal(name, config[key]);
57
+ else
58
+ this.vault.remove(name);
59
+ }
44
60
  // Cloned, so the caller's object cannot reach into the cache later.
45
61
  this.cache.set(platform, structuredClone(config));
46
- // The cache holds plaintext; only the row is sealed.
47
- const stored = this.secrets ? structuredClone(config) : config;
48
- if (this.secrets) {
49
- for (const key of ["token", "appToken"]) {
50
- if (stored[key])
51
- stored[key] = this.secrets.encrypt(stored[key]);
52
- }
53
- }
62
+ const { token: _token, appToken: _appToken, ...stored } = config;
54
63
  this.db.prepare(`
55
64
  INSERT INTO channels(platform, json) VALUES (?, ?)
56
65
  ON CONFLICT(platform) DO UPDATE SET json = excluded.json
@@ -59,9 +68,9 @@ export class ChannelStore {
59
68
  chat(platform, chatId) {
60
69
  return this.get(platform).chats.find((c) => c.id === chatId);
61
70
  }
62
- /** Telegram has no "list my chats" API, so discovery is passive and happens
63
- * on every message: the unchanged case must cost no clone. A new chat copies
64
- * 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. */
65
74
  discoverChat(platform, chat) {
66
75
  const cached = this.cached(platform).chats.find((c) => c.id === chat.id);
67
76
  if (cached && cached.name === chat.name && cached.kind === chat.kind)
@@ -92,7 +101,6 @@ export class ChannelStore {
92
101
  enabled: true,
93
102
  requireMention: config.requireMention,
94
103
  requireBind: config.requireBind,
95
- topicMode: config.topicMode,
96
104
  cwd: config.cwd,
97
105
  model: config.model,
98
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
  }