@timqi/pier 0.0.28 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/config-sync.js +12 -1
- package/dist/agent/config.js +53 -23
- package/dist/agent/events.js +27 -4
- package/dist/agent/listing.js +114 -16
- package/dist/agent/pi.js +158 -16
- package/dist/channels/lark-panel.js +1 -1
- package/dist/core/inbox.js +1 -1
- package/dist/core/router.js +14 -0
- package/dist/core/types.js +13 -1
- package/dist/db.js +48 -0
- package/dist/drain.js +1 -1
- package/dist/extensions/web/content.js +2 -2
- package/dist/main.js +8 -1
- package/dist/secrets.js +1 -1
- package/dist/service.js +2 -2
- package/dist/settings.js +31 -10
- package/dist/tasks/agent.js +109 -73
- package/dist/tasks/callbacks.js +1 -3
- package/dist/tasks/command.js +27 -8
- package/dist/tasks/execution.js +6 -4
- package/dist/tasks/groups.js +35 -26
- package/dist/tasks/messages.js +33 -27
- package/dist/tasks/outbox.js +48 -21
- package/dist/tasks/routes.js +1 -4
- package/dist/tasks/runs.js +10 -4
- package/dist/tasks/service.js +24 -12
- package/dist/tasks/store.js +17 -3
- package/dist/tasks/tool.js +97 -25
- package/dist/tools.js +2 -2
- package/dist/update.js +1 -1
- package/dist/web/instance.js +10 -3
- package/dist/web/providers.js +9 -2
- package/dist/web/public/assets/{activity-DVamP8fs.js → activity-Bl3vZukb.js} +2 -2
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
- package/dist/web/public/index.html +103 -134
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +2 -2
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/server.js +55 -36
- package/dist/web/session-state.js +54 -33
- package/package.json +2 -1
- package/skills/pier-tasks/SKILL.md +146 -127
- package/dist/web/public/assets/activity-DVamP8fs.js.br +0 -0
- package/dist/web/public/assets/activity-DVamP8fs.js.gz +0 -0
- package/dist/web/public/assets/boards-BKtjPwus.js +0 -1
- package/dist/web/public/assets/boards-BKtjPwus.js.br +0 -0
- package/dist/web/public/assets/boards-BKtjPwus.js.gz +0 -0
- package/dist/web/public/assets/explorer-CjO9NAKQ.js +0 -4
- package/dist/web/public/assets/explorer-CjO9NAKQ.js.br +0 -0
- package/dist/web/public/assets/explorer-CjO9NAKQ.js.gz +0 -0
- package/dist/web/public/assets/index-C23sXyS3.css +0 -2
- package/dist/web/public/assets/index-C23sXyS3.css.br +0 -0
- package/dist/web/public/assets/index-C23sXyS3.css.gz +0 -0
- package/dist/web/public/assets/index-DzWnvhXb.js +0 -85
- package/dist/web/public/assets/index-DzWnvhXb.js.br +0 -0
- package/dist/web/public/assets/index-DzWnvhXb.js.gz +0 -0
- package/dist/web/public/assets/runs-CxIZAx1s.js +0 -1
- package/dist/web/public/assets/runs-CxIZAx1s.js.br +0 -0
- package/dist/web/public/assets/runs-CxIZAx1s.js.gz +0 -0
- package/dist/web/public/assets/settings-B8JWLbpL.js +0 -5
- package/dist/web/public/assets/settings-B8JWLbpL.js.br +0 -0
- package/dist/web/public/assets/settings-B8JWLbpL.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BZ1brkLC.js +0 -3
- package/dist/web/public/assets/task-runs-BZ1brkLC.js.br +0 -0
- package/dist/web/public/assets/task-runs-BZ1brkLC.js.gz +0 -0
- package/dist/web/public/assets/tasks-BoYiBQpq.js +0 -4
- package/dist/web/public/assets/tasks-BoYiBQpq.js.br +0 -0
- package/dist/web/public/assets/tasks-BoYiBQpq.js.gz +0 -0
package/dist/agent/pi.js
CHANGED
|
@@ -1,20 +1,61 @@
|
|
|
1
1
|
// The only file outside src/extensions allowed to import @earendil-works/pi-*.
|
|
2
2
|
// Implements the AgentFactory/AgentSession seam from src/core/types.ts on the
|
|
3
3
|
// Pi SDK. No Pi type may appear in an exported signature.
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
4
6
|
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
import { inlineExtensions } from "../extensions/index.js";
|
|
8
|
+
import { SESSION_TITLE_MAX } from "../limits.js";
|
|
6
9
|
import { logger } from "../log.js";
|
|
7
|
-
import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
10
|
+
import { textOf, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
8
11
|
import { defaultAgentDir, PiConfigStore } from "./config.js";
|
|
9
12
|
import { IndexedListing } from "./listing.js";
|
|
10
13
|
import { curateModels, pinFirst } from "./models.js";
|
|
11
14
|
const log = logger("agent");
|
|
15
|
+
/** A directory is what a session is compared by — the rail groups on it, the
|
|
16
|
+
* New-session menu drops a `<repo>.<branch>` worktree when the repository
|
|
17
|
+
* itself is in the list — so two names for one directory are two projects. A
|
|
18
|
+
* session opened under `/home/qiqi` (a symlink to `/essd/qiqi`) never matched
|
|
19
|
+
* the worktrees recorded under the real path, and every branch checkout was
|
|
20
|
+
* offered as a project of its own. Resolved once per distinct path: a symlink
|
|
21
|
+
* that moves under a running instance is not a case we have.
|
|
22
|
+
*
|
|
23
|
+
* A directory that is gone — a worktree merged and removed — is still a
|
|
24
|
+
* session's cwd, and its spelling still decides whether the rail reads it as a
|
|
25
|
+
* branch of its repository. So the deepest ancestor that does resolve carries
|
|
26
|
+
* the rest of the path, and only a cwd with no resolvable ancestor at all is
|
|
27
|
+
* reported as recorded. */
|
|
28
|
+
const realPaths = new Map();
|
|
29
|
+
function realPath(cwd) {
|
|
30
|
+
const known = realPaths.get(cwd);
|
|
31
|
+
if (known !== undefined)
|
|
32
|
+
return known;
|
|
33
|
+
let real = cwd;
|
|
34
|
+
const missing = [];
|
|
35
|
+
for (let head = cwd;;) {
|
|
36
|
+
try {
|
|
37
|
+
real = join(realpathSync(head), ...missing);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const parent = dirname(head);
|
|
42
|
+
if (parent === head) {
|
|
43
|
+
log.debug(`cwd ${cwd} has no resolvable ancestor; using it as recorded`, err);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
missing.unshift(basename(head));
|
|
47
|
+
head = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
realPaths.set(cwd, real);
|
|
51
|
+
return real;
|
|
52
|
+
}
|
|
12
53
|
/** A listed record as the seam reports it. The one mapping, because `list` and
|
|
13
54
|
* `find` answer with the same shape and drifting would mean two answers about
|
|
14
55
|
* one session. */
|
|
15
56
|
const summaryOf = (s) => ({
|
|
16
57
|
id: s.id,
|
|
17
|
-
cwd: s.cwd,
|
|
58
|
+
cwd: realPath(s.cwd),
|
|
18
59
|
createdAt: s.created,
|
|
19
60
|
modified: s.modified,
|
|
20
61
|
...(s.title ? { title: s.title } : {}),
|
|
@@ -36,6 +77,36 @@ const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
|
|
|
36
77
|
const PROVIDER_CHECK_MAX_TOKENS = 8192;
|
|
37
78
|
/** Neither half of a probe is worth more than a screen. */
|
|
38
79
|
const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
|
|
80
|
+
/** Naming a session: one bare request on the operator's title model, after the
|
|
81
|
+
* first exchange. Each half of the exchange is cut to a few hundred
|
|
82
|
+
* characters — a title is about the subject, and the subject is in the
|
|
83
|
+
* opening lines — and the answer is a line, so the cap is a line's worth. */
|
|
84
|
+
const TITLE_INPUT_CHARS = 600;
|
|
85
|
+
const TITLE_MAX_TOKENS = 40;
|
|
86
|
+
const TITLE_TIMEOUT_MS = 20_000;
|
|
87
|
+
const TITLE_PROMPT = "Name this conversation for a session list. Reply with the title only: at most 12 Chinese characters " +
|
|
88
|
+
"or 6 English words, in the language the user wrote in, no quotes, no trailing period. " +
|
|
89
|
+
"A leading `[name<id> time]` on the user's message is a speaker header, not content.";
|
|
90
|
+
/** A subject, not the request: the model is asked to title the exchange, and
|
|
91
|
+
* the two halves are labelled so a reply that quotes an instruction back is
|
|
92
|
+
* not mistaken for one. */
|
|
93
|
+
const titleRequest = (first, reply) => `${TITLE_PROMPT}\n\n<user>\n${first.slice(0, TITLE_INPUT_CHARS)}\n</user>\n\n<assistant>\n${reply.slice(0, TITLE_INPUT_CHARS)}\n</assistant>`;
|
|
94
|
+
/** The text of a bare completion, or the refusal it was: a provider can
|
|
95
|
+
* decline as a message rather than a throw, and only the stop reason tells
|
|
96
|
+
* that from an answer. */
|
|
97
|
+
function textOfAnswer(answer) {
|
|
98
|
+
if (answer.stopReason === "error" || answer.stopReason === "aborted") {
|
|
99
|
+
throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
|
|
100
|
+
}
|
|
101
|
+
return textOf(answer.content).trim();
|
|
102
|
+
}
|
|
103
|
+
/** What a model hands back as a title, made into one: the first line, quotes
|
|
104
|
+
* off, capped where every other title is capped. Empty is a failure, not a
|
|
105
|
+
* cleared name — the caller reports it. */
|
|
106
|
+
export function titleFromAnswer(text) {
|
|
107
|
+
const line = text.trim().split("\n")[0]?.trim() ?? "";
|
|
108
|
+
return line.replace(/^["'“‘「]+|["'”’」.。]+$/g, "").trim().slice(0, SESSION_TITLE_MAX);
|
|
109
|
+
}
|
|
39
110
|
/** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
|
|
40
111
|
const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care — do the work and verify the result.
|
|
41
112
|
|
|
@@ -106,6 +177,7 @@ export class PiSession {
|
|
|
106
177
|
pinned;
|
|
107
178
|
wrote;
|
|
108
179
|
retention;
|
|
180
|
+
suggestTitle;
|
|
109
181
|
constructor(pi,
|
|
110
182
|
/** Operator pins, read per call — the menu can change while we run. */
|
|
111
183
|
pinned = () => [],
|
|
@@ -114,11 +186,16 @@ export class PiSession {
|
|
|
114
186
|
* every surface would re-read the old title and keep it until some
|
|
115
187
|
* unrelated event moved the list again. Same drop `create` does — a
|
|
116
188
|
* callback only because the session is what knows it happened. */
|
|
117
|
-
wrote = () => { }, retention = { value: "long" }
|
|
189
|
+
wrote = () => { }, retention = { value: "long" },
|
|
190
|
+
/** The operator's title model asked to name a first exchange, or nothing
|
|
191
|
+
* when auto-titling is off. Read per turn: switching it on names the next
|
|
192
|
+
* session that finishes a first turn, without a restart. */
|
|
193
|
+
suggestTitle = () => undefined) {
|
|
118
194
|
this.pi = pi;
|
|
119
195
|
this.pinned = pinned;
|
|
120
196
|
this.wrote = wrote;
|
|
121
197
|
this.retention = retention;
|
|
198
|
+
this.suggestTitle = suggestTitle;
|
|
122
199
|
}
|
|
123
200
|
/** Pi's dispose unhooks the one listener that persists and emits, so a turn
|
|
124
201
|
* started after it runs for real — model call, tools and all — and lands
|
|
@@ -342,9 +419,47 @@ export class PiSession {
|
|
|
342
419
|
}
|
|
343
420
|
for (const payload of toSessionEvents(piEvent)) {
|
|
344
421
|
fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
|
|
422
|
+
if (payload.type === "turn-end" && !payload.error)
|
|
423
|
+
this.autoTitle(payload.text, fn);
|
|
345
424
|
}
|
|
346
425
|
});
|
|
347
426
|
}
|
|
427
|
+
/** Whether naming has been decided for this session — done, started, or
|
|
428
|
+
* never: a session that already has a name (a task's, a rename during the
|
|
429
|
+
* turn) or more than one exchange behind it keeps what it has. Several
|
|
430
|
+
* subscribers see every turn-end; this is what makes the request one. */
|
|
431
|
+
titleDecided = false;
|
|
432
|
+
/** Name the session after its first exchange, on the operator's title model.
|
|
433
|
+
* Off by default (settings.titleModel), in which case the title stays the
|
|
434
|
+
* first prompt that agent/listing.ts derives. The result is a rename like
|
|
435
|
+
* any other — an append the next listing reads — announced as `renamed` so
|
|
436
|
+
* every surface re-lists; a failure is announced too, because a title that
|
|
437
|
+
* silently stayed the prompt looks like the setting did nothing (§5b). */
|
|
438
|
+
autoTitle(reply, fn) {
|
|
439
|
+
if (this.titleDecided)
|
|
440
|
+
return;
|
|
441
|
+
const suggest = this.suggestTitle();
|
|
442
|
+
if (!suggest)
|
|
443
|
+
return; // off — not decided: switched on later, the next turn still counts
|
|
444
|
+
this.titleDecided = true;
|
|
445
|
+
if (this.pi.sessionManager.getSessionName())
|
|
446
|
+
return;
|
|
447
|
+
const users = this.pi.messages.filter((m) => m.role === "user");
|
|
448
|
+
const first = users.length === 1 ? textOf(users[0]?.content) : "";
|
|
449
|
+
if (!first.trim())
|
|
450
|
+
return;
|
|
451
|
+
void suggest(first, reply).then((title) => {
|
|
452
|
+
// Named while we waited — by a person, whose word beats the model's.
|
|
453
|
+
if (this.disposed || this.pi.sessionManager.getSessionName())
|
|
454
|
+
return;
|
|
455
|
+
this.pi.sessionManager.appendSessionInfo(title);
|
|
456
|
+
this.wrote();
|
|
457
|
+
fn({ type: "renamed", title });
|
|
458
|
+
}, (err) => {
|
|
459
|
+
log.warn(`session ${this.pi.sessionId} could not be titled`, err);
|
|
460
|
+
fn({ type: "error", message: `session title: ${err instanceof Error ? err.message : String(err)} — the first message stays the title` });
|
|
461
|
+
});
|
|
462
|
+
}
|
|
348
463
|
/** Meta of the just-finished turn; live path, so "now" is the completion. */
|
|
349
464
|
lastTurnMeta() {
|
|
350
465
|
const messages = this.pi.messages;
|
|
@@ -367,6 +482,7 @@ export class PiAgentFactory {
|
|
|
367
482
|
providerConfig;
|
|
368
483
|
pinned;
|
|
369
484
|
enabledExtensions;
|
|
485
|
+
titleModel;
|
|
370
486
|
listings;
|
|
371
487
|
constructor(extraTools = [],
|
|
372
488
|
/** Appended as a virtual context file, so Pi's own prompt stays intact.
|
|
@@ -388,6 +504,10 @@ export class PiAgentFactory {
|
|
|
388
504
|
/** Which bundled extensions the Console has switched on. A getter for the
|
|
389
505
|
* same reason again: the toggle takes effect on the next session open. */
|
|
390
506
|
enabledExtensions = () => [],
|
|
507
|
+
/** The model that names a session after its first exchange (Console →
|
|
508
|
+
* Settings → Models); unset means the first prompt stays the title. A
|
|
509
|
+
* getter, read per turn end. */
|
|
510
|
+
titleModel = () => undefined,
|
|
391
511
|
/** What exists on disk. Injected so a test can hand this factory a listing
|
|
392
512
|
* instead of a session directory and a database. */
|
|
393
513
|
listings = new IndexedListing()) {
|
|
@@ -398,6 +518,7 @@ export class PiAgentFactory {
|
|
|
398
518
|
this.providerConfig = providerConfig;
|
|
399
519
|
this.pinned = pinned;
|
|
400
520
|
this.enabledExtensions = enabledExtensions;
|
|
521
|
+
this.titleModel = titleModel;
|
|
401
522
|
this.listings = listings;
|
|
402
523
|
}
|
|
403
524
|
/** One runtime for the whole process; catalogs are global, not per session. */
|
|
@@ -516,17 +637,7 @@ export class PiAgentFactory {
|
|
|
516
637
|
if (!model)
|
|
517
638
|
throw new Error(`unknown model: ${providerId}/${modelId}`);
|
|
518
639
|
const answer = await runtime.completeSimple(model, { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, { maxTokens: PROVIDER_CHECK_MAX_TOKENS, signal, fetch: recorded });
|
|
519
|
-
|
|
520
|
-
// is the only thing separating it from an answer.
|
|
521
|
-
const refused = answer.stopReason === "error" || answer.stopReason === "aborted";
|
|
522
|
-
if (refused) {
|
|
523
|
-
throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
|
|
524
|
-
}
|
|
525
|
-
const text = answer.content
|
|
526
|
-
.filter((part) => part.type === "text")
|
|
527
|
-
.map((part) => part.text)
|
|
528
|
-
.join("")
|
|
529
|
-
.trim();
|
|
640
|
+
const text = textOfAnswer(answer);
|
|
530
641
|
// An empty answer is still an answer; say which kind of nothing it was.
|
|
531
642
|
return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
|
|
532
643
|
}
|
|
@@ -539,6 +650,24 @@ export class PiAgentFactory {
|
|
|
539
650
|
: raw || error, false);
|
|
540
651
|
}
|
|
541
652
|
}
|
|
653
|
+
/** One bare completion — no session, no tools, no history — on the title
|
|
654
|
+
* model. Rejects on anything but a usable line: the session reports it. */
|
|
655
|
+
async suggestTitle(model, first, reply) {
|
|
656
|
+
const runtime = await this.refreshedRuntime();
|
|
657
|
+
const resolved = runtime.getModel(model.provider, model.id);
|
|
658
|
+
if (!resolved)
|
|
659
|
+
throw new Error(`title model ${model.provider}/${model.id} is not in the catalog`);
|
|
660
|
+
// The least reasoning the model does, said explicitly: unset means the
|
|
661
|
+
// provider's default, which on a reasoning model is not "none" (gpt-5:
|
|
662
|
+
// medium) and spends the whole output cap thinking about a title. Anthropic
|
|
663
|
+
// is the exception — its default is thinking off, and any level turns it on.
|
|
664
|
+
const reasoning = resolved.reasoning && resolved.api !== "anthropic-messages" ? "minimal" : undefined;
|
|
665
|
+
const answer = await runtime.completeSimple(resolved, { messages: [{ role: "user", content: titleRequest(first, reply), timestamp: Date.now() }] }, { maxTokens: TITLE_MAX_TOKENS, reasoning, signal: AbortSignal.timeout(TITLE_TIMEOUT_MS) });
|
|
666
|
+
const title = titleFromAnswer(textOfAnswer(answer));
|
|
667
|
+
if (!title)
|
|
668
|
+
throw new Error(`${model.provider}/${model.id} answered with no title`);
|
|
669
|
+
return title;
|
|
670
|
+
}
|
|
542
671
|
async setup(input) {
|
|
543
672
|
const builtins = await this.builtinIds();
|
|
544
673
|
if (input.kind === "builtin" && !builtins.has(input.id)) {
|
|
@@ -700,7 +829,10 @@ export class PiAgentFactory {
|
|
|
700
829
|
live.agent.followUpMode = "all";
|
|
701
830
|
const session = new PiSession(live, this.pinned, () => {
|
|
702
831
|
this.listing = undefined;
|
|
703
|
-
}, retention)
|
|
832
|
+
}, retention, () => {
|
|
833
|
+
const model = this.titleModel();
|
|
834
|
+
return model && ((first, reply) => this.suggestTitle(model, first, reply));
|
|
835
|
+
});
|
|
704
836
|
if (opts.model)
|
|
705
837
|
await session.setModel(opts.model);
|
|
706
838
|
if (opts.thinking)
|
|
@@ -710,7 +842,10 @@ export class PiAgentFactory {
|
|
|
710
842
|
}
|
|
711
843
|
async create(opts) {
|
|
712
844
|
this.listing = undefined;
|
|
713
|
-
|
|
845
|
+
// Resolved before Pi records it, so the transcript, the session directory
|
|
846
|
+
// and every later listing all name the directory the same way.
|
|
847
|
+
const cwd = realPath(opts.cwd);
|
|
848
|
+
return this.open(cwd, SessionManager.create(cwd), { ...opts, cwd });
|
|
714
849
|
}
|
|
715
850
|
async resume(sessionId) {
|
|
716
851
|
const known = this.located.get(sessionId);
|
|
@@ -778,4 +913,11 @@ export class PiAgentFactory {
|
|
|
778
913
|
async list() {
|
|
779
914
|
return (await this.listed()).map(summaryOf);
|
|
780
915
|
}
|
|
916
|
+
/** After a listing, so a transcript that grew since the last one is indexed
|
|
917
|
+
* before it is asked about; the listing owns the index. One that cannot
|
|
918
|
+
* search — a test's — has nothing to say. */
|
|
919
|
+
async search(query) {
|
|
920
|
+
await this.listed();
|
|
921
|
+
return this.listings.search?.(query) ?? [];
|
|
922
|
+
}
|
|
781
923
|
}
|
|
@@ -12,7 +12,7 @@ import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js"
|
|
|
12
12
|
/** A form-submit button name: `cwdgo:<thread root>`. */
|
|
13
13
|
export const CWD_SUBMIT_PREFIX = "cwdgo:";
|
|
14
14
|
/** The form input's field name, the key `form_value` answers under. */
|
|
15
|
-
|
|
15
|
+
const CWD_FIELD = "cwd";
|
|
16
16
|
export class LarkPanel extends ChatPanel {
|
|
17
17
|
deps;
|
|
18
18
|
platform = "lark";
|
package/dist/core/inbox.js
CHANGED
|
@@ -12,7 +12,7 @@ import { basename, join } from "node:path";
|
|
|
12
12
|
import { pierPath } from "../paths.js";
|
|
13
13
|
import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
|
|
14
14
|
/** Where every inbound file lives; the attachment route allowlists this root. */
|
|
15
|
-
|
|
15
|
+
const INBOX_DIR = pierPath("inbox");
|
|
16
16
|
/**
|
|
17
17
|
* Write one inbound file and return its absolute path. The timestamp-random
|
|
18
18
|
* prefix keeps concurrent saves collision-free (`wx` turns the impossible
|
package/dist/core/router.js
CHANGED
|
@@ -78,6 +78,7 @@ export class Router {
|
|
|
78
78
|
* the process exits when the drain ends — `endDrain` exists for the one
|
|
79
79
|
* caller that drains *speculatively* and may not get to exit. */
|
|
80
80
|
draining = false;
|
|
81
|
+
spokenTo;
|
|
81
82
|
constructor(hub,
|
|
82
83
|
/** Create or resume the session owning a conversation (wired in main.ts). */
|
|
83
84
|
resolve) {
|
|
@@ -87,6 +88,14 @@ export class Router {
|
|
|
87
88
|
registerChannel(channel) {
|
|
88
89
|
this.channels.set(channel.id, channel);
|
|
89
90
|
}
|
|
91
|
+
/** Told which session a human just spoke to — every message from a chat or
|
|
92
|
+
* the workbench passes through `dispatch`, and nothing a task or a subagent
|
|
93
|
+
* starts does. Registered rather than a constructor argument: the one
|
|
94
|
+
* listener is the rail's working set (web/session-state.ts), built with the
|
|
95
|
+
* web surface long after the router. */
|
|
96
|
+
onSpokenTo(listener) {
|
|
97
|
+
this.spokenTo = listener;
|
|
98
|
+
}
|
|
90
99
|
/**
|
|
91
100
|
* Tell the conversation something went wrong, then the event stream.
|
|
92
101
|
*
|
|
@@ -242,6 +251,10 @@ export class Router {
|
|
|
242
251
|
state: payload.state,
|
|
243
252
|
});
|
|
244
253
|
}
|
|
254
|
+
// A title the session gave itself: every list reads the transcript, so
|
|
255
|
+
// the same re-list a rename route broadcasts.
|
|
256
|
+
if (payload.type === "renamed")
|
|
257
|
+
this.hub.emitWorkspace({ type: "sessions-changed" });
|
|
245
258
|
// An error the session itself reported (a tool that threw, a model
|
|
246
259
|
// refusal, a lost connection). Without this it lands only in the web
|
|
247
260
|
// timeline and the IM side goes quiet for no visible reason.
|
|
@@ -602,6 +615,7 @@ export class Router {
|
|
|
602
615
|
// closed must not start the turn the drain just declared finished with.
|
|
603
616
|
if (this.draining)
|
|
604
617
|
this.refuseDraining(msg.key);
|
|
618
|
+
this.spokenTo?.(session.id);
|
|
605
619
|
const { action, text } = decide(msg, session.state);
|
|
606
620
|
// A group chat is many people talking into one session; without a speaker
|
|
607
621
|
// line the agent cannot tell them apart or mention anyone back. Emitted
|
package/dist/core/types.js
CHANGED
|
@@ -11,13 +11,17 @@ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhig
|
|
|
11
11
|
export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);
|
|
12
12
|
// Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
|
|
13
13
|
// non-Pi backend is bound to them by this seam.
|
|
14
|
-
|
|
14
|
+
const PROVIDER_APIS = [
|
|
15
15
|
"openai-completions",
|
|
16
16
|
"openai-responses",
|
|
17
17
|
"anthropic-messages",
|
|
18
18
|
"google-generative-ai",
|
|
19
19
|
];
|
|
20
20
|
export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
|
|
21
|
+
/** How far a model's reasoning goes. Every reasoning model offers up to
|
|
22
|
+
* "high"; the two levels above it exist only for a model whose catalog entry
|
|
23
|
+
* says so, which is the one thing a Console-defined model could not say. */
|
|
24
|
+
export const MODEL_EFFORTS = ["high", "xhigh", "max"];
|
|
21
25
|
/** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
|
|
22
26
|
* write and web/ pre-checks them at its HTTP boundary, and neither may import
|
|
23
27
|
* the other. Throws the message the surface shows. */
|
|
@@ -46,6 +50,14 @@ export function validateProviderSetup(input) {
|
|
|
46
50
|
if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
|
|
47
51
|
throw new Error("model ids must be non-empty, trimmed and unique");
|
|
48
52
|
}
|
|
53
|
+
// An effort ceiling on a model that does not reason would be written into
|
|
54
|
+
// the catalog and never offered — a setting that lies about itself.
|
|
55
|
+
if (input.models.some((model) => model.effort !== undefined && !model.reasoning)) {
|
|
56
|
+
throw new Error("effort requires reasoning");
|
|
57
|
+
}
|
|
58
|
+
if (input.models.some((model) => model.effort !== undefined && !MODEL_EFFORTS.includes(model.effort))) {
|
|
59
|
+
throw new Error("unsupported model effort");
|
|
60
|
+
}
|
|
49
61
|
}
|
|
50
62
|
export function validateEndpoint(endpoint) {
|
|
51
63
|
let url;
|
package/dist/db.js
CHANGED
|
@@ -331,6 +331,54 @@ const MIGRATIONS = [
|
|
|
331
331
|
-- land in the pinned section. project_sort stays as a column nothing reads
|
|
332
332
|
-- — a SQLite column drop rewrites the table for a NULL nobody pays for.
|
|
333
333
|
UPDATE session_state SET pinned = 0;
|
|
334
|
+
`,
|
|
335
|
+
// 20 — the top of the rail is maintained, not arranged: no pin, no drag.
|
|
336
|
+
`
|
|
337
|
+
-- sort was the place a hand dragged a pinned row to; it is now the rank in
|
|
338
|
+
-- the working set the rail keeps on top, which a session enters by being
|
|
339
|
+
-- spoken to and leaves by being pushed out of the last slot. The pinned rows
|
|
340
|
+
-- are that set's first members — they are what somebody was working on — in
|
|
341
|
+
-- the order they were arranged in; never-dragged ones sorted first, so -1 is
|
|
342
|
+
-- the rank that keeps them there.
|
|
343
|
+
UPDATE session_state SET sort = -1 WHERE pinned = 1 AND sort IS NULL;
|
|
344
|
+
UPDATE session_state SET sort = NULL WHERE pinned = 0;
|
|
345
|
+
-- The set has a size (web/session-state.ts); more pins than that is a list
|
|
346
|
+
-- the promotion rule would never have built.
|
|
347
|
+
UPDATE session_state SET sort = NULL WHERE session_id IN (
|
|
348
|
+
SELECT session_id FROM session_state WHERE sort IS NOT NULL
|
|
349
|
+
ORDER BY sort, session_id LIMIT -1 OFFSET 8
|
|
350
|
+
);
|
|
351
|
+
ALTER TABLE session_state DROP COLUMN pinned;
|
|
352
|
+
`,
|
|
353
|
+
// 21 — unread is the workbench's own attention, so it is only its own rows.
|
|
354
|
+
`
|
|
355
|
+
-- The flag was written for every session whose turn ended, including the
|
|
356
|
+
-- ones no browser is the reader of: an IM session answers its chat, a run's
|
|
357
|
+
-- session answers its supervisor. Both are now skipped at the write
|
|
358
|
+
-- (web/server.ts), and neither could ever be acked — that needs the session
|
|
359
|
+
-- on screen — so the rows they left would stay set forever. On the instance
|
|
360
|
+
-- this was decided on, 195 of 196 marks were those. Cleared wholesale rather
|
|
361
|
+
-- than by owner: the one real row is a turn from before an upgrade nobody
|
|
362
|
+
-- was watching for, and a false amber dot costs less than the join.
|
|
363
|
+
UPDATE session_state SET unread = 0;
|
|
364
|
+
`,
|
|
365
|
+
// 22 — the palette finds what was said, not only what a session is called.
|
|
366
|
+
`
|
|
367
|
+
-- One row per user message and per assistant reply, keyed by the transcript
|
|
368
|
+
-- it came from so a file that is rewritten or gone drops its rows in one
|
|
369
|
+
-- statement (agent/listing.ts). Trigram tokens: a substring match with no
|
|
370
|
+
-- word segmentation, which CJK text has none of and a path or an identifier
|
|
371
|
+
-- in a prompt has too much of. Steps stay out — tool calls, their output,
|
|
372
|
+
-- thinking are the bulk of a transcript and nobody searches for what ran.
|
|
373
|
+
CREATE VIRTUAL TABLE session_fts USING fts5(
|
|
374
|
+
text, session_id UNINDEXED, path UNINDEXED, role UNINDEXED, at UNINDEXED,
|
|
375
|
+
tokenize = 'trigram'
|
|
376
|
+
);
|
|
377
|
+
-- Derived and disposable (migration 7): every row goes, so the next scan
|
|
378
|
+
-- reads every transcript from its first byte and fills the table above.
|
|
379
|
+
-- Resetting parsed_bytes alone would not — a row whose (size, mtime) still
|
|
380
|
+
-- match is trusted without opening the file.
|
|
381
|
+
DELETE FROM session_index;
|
|
334
382
|
`,
|
|
335
383
|
];
|
|
336
384
|
/**
|
package/dist/drain.js
CHANGED
|
@@ -11,7 +11,7 @@ import { logger } from "./log.js";
|
|
|
11
11
|
const log = logger("drain");
|
|
12
12
|
/** How long running turns may take before they are aborted. Generous: a turn
|
|
13
13
|
* can be a subagent fan-out, and an abort still persists the partial work. */
|
|
14
|
-
|
|
14
|
+
const DRAIN_DEADLINE_MS = 5 * 60_000;
|
|
15
15
|
const POLL_MS = 1_000;
|
|
16
16
|
/** Shared cleanup window after the deadline. All sessions use the same clock,
|
|
17
17
|
* so N hung seams still cost at most this long rather than N times as long. */
|
|
@@ -49,7 +49,7 @@ export function sourcesFrom(content) {
|
|
|
49
49
|
return [...sources.values()].map(({ title, url }) => ({ title, url }));
|
|
50
50
|
}
|
|
51
51
|
/** Only what the search itself returned, in the order it ranked them. */
|
|
52
|
-
|
|
52
|
+
function searchResultsFrom(content) {
|
|
53
53
|
const results = new Map();
|
|
54
54
|
for (const block of content) {
|
|
55
55
|
if (!isObject(block) || block.type !== "web_search_tool_result")
|
|
@@ -61,7 +61,7 @@ export function searchResultsFrom(content) {
|
|
|
61
61
|
}
|
|
62
62
|
return [...results.values()];
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
function searchQueriesFrom(content) {
|
|
65
65
|
const queries = [];
|
|
66
66
|
for (const block of content) {
|
|
67
67
|
if (!isObject(block) || block.type !== "server_tool_use" || block.name !== "web_search") {
|
package/dist/main.js
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeAgentSnapshot } from "./agent/config-sync.js";
|
|
|
8
8
|
import { ConfigSync } from "./config-sync.js";
|
|
9
9
|
import { configSyncTask } from "./config-sync-task.js";
|
|
10
10
|
import { CredentialStore } from "./agent/credentials.js";
|
|
11
|
+
import { IndexedListing } from "./agent/listing.js";
|
|
11
12
|
import { PiAgentFactory } from "./agent/pi.js";
|
|
12
13
|
import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
|
|
13
14
|
import { ChannelStore } from "./channels/config.js";
|
|
@@ -20,6 +21,7 @@ import { SlackDirectory } from "./channels/slack-directory.js";
|
|
|
20
21
|
import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
|
|
21
22
|
import { parseConversation as parseSlackConversation } from "./channels/slack.js";
|
|
22
23
|
import { EventHub } from "./core/hub.js";
|
|
24
|
+
import { splitSpeaker } from "./core/identity.js";
|
|
23
25
|
import { pierDb } from "./db.js";
|
|
24
26
|
import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
|
|
25
27
|
import { bundledInfo } from "./extensions/index.js";
|
|
@@ -135,7 +137,12 @@ new CredentialStore(db, secrets), piConfig,
|
|
|
135
137
|
() => settings.get().modelMenu,
|
|
136
138
|
// Bundled extensions the Console switched on; read per session open, so the
|
|
137
139
|
// toggle reaches the next session the same way an edited agent file does.
|
|
138
|
-
() => settings.get().extensions
|
|
140
|
+
() => settings.get().extensions,
|
|
141
|
+
// The title model, if the operator picked one; read when a first turn ends.
|
|
142
|
+
() => settings.get().titleModel,
|
|
143
|
+
// The search index reads Pi's transcripts, which carry the speaker header
|
|
144
|
+
// core wrote for the model; this is where the two areas meet.
|
|
145
|
+
new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
|
|
139
146
|
const hub = new EventHub();
|
|
140
147
|
const router = new Router(hub, (key) => {
|
|
141
148
|
// Web conversation ids ARE session ids; an IM conversation id is a chat or a
|
package/dist/secrets.js
CHANGED
|
@@ -172,7 +172,7 @@ function open(key, sealed, aad) {
|
|
|
172
172
|
}
|
|
173
173
|
/** The real vt CLI. Absent binary or denied approval both surface as the
|
|
174
174
|
* spawn/exit error — unlock() records it and the operator reads it. */
|
|
175
|
-
|
|
175
|
+
const vtCli = {
|
|
176
176
|
read: (record) => run("vt", ["read", record]),
|
|
177
177
|
create: async (plaintext) => {
|
|
178
178
|
const out = await run("vt", ["create"], plaintext);
|
package/dist/service.js
CHANGED
|
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync }
|
|
|
15
15
|
import { homedir, userInfo } from "node:os";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
export const UNIT_NAME = "pier.service";
|
|
18
|
-
|
|
18
|
+
const UPDATE_UNIT_NAME = "pier-update.service";
|
|
19
19
|
/** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
|
|
20
20
|
export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
|
|
21
21
|
/** The drop-in Pier writes once and never touches again: what the unit may
|
|
@@ -146,7 +146,7 @@ ExecStopPost=systemctl --user start ${UNIT_NAME}
|
|
|
146
146
|
* exists to protect the OS and sshd outside it. Written commented so the
|
|
147
147
|
* operator tuning it can see what each line buys.
|
|
148
148
|
*/
|
|
149
|
-
|
|
149
|
+
function renderLimits() {
|
|
150
150
|
return `[Service]
|
|
151
151
|
# Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
|
|
152
152
|
# instead of killing anything. This is the one that should bite first.
|
package/dist/settings.js
CHANGED
|
@@ -50,27 +50,40 @@ export function normalizeModelMenu(raw) {
|
|
|
50
50
|
return null;
|
|
51
51
|
const menu = [];
|
|
52
52
|
for (const item of raw) {
|
|
53
|
-
|
|
53
|
+
const ref = normalizeModelRef(item);
|
|
54
|
+
if (!ref)
|
|
54
55
|
return null;
|
|
55
|
-
const {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
56
|
+
const { thinking, note } = item;
|
|
57
|
+
// The one thing repaired rather than rejected, because it is not input:
|
|
58
|
+
// entries stored (or exported by an instance) before the level was
|
|
59
|
+
// required have none, and dropping the menu over it would lose the pins.
|
|
60
|
+
const level = thinking === undefined ? "medium" : thinking;
|
|
61
|
+
if (!isThinkingLevel(level))
|
|
61
62
|
return null;
|
|
62
63
|
if (note !== undefined && typeof note !== "string")
|
|
63
64
|
return null;
|
|
64
65
|
const cleaned = note?.trim().slice(0, 200);
|
|
65
66
|
menu.push({
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
...(thinking !== undefined ? { thinking } : {}),
|
|
67
|
+
...ref,
|
|
68
|
+
thinking: level,
|
|
69
69
|
...(cleaned ? { note: cleaned } : {}),
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
return menu;
|
|
73
73
|
}
|
|
74
|
+
/** One model reference, rejecting rather than repairing — the menu above is
|
|
75
|
+
* built from these. Existence is not checked here: the catalog is the
|
|
76
|
+
* agent's, and a model that went away is reported by the call that fails. */
|
|
77
|
+
export function normalizeModelRef(raw) {
|
|
78
|
+
if (typeof raw !== "object" || raw === null)
|
|
79
|
+
return null;
|
|
80
|
+
const { provider, id } = raw;
|
|
81
|
+
if (typeof provider !== "string" || !provider.trim())
|
|
82
|
+
return null;
|
|
83
|
+
if (typeof id !== "string" || !id.trim())
|
|
84
|
+
return null;
|
|
85
|
+
return { provider: provider.trim(), id: id.trim() };
|
|
86
|
+
}
|
|
74
87
|
/**
|
|
75
88
|
* Shape only — an unknown name is not an error here. This file must not know
|
|
76
89
|
* what Pier bundles (that catalog is code, and importing it would drag the Pi
|
|
@@ -107,9 +120,11 @@ export class SettingsStore {
|
|
|
107
120
|
this.#db = db;
|
|
108
121
|
}
|
|
109
122
|
get() {
|
|
123
|
+
const titleModel = this.#json("titleModel", normalizeModelRef, "a {provider, id}");
|
|
110
124
|
return {
|
|
111
125
|
publicUrl: this.#value("publicUrl") ?? "",
|
|
112
126
|
modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
|
|
127
|
+
...(titleModel ? { titleModel } : {}),
|
|
113
128
|
autoUpdate: this.#value("autoUpdate") === "1",
|
|
114
129
|
extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
|
|
115
130
|
tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
|
|
@@ -152,6 +167,12 @@ export class SettingsStore {
|
|
|
152
167
|
this.#set("modelMenu", JSON.stringify(menu));
|
|
153
168
|
return this.get();
|
|
154
169
|
}
|
|
170
|
+
/** Same contract: hand this `normalizeModelRef`'s output; null switches
|
|
171
|
+
* auto-titling off. */
|
|
172
|
+
setTitleModel(ref) {
|
|
173
|
+
this.#set("titleModel", ref ? JSON.stringify(ref) : "");
|
|
174
|
+
return this.get();
|
|
175
|
+
}
|
|
155
176
|
setAutoUpdate(on) {
|
|
156
177
|
this.#set("autoUpdate", on ? "1" : "0");
|
|
157
178
|
return this.get();
|