@timqi/pier 0.0.29 → 0.1.1
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 +58 -125
- package/dist/agent/config.js +24 -11
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +43 -62
- package/dist/agent/listing.js +113 -68
- package/dist/agent/pi.js +204 -211
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +8 -24
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +13 -35
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -260
- package/dist/core/types.js +17 -1
- package/dist/db.js +98 -252
- package/dist/drain.js +58 -51
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +6 -14
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +87 -179
- package/dist/paths.js +10 -26
- package/dist/secrets.js +19 -46
- package/dist/service.js +33 -75
- package/dist/settings.js +42 -65
- package/dist/tasks/agent.js +129 -114
- package/dist/tasks/callbacks.js +9 -19
- package/dist/tasks/command.js +29 -14
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +46 -42
- package/dist/tasks/groups.js +41 -35
- package/dist/tasks/messages.js +121 -182
- package/dist/tasks/outbox.js +61 -55
- package/dist/tasks/routes.js +5 -11
- package/dist/tasks/runs.js +14 -13
- package/dist/tasks/service.js +53 -54
- package/dist/tasks/store.js +53 -30
- package/dist/tasks/tool.js +132 -61
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +100 -327
- package/dist/update.js +21 -44
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +45 -85
- package/dist/web/providers.js +14 -13
- package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +100 -130
- 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/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +131 -180
- package/dist/web/session-state.js +14 -54
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +153 -160
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
- package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
- package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
- package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
- package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
- package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
- package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
- package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
- package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/dist/agent/pi.js
CHANGED
|
@@ -1,41 +1,86 @@
|
|
|
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 "../core/types.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");
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
/** Sessions are grouped by directory, so two names for one directory (a
|
|
16
|
+
* symlinked home) are two projects. A directory that is gone is still a cwd:
|
|
17
|
+
* the deepest resolvable ancestor carries the rest of the path. */
|
|
18
|
+
const realPaths = new Map();
|
|
19
|
+
function realPath(cwd) {
|
|
20
|
+
const known = realPaths.get(cwd);
|
|
21
|
+
if (known !== undefined)
|
|
22
|
+
return known;
|
|
23
|
+
let real = cwd;
|
|
24
|
+
const missing = [];
|
|
25
|
+
for (let head = cwd;;) {
|
|
26
|
+
try {
|
|
27
|
+
real = join(realpathSync(head), ...missing);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const parent = dirname(head);
|
|
32
|
+
if (parent === head) {
|
|
33
|
+
log.debug(`cwd ${cwd} has no resolvable ancestor; using it as recorded`, err);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
missing.unshift(basename(head));
|
|
37
|
+
head = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
realPaths.set(cwd, real);
|
|
41
|
+
return real;
|
|
42
|
+
}
|
|
15
43
|
const summaryOf = (s) => ({
|
|
16
44
|
id: s.id,
|
|
17
|
-
cwd: s.cwd,
|
|
45
|
+
cwd: realPath(s.cwd),
|
|
18
46
|
createdAt: s.created,
|
|
19
47
|
modified: s.modified,
|
|
20
48
|
...(s.title ? { title: s.title } : {}),
|
|
21
49
|
});
|
|
22
|
-
/** Pi's bash tool has no default timeout,
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* a tool error the agent can retry with an explicit longer timeout, instead of
|
|
26
|
-
* killing the whole run. */
|
|
50
|
+
/** Pi's bash tool has no default timeout, and nobody is watching a scheduled
|
|
51
|
+
* task. Below the default task-run timeout, so a stuck command comes back as a
|
|
52
|
+
* tool error instead of killing the run. */
|
|
27
53
|
const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
* that a title no invalidation covers is never stale on screen. */
|
|
54
|
+
/** Long enough that one workspace event, answered by several surfaces, scans
|
|
55
|
+
* disk once; short enough that a title no invalidation covers is never stale. */
|
|
31
56
|
const LIST_TTL_MS = 3_000;
|
|
32
|
-
/** A probe nobody is watching is a hung page: the Console waits on this. */
|
|
33
57
|
const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
|
|
34
|
-
/** An ordinary budget
|
|
35
|
-
* ever makes, and answers about it are answers about a different request. */
|
|
58
|
+
/** An ordinary budget: a 1-token cap is a request no real turn ever makes. */
|
|
36
59
|
const PROVIDER_CHECK_MAX_TOKENS = 8192;
|
|
37
|
-
/** Neither half of a probe is worth more than a screen. */
|
|
38
60
|
const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
|
|
61
|
+
/** The subject of an exchange is in its opening lines; the answer is one line. */
|
|
62
|
+
const TITLE_INPUT_CHARS = 600;
|
|
63
|
+
const TITLE_MAX_TOKENS = 40;
|
|
64
|
+
const TITLE_TIMEOUT_MS = 20_000;
|
|
65
|
+
const TITLE_PROMPT = "Name this conversation for a session list. Reply with the title only: at most 12 Chinese characters " +
|
|
66
|
+
"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.";
|
|
68
|
+
/** The halves are labelled so a reply quoting an instruction back is not
|
|
69
|
+
* mistaken for one. */
|
|
70
|
+
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>`;
|
|
71
|
+
/** A provider can decline as a message rather than a throw; only the stop
|
|
72
|
+
* reason tells that from an answer. */
|
|
73
|
+
function textOfAnswer(answer) {
|
|
74
|
+
if (answer.stopReason === "error" || answer.stopReason === "aborted") {
|
|
75
|
+
throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
|
|
76
|
+
}
|
|
77
|
+
return textOf(answer.content).trim();
|
|
78
|
+
}
|
|
79
|
+
/** Empty is a failure, not a cleared name — the caller reports it. */
|
|
80
|
+
export function titleFromAnswer(text) {
|
|
81
|
+
const line = text.trim().split("\n")[0]?.trim() ?? "";
|
|
82
|
+
return line.replace(/^["'“‘「]+|["'”’」.。]+$/g, "").trim().slice(0, SESSION_TITLE_MAX);
|
|
83
|
+
}
|
|
39
84
|
/** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
|
|
40
85
|
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
86
|
|
|
@@ -59,8 +104,7 @@ These rules govern conversational replies. A human reads them on a phone-sized s
|
|
|
59
104
|
- Destructive or irreversible actions on things you didn't create — deleting user files, force push, migrations, deploys, service restarts: ask first; unattended, don't do them and report what you would have done.
|
|
60
105
|
- Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.`;
|
|
61
106
|
export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
|
|
62
|
-
/** Patching the call
|
|
63
|
-
* shell settings, and the agent spends no tokens deciding a timeout. */
|
|
107
|
+
/** Patching the call keeps the built-in's shell settings. */
|
|
64
108
|
const bashTimeoutDefault = (pi) => {
|
|
65
109
|
pi.on("tool_call", (event) => {
|
|
66
110
|
if (event.toolName === "bash" && event.input.timeout === undefined) {
|
|
@@ -68,12 +112,8 @@ const bashTimeoutDefault = (pi) => {
|
|
|
68
112
|
}
|
|
69
113
|
});
|
|
70
114
|
};
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
73
|
-
* its tools. Pi loads both and reports the clash as a diagnostic nobody reads,
|
|
74
|
-
* leaving two tools of the same name and no way to tell which one answered;
|
|
75
|
-
* the copy the user put there wins, and the journal says so (§5b).
|
|
76
|
-
*/
|
|
115
|
+
/** Pi loads a bundled extension and its on-disk twin both, leaving two tools
|
|
116
|
+
* of one name; the copy the user put there wins, and the journal says so (§5). */
|
|
77
117
|
export const standDownShadowed = (base) => {
|
|
78
118
|
const inline = (ext) => ext.path.startsWith("<inline:");
|
|
79
119
|
const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
|
|
@@ -90,11 +130,8 @@ export const standDownShadowed = (base) => {
|
|
|
90
130
|
}),
|
|
91
131
|
};
|
|
92
132
|
};
|
|
93
|
-
/**
|
|
94
|
-
*
|
|
95
|
-
* description is resident in every prompt, so one pointing at a tool this
|
|
96
|
-
* session was not given is both a cost and a route the agent cannot take.
|
|
97
|
-
*/
|
|
133
|
+
/** A skill's description is resident in every prompt; one pointing at a tool
|
|
134
|
+
* this session was not given is a route the agent cannot take. */
|
|
98
135
|
export const standDownUndocumented = (tools, skills) => {
|
|
99
136
|
const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
|
|
100
137
|
if (!gone.size)
|
|
@@ -106,25 +143,23 @@ export class PiSession {
|
|
|
106
143
|
pinned;
|
|
107
144
|
wrote;
|
|
108
145
|
retention;
|
|
146
|
+
suggestTitle;
|
|
109
147
|
constructor(pi,
|
|
110
|
-
/**
|
|
148
|
+
/** Read per call — the menu can change while we run. */
|
|
111
149
|
pinned = () => [],
|
|
112
|
-
/**
|
|
113
|
-
*
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
wrote = () => { }, retention = { value: "long" }) {
|
|
150
|
+
/** Drops the factory's retained listing: a rename lands in exactly the
|
|
151
|
+
* window it covers, and every surface would keep the old title. */
|
|
152
|
+
wrote = () => { }, retention = { value: "long" },
|
|
153
|
+
/** Read per turn: switching auto-titling on takes effect without a restart. */
|
|
154
|
+
suggestTitle = () => undefined) {
|
|
118
155
|
this.pi = pi;
|
|
119
156
|
this.pinned = pinned;
|
|
120
157
|
this.wrote = wrote;
|
|
121
158
|
this.retention = retention;
|
|
159
|
+
this.suggestTitle = suggestTitle;
|
|
122
160
|
}
|
|
123
|
-
/** Pi's dispose
|
|
124
|
-
*
|
|
125
|
-
* nowhere: no transcript entry, no event, and a promise that resolves as if
|
|
126
|
-
* it worked. Refusing is what turns that silence into the failure it is;
|
|
127
|
-
* callers already report or retry a rejection (§5b). */
|
|
161
|
+
/** A turn started after Pi's dispose runs for real and lands nowhere — no
|
|
162
|
+
* transcript, no event, a promise that resolves. Refusing makes it a failure (§5). */
|
|
128
163
|
disposed = false;
|
|
129
164
|
live() {
|
|
130
165
|
if (this.disposed)
|
|
@@ -150,7 +185,6 @@ export class PiSession {
|
|
|
150
185
|
async setModel(ref) {
|
|
151
186
|
const m = this.pi.modelRuntime.getModel(ref.provider, ref.id);
|
|
152
187
|
if (!m) {
|
|
153
|
-
// Lazy discovery: the failure itself documents what is selectable.
|
|
154
188
|
const available = (await this.availableModels())
|
|
155
189
|
.slice(0, 8).map((entry) => `${entry.provider}/${entry.id}`).join(", ");
|
|
156
190
|
throw new Error(`unknown model: ${ref.provider}/${ref.id}; available: ${available}`);
|
|
@@ -160,8 +194,7 @@ export class PiSession {
|
|
|
160
194
|
async availableModels() {
|
|
161
195
|
const available = await this.pi.modelRuntime.getAvailable();
|
|
162
196
|
const curated = pinFirst(curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning }))), this.pinned());
|
|
163
|
-
// The
|
|
164
|
-
// (or an older catalog) would hide it.
|
|
197
|
+
// The active model must stay selectable even when curation would hide it.
|
|
165
198
|
const current = this.model;
|
|
166
199
|
if (current && !curated.some((m) => m.provider === current.provider && m.id === current.id)) {
|
|
167
200
|
curated.unshift(current);
|
|
@@ -184,22 +217,20 @@ export class PiSession {
|
|
|
184
217
|
};
|
|
185
218
|
}
|
|
186
219
|
/** A system input handed to a streaming session goes into Pi's *agent*
|
|
187
|
-
* queue,
|
|
188
|
-
*
|
|
189
|
-
* in front of a running turn is in neither the queue nor the transcript,
|
|
190
|
-
* and this list is the only thing that says it exists. */
|
|
220
|
+
* queue, which `pendingQueue` cannot see; this list is the only thing that
|
|
221
|
+
* says it exists. */
|
|
191
222
|
queuedInputs = [];
|
|
192
223
|
async pendingSystemInputs() {
|
|
193
|
-
// Pi drains its
|
|
194
|
-
//
|
|
195
|
-
//
|
|
224
|
+
// Pi drains its queue before the turn ends; anything still listed on an
|
|
225
|
+
// idle session was aborted, and calling it queued would leave its sender
|
|
226
|
+
// waiting forever (§5).
|
|
196
227
|
if (!this.pi.isStreaming)
|
|
197
228
|
this.queuedInputs.length = 0;
|
|
198
229
|
return [...this.queuedInputs];
|
|
199
230
|
}
|
|
200
231
|
async clearQueue() {
|
|
201
|
-
// Not
|
|
202
|
-
//
|
|
232
|
+
// Not returned (a system input is nobody's draft), but dropped, so its
|
|
233
|
+
// sender stops being told it is on its way.
|
|
203
234
|
this.queuedInputs.length = 0;
|
|
204
235
|
return this.pi.clearQueue();
|
|
205
236
|
}
|
|
@@ -209,8 +240,8 @@ export class PiSession {
|
|
|
209
240
|
}
|
|
210
241
|
async rewindToUserTurn(index) {
|
|
211
242
|
const total = (await this.history()).filter((t) => t.role === "user").length;
|
|
212
|
-
//
|
|
213
|
-
//
|
|
243
|
+
// Branch entries keep compacted-away history that history() no longer
|
|
244
|
+
// shows, so only end-relative indices line up.
|
|
214
245
|
const back = total - index;
|
|
215
246
|
const users = this.pi.sessionManager
|
|
216
247
|
.getBranch()
|
|
@@ -218,27 +249,20 @@ export class PiSession {
|
|
|
218
249
|
const target = back >= 1 ? users[users.length - back] : undefined;
|
|
219
250
|
if (!target)
|
|
220
251
|
throw new Error(`no user turn at index ${index}`);
|
|
221
|
-
//
|
|
222
|
-
// branch stays in the file but leaves the context.
|
|
252
|
+
// The old branch stays in the file but leaves the context.
|
|
223
253
|
const { cancelled } = await this.pi.navigateTree(target.id);
|
|
224
254
|
if (cancelled)
|
|
225
255
|
throw new Error("rewind cancelled");
|
|
226
256
|
}
|
|
227
|
-
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* pass the route's idle check, so the gate has to be here. */
|
|
257
|
+
/** Pi keeps no lock of its own: a second `compact()` summarizes a transcript
|
|
258
|
+
* being replaced under it, and two POSTs a millisecond apart both pass the
|
|
259
|
+
* route's idle check. */
|
|
231
260
|
compacting = null;
|
|
232
|
-
/** Pi's own compaction, minus its `CompactionResult`: the numbers reach
|
|
233
|
-
* surfaces as the `context-compacted` event the seam already emits for the
|
|
234
|
-
* automatic one, so a caller has nothing to do with them. Refused while one
|
|
235
|
-
* is running, rather than run twice over one context. */
|
|
236
261
|
async compact() {
|
|
237
262
|
this.live();
|
|
238
263
|
if (this.compacting)
|
|
239
264
|
throw new Error(`session ${this.pi.sessionId} is already compacting`);
|
|
240
|
-
//
|
|
241
|
-
// what makes the check above a gate and not a hint.
|
|
265
|
+
// Recorded in the same tick, no await between: that is what makes the check a gate.
|
|
242
266
|
const running = this.pi.compact().then(() => undefined);
|
|
243
267
|
this.compacting = running;
|
|
244
268
|
try {
|
|
@@ -248,48 +272,30 @@ export class PiSession {
|
|
|
248
272
|
this.compacting = null;
|
|
249
273
|
}
|
|
250
274
|
}
|
|
251
|
-
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* do with the turn running.
|
|
255
|
-
*
|
|
256
|
-
* Returns nothing, because the transcript is the answer: what the session is
|
|
257
|
-
* called after this — the name, or the title a cleared one falls back to —
|
|
258
|
-
* is what the next listing reads off the file, and deriving it here as well
|
|
259
|
-
* was a second copy of a rule agent/listing.ts already owns.
|
|
260
|
-
*
|
|
261
|
-
* TODO: renaming a cold session costs a whole resume, because the route
|
|
262
|
-
* reaches it through `ensure` and this method needs a live Pi session to
|
|
263
|
-
* append through. The work is one line in a file. Revisit when Pi offers a
|
|
264
|
-
* lightweight append to a session it has not loaded. */
|
|
275
|
+
/** An append: Pi's reader takes the latest `session_info`. Never refused for
|
|
276
|
+
* being busy. TODO: renaming a cold session costs a whole resume for one
|
|
277
|
+
* appended line; revisit when Pi offers a lightweight append. */
|
|
265
278
|
async rename(name) {
|
|
266
279
|
this.live();
|
|
267
280
|
this.pi.sessionManager.appendSessionInfo(name);
|
|
268
281
|
this.wrote();
|
|
269
282
|
}
|
|
270
|
-
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* follow-up queue, which is only drained by the *next* turn: a message
|
|
274
|
-
* parked there while nothing is running would sit unsent, and Pi's own
|
|
275
|
-
* `prompt()` guard would have thrown the user's message away (§5b). */
|
|
283
|
+
/** A dispatch landing mid-compaction waits for the summary instead of
|
|
284
|
+
* starting a turn over it. Not Pi's follow-up queue: that is drained only by
|
|
285
|
+
* the *next* turn, so a message parked there while idle would sit unsent. */
|
|
276
286
|
async whenCompacted() {
|
|
277
287
|
while (this.compacting)
|
|
278
288
|
await this.compacting.catch(() => undefined);
|
|
279
289
|
}
|
|
280
|
-
// Async, so a refusal is a rejected promise
|
|
281
|
-
// may only `.catch()` (core/types.ts), and dispatch does exactly that.
|
|
290
|
+
// Async, so a refusal is a rejected promise the seam lets callers `.catch()`.
|
|
282
291
|
async prompt(text) {
|
|
283
292
|
this.live();
|
|
284
293
|
await this.whenCompacted();
|
|
285
|
-
//
|
|
294
|
+
// The wait above is long enough for a dispose to land.
|
|
286
295
|
this.live();
|
|
287
|
-
// A turn may have started since the caller read the state
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// processing" and the message is gone (§5b); queued, it is the same
|
|
291
|
-
// "delivered when idle" the core's own policy picks for an auto message
|
|
292
|
-
// that lands mid-turn (core/queue.ts).
|
|
296
|
+
// A turn may have started since the caller read the state. Bare, Pi throws
|
|
297
|
+
// "already processing" and the message is gone (§5); queued, it is the
|
|
298
|
+
// same "delivered when idle" core/queue.ts picks for a mid-turn message.
|
|
293
299
|
return this.pi.prompt(text, { streamingBehavior: "followUp" });
|
|
294
300
|
}
|
|
295
301
|
async steer(text) {
|
|
@@ -302,13 +308,10 @@ export class PiSession {
|
|
|
302
308
|
}
|
|
303
309
|
async systemInput(text, origin, mode) {
|
|
304
310
|
this.live();
|
|
305
|
-
// Same gate as prompt(): an idle session takes a system input as a turn
|
|
306
|
-
// whatever the mode says, so a callback landing mid-compaction would race
|
|
307
|
-
// the summary too.
|
|
311
|
+
// Same gate as prompt(): an idle session takes a system input as a turn.
|
|
308
312
|
await this.whenCompacted();
|
|
309
313
|
this.live();
|
|
310
|
-
// Read
|
|
311
|
-
// running, so the call below queues this input rather than starting one.
|
|
314
|
+
// Read after the wait: a running turn means the call below queues.
|
|
312
315
|
const queued = this.pi.isStreaming;
|
|
313
316
|
if (queued)
|
|
314
317
|
this.queuedInputs.push(origin);
|
|
@@ -316,8 +319,7 @@ export class PiSession {
|
|
|
316
319
|
return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
|
|
317
320
|
}
|
|
318
321
|
catch (error) {
|
|
319
|
-
// Refused: nothing is in flight
|
|
320
|
-
// turn may have ended while we waited, so the entry may be gone already.
|
|
322
|
+
// Refused: nothing is in flight. The entry may be gone already.
|
|
321
323
|
const at = this.queuedInputs.indexOf(origin);
|
|
322
324
|
if (at >= 0)
|
|
323
325
|
this.queuedInputs.splice(at, 1);
|
|
@@ -334,18 +336,46 @@ export class PiSession {
|
|
|
334
336
|
if (piEvent.type === "agent_end")
|
|
335
337
|
retryPending = piEvent.willRetry === true;
|
|
336
338
|
if (piEvent.type === "agent_settled" && retryPending) {
|
|
337
|
-
// Aborting Pi during retry backoff produces no final agent_end
|
|
338
|
-
// turn has to be ended here. The idle is not: `agent_settled` carries
|
|
339
|
-
// it through the table below like any other settle.
|
|
339
|
+
// Aborting Pi during retry backoff produces no final agent_end.
|
|
340
340
|
retryPending = false;
|
|
341
341
|
fn({ type: "turn-end", text: "", meta: this.lastTurnMeta() });
|
|
342
342
|
}
|
|
343
343
|
for (const payload of toSessionEvents(piEvent)) {
|
|
344
344
|
fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
|
|
345
|
+
if (payload.type === "turn-end" && !payload.error)
|
|
346
|
+
this.autoTitle(payload.text, fn);
|
|
345
347
|
}
|
|
346
348
|
});
|
|
347
349
|
}
|
|
348
|
-
/**
|
|
350
|
+
/** Several subscribers see every turn-end; this is what makes the request one. */
|
|
351
|
+
titleDecided = false;
|
|
352
|
+
/** A failure is announced too: a title that silently stayed the prompt looks
|
|
353
|
+
* like the setting did nothing (§5). */
|
|
354
|
+
autoTitle(reply, fn) {
|
|
355
|
+
if (this.titleDecided)
|
|
356
|
+
return;
|
|
357
|
+
const suggest = this.suggestTitle();
|
|
358
|
+
if (!suggest)
|
|
359
|
+
return; // off is not decided: switched on later, the next turn still counts
|
|
360
|
+
this.titleDecided = true;
|
|
361
|
+
if (this.pi.sessionManager.getSessionName())
|
|
362
|
+
return;
|
|
363
|
+
const users = this.pi.messages.filter((m) => m.role === "user");
|
|
364
|
+
const first = users.length === 1 ? textOf(users[0]?.content) : "";
|
|
365
|
+
if (!first.trim())
|
|
366
|
+
return;
|
|
367
|
+
void suggest(first, reply).then((title) => {
|
|
368
|
+
// Named while we waited, by a person: their word beats the model's.
|
|
369
|
+
if (this.disposed || this.pi.sessionManager.getSessionName())
|
|
370
|
+
return;
|
|
371
|
+
this.pi.sessionManager.appendSessionInfo(title);
|
|
372
|
+
this.wrote();
|
|
373
|
+
fn({ type: "renamed", title });
|
|
374
|
+
}, (err) => {
|
|
375
|
+
log.warn(`session ${this.pi.sessionId} could not be titled`, err);
|
|
376
|
+
fn({ type: "error", message: `session title: ${err instanceof Error ? err.message : String(err)} — the first message stays the title` });
|
|
377
|
+
});
|
|
378
|
+
}
|
|
349
379
|
lastTurnMeta() {
|
|
350
380
|
const messages = this.pi.messages;
|
|
351
381
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -367,29 +397,19 @@ export class PiAgentFactory {
|
|
|
367
397
|
providerConfig;
|
|
368
398
|
pinned;
|
|
369
399
|
enabledExtensions;
|
|
400
|
+
titleModel;
|
|
370
401
|
listings;
|
|
371
402
|
constructor(extraTools = [],
|
|
372
|
-
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
* ones. */
|
|
403
|
+
/** Getters are read per session open, so a Console change reaches the next
|
|
404
|
+
* session without a restart. Appended as a context file so the user's own
|
|
405
|
+
* instructions still win. */
|
|
376
406
|
instructions = () => "",
|
|
377
|
-
/**
|
|
378
|
-
* into the user's global or project skill directories. */
|
|
407
|
+
/** Loaded per session, never installed into the user's skill directories. */
|
|
379
408
|
skillPaths = [],
|
|
380
|
-
/**
|
|
381
|
-
*
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
credentials, providerConfig = new PiConfigStore(),
|
|
385
|
-
/** Operator-pinned models (Console → Settings → Models), surfaced first in
|
|
386
|
-
* every picker. A getter for the same reason `instructions` is one. */
|
|
387
|
-
pinned = () => [],
|
|
388
|
-
/** Which bundled extensions the Console has switched on. A getter for the
|
|
389
|
-
* same reason again: the toggle takes effect on the next session open. */
|
|
390
|
-
enabledExtensions = () => [],
|
|
391
|
-
/** What exists on disk. Injected so a test can hand this factory a listing
|
|
392
|
-
* instead of a session directory and a database. */
|
|
409
|
+
/** Optional only for bare test factories; an OAuth refresh persists here,
|
|
410
|
+
* never to a plaintext auth.json. */
|
|
411
|
+
credentials, providerConfig = new PiConfigStore(), pinned = () => [], enabledExtensions = () => [], titleModel = () => undefined,
|
|
412
|
+
/** Injected so a test needs no session directory or database. */
|
|
393
413
|
listings = new IndexedListing()) {
|
|
394
414
|
this.extraTools = extraTools;
|
|
395
415
|
this.instructions = instructions;
|
|
@@ -398,24 +418,19 @@ export class PiAgentFactory {
|
|
|
398
418
|
this.providerConfig = providerConfig;
|
|
399
419
|
this.pinned = pinned;
|
|
400
420
|
this.enabledExtensions = enabledExtensions;
|
|
421
|
+
this.titleModel = titleModel;
|
|
401
422
|
this.listings = listings;
|
|
402
423
|
}
|
|
403
|
-
/**
|
|
424
|
+
/** Catalogs are global, not per session. */
|
|
404
425
|
catalog;
|
|
405
|
-
/**
|
|
406
|
-
*
|
|
407
|
-
* message, a task run. The sidebar's own listing keeps this warm; a miss
|
|
408
|
-
* still lists. */
|
|
426
|
+
/** A scan stats every session file, which `resume` would otherwise pay on
|
|
427
|
+
* every cold open. */
|
|
409
428
|
located = new Map();
|
|
410
|
-
/**
|
|
411
|
-
* surface — one workspace event has three (sidebar, Activity, task lookups).
|
|
412
|
-
* Dropped on create; ids appear for reasons this factory never sees, so
|
|
413
|
-
* a miss that decides something re-lists rather than trusts it (`resume`). */
|
|
429
|
+
/** Retained for LIST_TTL_MS; one workspace event has three asking surfaces. */
|
|
414
430
|
listing;
|
|
415
431
|
refreshQueue = Promise.resolve();
|
|
416
432
|
builtinProviderIds;
|
|
417
|
-
/**
|
|
418
|
-
* name, so the SDK accepts it without this file exporting any SDK type. */
|
|
433
|
+
/** CredentialStore mirrors pi-ai's interface structurally, so no SDK type is exported. */
|
|
419
434
|
createRuntime() {
|
|
420
435
|
return ModelRuntime.create(this.credentials ? { credentials: this.credentials } : {});
|
|
421
436
|
}
|
|
@@ -447,8 +462,8 @@ export class PiAgentFactory {
|
|
|
447
462
|
this.builtinIds(),
|
|
448
463
|
this.providerConfig.providerStructures(),
|
|
449
464
|
]);
|
|
450
|
-
//
|
|
451
|
-
// combine an older catalog with a newer models.json
|
|
465
|
+
// After queued config writes settle, so runtime and structure never
|
|
466
|
+
// combine an older catalog with a newer models.json.
|
|
452
467
|
const runtime = await this.refreshedRuntime();
|
|
453
468
|
const stored = new Map((await runtime.listCredentials()).map((c) => [c.providerId, c.type]));
|
|
454
469
|
return runtime.getProviders().map((provider) => {
|
|
@@ -480,17 +495,9 @@ export class PiAgentFactory {
|
|
|
480
495
|
};
|
|
481
496
|
});
|
|
482
497
|
}
|
|
483
|
-
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* gateway rewriting the request and a model this endpoint has never heard of
|
|
487
|
-
* all look identical until a turn fails hours later.
|
|
488
|
-
*
|
|
489
|
-
* The request goes out through a fetch of our own for one reason: what a
|
|
490
|
-
* provider (or a proxy in front of it) was actually sent, and what it
|
|
491
|
-
* actually said, is the answer here — a summary of either would be Pier's
|
|
492
|
-
* word for someone else's.
|
|
493
|
-
*/
|
|
498
|
+
/** A fetch of our own records what the provider (or a proxy in front of it)
|
|
499
|
+
* was actually sent and actually said; a summary would be Pier's word for
|
|
500
|
+
* someone else's. */
|
|
494
501
|
async check(providerId, modelId) {
|
|
495
502
|
const started = Date.now();
|
|
496
503
|
const signal = AbortSignal.timeout(PROVIDER_CHECK_TIMEOUT_MS);
|
|
@@ -499,7 +506,7 @@ export class PiAgentFactory {
|
|
|
499
506
|
const recorded = async (input, init) => {
|
|
500
507
|
request = typeof init?.body === "string" ? init.body : "";
|
|
501
508
|
const response = await globalThis.fetch(input, init);
|
|
502
|
-
// Cloned
|
|
509
|
+
// Cloned: the SDK still needs the real stream.
|
|
503
510
|
body = response.clone().text().then(clip, () => "");
|
|
504
511
|
return response;
|
|
505
512
|
};
|
|
@@ -516,18 +523,7 @@ export class PiAgentFactory {
|
|
|
516
523
|
if (!model)
|
|
517
524
|
throw new Error(`unknown model: ${providerId}/${modelId}`);
|
|
518
525
|
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();
|
|
530
|
-
// An empty answer is still an answer; say which kind of nothing it was.
|
|
526
|
+
const text = textOfAnswer(answer);
|
|
531
527
|
return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
|
|
532
528
|
}
|
|
533
529
|
catch (err) {
|
|
@@ -539,6 +535,20 @@ export class PiAgentFactory {
|
|
|
539
535
|
: raw || error, false);
|
|
540
536
|
}
|
|
541
537
|
}
|
|
538
|
+
async suggestTitle(model, first, reply) {
|
|
539
|
+
const runtime = await this.refreshedRuntime();
|
|
540
|
+
const resolved = runtime.getModel(model.provider, model.id);
|
|
541
|
+
if (!resolved)
|
|
542
|
+
throw new Error(`title model ${model.provider}/${model.id} is not in the catalog`);
|
|
543
|
+
// Unset means the provider's default, which on gpt-5 is medium and spends
|
|
544
|
+
// the whole output cap thinking. Anthropic's default is off, and any level turns it on.
|
|
545
|
+
const reasoning = resolved.reasoning && resolved.api !== "anthropic-messages" ? "minimal" : undefined;
|
|
546
|
+
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) });
|
|
547
|
+
const title = titleFromAnswer(textOfAnswer(answer));
|
|
548
|
+
if (!title)
|
|
549
|
+
throw new Error(`${model.provider}/${model.id} answered with no title`);
|
|
550
|
+
return title;
|
|
551
|
+
}
|
|
542
552
|
async setup(input) {
|
|
543
553
|
const builtins = await this.builtinIds();
|
|
544
554
|
if (input.kind === "builtin" && !builtins.has(input.id)) {
|
|
@@ -594,17 +604,11 @@ export class PiAgentFactory {
|
|
|
594
604
|
async logout(providerId) {
|
|
595
605
|
await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
|
|
596
606
|
}
|
|
597
|
-
/**
|
|
598
|
-
* Pi discovers AGENTS.md itself; we append one more, in memory, telling the
|
|
599
|
-
* agent what the surface it is talking to can render. Layered as a context
|
|
600
|
-
* file (not a systemPromptOverride) so the user's own instructions still win.
|
|
601
|
-
*/
|
|
602
607
|
async resourceLoader(cwd) {
|
|
603
608
|
const loader = new DefaultResourceLoader({
|
|
604
609
|
cwd,
|
|
605
|
-
agentDir: defaultAgentDir(),
|
|
606
|
-
// SYSTEM.md
|
|
607
|
-
// Pi's generic default, preserving the user's later instruction layer.
|
|
610
|
+
agentDir: defaultAgentDir(),
|
|
611
|
+
// The user's SYSTEM.md is appended after Pier's baseline, so it still wins.
|
|
608
612
|
systemPromptOverride: pierSystemPrompt,
|
|
609
613
|
additionalSkillPaths: this.skillPaths,
|
|
610
614
|
skillsOverride: (base) => ({
|
|
@@ -633,11 +637,8 @@ export class PiAgentFactory {
|
|
|
633
637
|
}
|
|
634
638
|
async openSnapshot(cwd, sessionManager, opts) {
|
|
635
639
|
let live;
|
|
636
|
-
//
|
|
637
|
-
// configured yet would otherwise cost context on every turn of every
|
|
638
|
-
// session and be able to answer nothing.
|
|
640
|
+
// Per open: an unconfigured tool would cost context on every turn and answer nothing.
|
|
639
641
|
const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
|
|
640
|
-
// Generic translation only — tool contracts are data owned by their feature.
|
|
641
642
|
const customTools = active.map((tool) => defineTool({
|
|
642
643
|
name: tool.name,
|
|
643
644
|
label: tool.label,
|
|
@@ -651,8 +652,7 @@ export class PiAgentFactory {
|
|
|
651
652
|
content: [
|
|
652
653
|
{
|
|
653
654
|
type: "text",
|
|
654
|
-
//
|
|
655
|
-
// costs ~20% more tokens and buys the model nothing.
|
|
655
|
+
// Pretty-printing costs ~20% more tokens and buys the model nothing.
|
|
656
656
|
text: JSON.stringify(await tool.execute(params, caller, signal)),
|
|
657
657
|
},
|
|
658
658
|
],
|
|
@@ -660,25 +660,20 @@ export class PiAgentFactory {
|
|
|
660
660
|
};
|
|
661
661
|
}
|
|
662
662
|
catch (err) {
|
|
663
|
-
// Pi turns this into tool-result text the
|
|
664
|
-
// right recovery and the wrong record: rethrown, but logged first.
|
|
663
|
+
// Pi turns this into tool-result text: the right recovery, the wrong record.
|
|
665
664
|
log.warn(`tool ${tool.name} failed for ${caller}`, err);
|
|
666
665
|
throw err;
|
|
667
666
|
}
|
|
668
667
|
},
|
|
669
668
|
}));
|
|
670
|
-
//
|
|
671
|
-
// configured"
|
|
672
|
-
// appendSessionInfo, so the refused open writes nothing to disk.
|
|
669
|
+
// A locked store is a refusal with a reason here, not "provider not
|
|
670
|
+
// configured" later. Before appendSessionInfo, so nothing is written.
|
|
673
671
|
this.credentials?.assertUnlocked();
|
|
674
672
|
if (opts.name)
|
|
675
673
|
sessionManager.appendSessionInfo(opts.name);
|
|
676
|
-
// This runtime serves
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
// tasks downgrade to "short" (5m) via setCacheRetention. The default sits
|
|
680
|
-
// before the spread so an explicit per-request value still wins —
|
|
681
|
-
// compaction passes cacheRetention: "none" and must keep it.
|
|
674
|
+
// This runtime serves one session, so shadowing streamSimple is the
|
|
675
|
+
// per-session seam for the cache TTL. Default before the spread: compaction
|
|
676
|
+
// passes cacheRetention: "none" and must keep it.
|
|
682
677
|
const runtime = await this.createRuntime();
|
|
683
678
|
const retention = { value: "long" };
|
|
684
679
|
const stream = runtime.streamSimple.bind(runtime);
|
|
@@ -691,16 +686,16 @@ export class PiAgentFactory {
|
|
|
691
686
|
resourceLoader: await this.resourceLoader(cwd),
|
|
692
687
|
});
|
|
693
688
|
live = created.session;
|
|
694
|
-
// Pi
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
// message 11 minutes late (2026-09-07). The agent's own setter only flips
|
|
698
|
-
// the in-memory queue; `session.setFollowUpMode` would persist it to Pi's
|
|
699
|
-
// settings.json on every open and make it global forever.
|
|
689
|
+
// Pi defaults to one follow-up per turn boundary, so N queued messages cost
|
|
690
|
+
// N turns. The agent's setter flips only the in-memory queue;
|
|
691
|
+
// `session.setFollowUpMode` would persist it to Pi's settings.json.
|
|
700
692
|
live.agent.followUpMode = "all";
|
|
701
693
|
const session = new PiSession(live, this.pinned, () => {
|
|
702
694
|
this.listing = undefined;
|
|
703
|
-
}, retention)
|
|
695
|
+
}, retention, () => {
|
|
696
|
+
const model = this.titleModel();
|
|
697
|
+
return model && ((first, reply) => this.suggestTitle(model, first, reply));
|
|
698
|
+
});
|
|
704
699
|
if (opts.model)
|
|
705
700
|
await session.setModel(opts.model);
|
|
706
701
|
if (opts.thinking)
|
|
@@ -710,7 +705,9 @@ export class PiAgentFactory {
|
|
|
710
705
|
}
|
|
711
706
|
async create(opts) {
|
|
712
707
|
this.listing = undefined;
|
|
713
|
-
|
|
708
|
+
// Resolved before Pi records it, so every later listing names it the same way.
|
|
709
|
+
const cwd = realPath(opts.cwd);
|
|
710
|
+
return this.open(cwd, SessionManager.create(cwd), { ...opts, cwd });
|
|
714
711
|
}
|
|
715
712
|
async resume(sessionId) {
|
|
716
713
|
const known = this.located.get(sessionId);
|
|
@@ -719,8 +716,6 @@ export class PiAgentFactory {
|
|
|
719
716
|
return await this.open(known.cwd, SessionManager.open(known.path));
|
|
720
717
|
}
|
|
721
718
|
catch (err) {
|
|
722
|
-
// The file moved or went away under us: the cache was the only thing
|
|
723
|
-
// that claimed otherwise, so drop it and take the slow, true path.
|
|
724
719
|
log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
|
|
725
720
|
this.located.delete(sessionId);
|
|
726
721
|
}
|
|
@@ -730,14 +725,9 @@ export class PiAgentFactory {
|
|
|
730
725
|
throw new Error(`unknown session: ${sessionId}`);
|
|
731
726
|
return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
|
|
732
727
|
}
|
|
733
|
-
/** The
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
* session this factory opened. The miss is what earns a fresh scan, because
|
|
737
|
-
* callers read it as permission to start a replacement session
|
|
738
|
-
* (channels/conversations.ts), which costs a conversation its history, or as
|
|
739
|
-
* a session that no longer exists (tasks/, web/server.ts). `reused` is how we
|
|
740
|
-
* know a scan is owed: same entry back, same disk state. */
|
|
728
|
+
/** The one place "no such session" is decided. A retained listing is not
|
|
729
|
+
* evidence a session is gone, and callers read a miss as permission to start
|
|
730
|
+
* a replacement, so a miss earns a fresh scan. */
|
|
741
731
|
async locate(sessionId) {
|
|
742
732
|
const find = (infos) => infos.find((s) => s.id === sessionId);
|
|
743
733
|
const reused = this.listing;
|
|
@@ -748,11 +738,9 @@ export class PiAgentFactory {
|
|
|
748
738
|
const info = await this.locate(sessionId);
|
|
749
739
|
return info ? summaryOf(info) : undefined;
|
|
750
740
|
}
|
|
751
|
-
/**
|
|
752
|
-
*
|
|
753
|
-
* that format moves under it. */
|
|
741
|
+
/** agent/listing.ts parses Pi's transcripts itself; only a comparison
|
|
742
|
+
* notices when that format moves under it. */
|
|
754
743
|
audited = false;
|
|
755
|
-
/** Every listing goes through here, so it also refreshes `located`. */
|
|
756
744
|
listed(force = false) {
|
|
757
745
|
const now = Date.now();
|
|
758
746
|
if (!force && this.listing && now - this.listing.at < LIST_TTL_MS)
|
|
@@ -778,4 +766,9 @@ export class PiAgentFactory {
|
|
|
778
766
|
async list() {
|
|
779
767
|
return (await this.listed()).map(summaryOf);
|
|
780
768
|
}
|
|
769
|
+
/** After a listing, so a transcript that grew is indexed before it is asked about. */
|
|
770
|
+
async search(query) {
|
|
771
|
+
await this.listed();
|
|
772
|
+
return this.listings.search?.(query) ?? [];
|
|
773
|
+
}
|
|
781
774
|
}
|