@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.
- package/README.md +4 -4
- package/dist/agent/events.js +12 -3
- package/dist/agent/listing.js +10 -2
- package/dist/agent/packages.js +3 -13
- package/dist/agent/pi.js +13 -40
- package/dist/channels/attach.js +2 -2
- package/dist/channels/commands.js +10 -8
- package/dist/channels/config.js +4 -6
- package/dist/channels/control.js +60 -14
- package/dist/channels/conversations.js +60 -13
- package/dist/channels/handoff.js +90 -0
- package/dist/channels/lark-api.js +7 -0
- package/dist/channels/lark-outbound.js +19 -0
- package/dist/channels/lark-panel.js +41 -26
- package/dist/channels/lark-render.js +2 -1
- package/dist/channels/lark.js +35 -26
- package/dist/channels/lines.js +1 -1
- package/dist/channels/panel.js +324 -115
- package/dist/channels/receipts.js +25 -12
- package/dist/channels/routes.js +21 -4
- package/dist/channels/runtime.js +21 -7
- package/dist/channels/slack-outbound.js +10 -0
- package/dist/channels/slack-panel.js +81 -40
- package/dist/channels/slack-render.js +3 -1
- package/dist/channels/slack.js +29 -14
- package/dist/channels/types.js +1 -2
- package/dist/cli.js +13 -5
- package/dist/core/identity.js +47 -12
- package/dist/core/reply.js +4 -1
- package/dist/core/router.js +20 -3
- package/dist/db.js +6 -1
- package/dist/main.js +42 -9
- package/dist/settings.js +3 -12
- package/dist/socket.js +10 -6
- package/dist/tasks/cli.js +17 -7
- package/dist/tasks/operations.js +24 -12
- package/dist/tools.js +1 -3
- package/dist/web/public/assets/{activity-NLS8W9yl.js → activity-CrybM-E8.js} +1 -1
- package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
- package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
- package/dist/web/public/assets/{boards-DleegoiC.js → boards-Cw7_6J6L.js} +1 -1
- package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
- package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
- package/dist/web/public/assets/{explorer-vJvV1Sx0.js → explorer-DV066dUD.js} +1 -1
- package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
- package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css +2 -0
- package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
- package/dist/web/public/assets/{runs-C5FveZ75.js → runs-DDagTNaM.js} +1 -1
- package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
- package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CJJ2j5Ks.js → task-runs-BnMack9t.js} +1 -1
- package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
- package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
- package/dist/web/public/assets/{tasks-R2Pv0Abg.js → tasks-BkW7YShZ.js} +1 -1
- package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
- package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/push.js +4 -10
- package/dist/{extensions/web → websearch}/artifacts.js +2 -2
- package/dist/websearch/cli.js +73 -0
- package/dist/websearch/run.js +273 -0
- package/docs/deploy.md +3 -3
- package/package.json +2 -3
- package/skills/pier-help/SKILL.md +22 -10
- package/skills/pier-tasks/SKILL.md +17 -8
- package/skills/pier-web/SKILL.md +50 -0
- package/dist/channels/telegram-api.js +0 -86
- package/dist/channels/telegram-panel.js +0 -97
- package/dist/channels/telegram-render.js +0 -69
- package/dist/channels/telegram.js +0 -421
- package/dist/extensions/index.js +0 -10
- package/dist/extensions/web/index.js +0 -9
- package/dist/extensions/web/tools.js +0 -265
- package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
- package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
- package/dist/web/public/assets/index-Bs-gol9o.css +0 -2
- package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
- package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js +0 -85
- package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js +0 -5
- package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
- /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
- /package/dist/{extensions/web → websearch}/content.js +0 -0
- /package/dist/{extensions/web → websearch}/http.js +0 -0
- /package/dist/{extensions/web → websearch}/json.js +0 -0
- /package/dist/{extensions/web → websearch}/language.js +0 -0
- /package/dist/{extensions/web → websearch}/openai.js +0 -0
- /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
|
|
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:
|
|
66
|
-
(`web
|
|
67
|
-
|
|
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
|
package/dist/agent/events.js
CHANGED
|
@@ -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
|
-
:
|
|
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
|
|
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": {
|
package/dist/agent/listing.js
CHANGED
|
@@ -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()
|
|
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 ??
|
|
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)
|
package/dist/agent/packages.js
CHANGED
|
@@ -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 {
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
414
|
-
pier = () => ({
|
|
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 {
|
|
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 {
|
package/dist/channels/attach.js
CHANGED
|
@@ -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
|
-
/**
|
|
9
|
-
*
|
|
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:
|
|
2
|
-
//
|
|
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
|
|
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
|
}
|
package/dist/channels/config.js
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
73
|
-
* on every message: the unchanged case must cost no clone. A new
|
|
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,
|
package/dist/channels/control.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
49
|
+
pins: modelMenu,
|
|
38
50
|
async setModel(key, model) {
|
|
39
|
-
await
|
|
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
|
-
|
|
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,
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
54
|
-
const
|
|
55
|
-
|
|
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);
|