@timqi/pier 0.0.29 → 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.js +22 -0
- package/dist/agent/events.js +4 -1
- 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 +4 -0
- package/dist/core/types.js +13 -1
- package/dist/db.js +30 -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-D3m4L2IL.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 +76 -120
- 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 +40 -8
- package/dist/web/session-state.js +5 -5
- package/package.json +2 -1
- package/skills/pier-tasks/SKILL.md +145 -160
- 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/config.js
CHANGED
|
@@ -20,6 +20,21 @@ const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
|
|
|
20
20
|
export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
|
|
21
21
|
/** Stable mask: mapped back by field, without exposing key fragments. */
|
|
22
22
|
const maskKey = (_key) => "••••••••";
|
|
23
|
+
/** Pi reads the two highest levels off the model's own map, so a ceiling is
|
|
24
|
+
* read back from it and written into it — leaving the rest of a hand-written
|
|
25
|
+
* map (an `off: null` that forbids thinking-off) alone, because the Console
|
|
26
|
+
* does not offer it and therefore may not drop it. */
|
|
27
|
+
const effortOf = (map) => typeof map?.max === "string" ? "max" : typeof map?.xhigh === "string" ? "xhigh" : undefined;
|
|
28
|
+
function withEffort(map, effort) {
|
|
29
|
+
const next = { ...map };
|
|
30
|
+
delete next.xhigh;
|
|
31
|
+
delete next.max;
|
|
32
|
+
if (effort === "xhigh" || effort === "max")
|
|
33
|
+
next.xhigh = "xhigh";
|
|
34
|
+
if (effort === "max")
|
|
35
|
+
next.max = "max";
|
|
36
|
+
return Object.keys(next).length ? next : undefined;
|
|
37
|
+
}
|
|
23
38
|
const missing = (err) => err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
24
39
|
const readNullable = async (path) => {
|
|
25
40
|
try {
|
|
@@ -131,9 +146,11 @@ export class PiConfigStore {
|
|
|
131
146
|
if (typeof model !== "object" || model === null || typeof model.id !== "string") {
|
|
132
147
|
return [];
|
|
133
148
|
}
|
|
149
|
+
const effort = effortOf(asRecord(model.thinkingLevelMap));
|
|
134
150
|
return [{
|
|
135
151
|
id: model.id,
|
|
136
152
|
reasoning: model.reasoning === true,
|
|
153
|
+
...(effort ? { effort } : {}),
|
|
137
154
|
}];
|
|
138
155
|
})
|
|
139
156
|
: undefined;
|
|
@@ -180,6 +197,11 @@ export class PiConfigStore {
|
|
|
180
197
|
next.reasoning = true;
|
|
181
198
|
else
|
|
182
199
|
delete next.reasoning;
|
|
200
|
+
const map = withEffort(asRecord(next.thinkingLevelMap), model.effort);
|
|
201
|
+
if (map)
|
|
202
|
+
next.thinkingLevelMap = map;
|
|
203
|
+
else
|
|
204
|
+
delete next.thinkingLevelMap;
|
|
183
205
|
return next;
|
|
184
206
|
});
|
|
185
207
|
}
|
package/dist/agent/events.js
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
// and Pi types never leak past the seam. The golden-table test in
|
|
4
4
|
// events.test.ts is the mapping's spec; extend types.ts before adding events.
|
|
5
5
|
import { isThinkingLevel, MAX_STEP_OUTPUT } from "../core/types.js";
|
|
6
|
-
|
|
6
|
+
/** An assistant message that calls a tool is work in progress, not a reply —
|
|
7
|
+
* the rule `toChatTurns` rebuilds a transcript by, and agent/listing.ts
|
|
8
|
+
* indexes one by. */
|
|
9
|
+
export const hasToolCalls = (message) => Array.isArray(message?.content) && message.content.some((part) => part.type === "toolCall");
|
|
7
10
|
export function textOf(content) {
|
|
8
11
|
if (typeof content === "string")
|
|
9
12
|
return content;
|
package/dist/agent/listing.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
// path and validated by (size, mtime), and a file that grew is picked up at the
|
|
12
12
|
// byte the last scan stopped on.
|
|
13
13
|
//
|
|
14
|
+
// The same pass indexes what was said. Every user message and every reply
|
|
15
|
+
// goes into `session_fts` beside the index row, in the same transaction, so
|
|
16
|
+
// the palette can search messages without a second reader of the same bytes.
|
|
17
|
+
//
|
|
14
18
|
// Reading Pi's on-disk entries is the cost of that; agent/ is where the
|
|
15
19
|
// knowledge of Pi's formats already lives, and Pi's own reader is not
|
|
16
20
|
// incremental.
|
|
@@ -20,7 +24,7 @@ import { pierDb, statements, transact } from "../db.js";
|
|
|
20
24
|
import { SESSION_TITLE_MAX } from "../limits.js";
|
|
21
25
|
import { logger } from "../log.js";
|
|
22
26
|
import { defaultAgentDir } from "./config.js";
|
|
23
|
-
import { textOf } from "./events.js";
|
|
27
|
+
import { hasToolCalls, textOf } from "./events.js";
|
|
24
28
|
const log = logger("agent");
|
|
25
29
|
/** How many of the newest sessions a cross-check reads: enough that a format
|
|
26
30
|
* change surfaces within a boot or two, few enough to cost nothing. */
|
|
@@ -30,17 +34,35 @@ const AUDIT_SAMPLE = 5;
|
|
|
30
34
|
* differently and an object would silently accept either. */
|
|
31
35
|
const titleOf = (name, first) => name || first || undefined;
|
|
32
36
|
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
37
|
+
/** What a search may find: what the person said and what the agent answered.
|
|
38
|
+
* A reply that calls a tool is work in progress, not a reply (events.ts
|
|
39
|
+
* `toChatTurns`) — its text is a step, and steps are not indexed; neither is
|
|
40
|
+
* thinking, a tool result, or a system input. `clean` takes off what was
|
|
41
|
+
* written for the model rather than said — the speaker header a channel
|
|
42
|
+
* prefixes (core/identity.ts), which as text would make "operator" hit every
|
|
43
|
+
* session the workbench ever opened. */
|
|
44
|
+
function saidIn(message, entryAt, clean) {
|
|
45
|
+
if (message?.role !== "user" && message?.role !== "assistant")
|
|
46
|
+
return undefined;
|
|
47
|
+
if (message.role === "assistant" && hasToolCalls(message))
|
|
48
|
+
return undefined;
|
|
49
|
+
const text = clean(textOf(message.content)).trim();
|
|
50
|
+
if (!text)
|
|
51
|
+
return undefined;
|
|
52
|
+
const at = typeof message.timestamp === "number" ? message.timestamp : Date.parse(entryAt ?? "");
|
|
53
|
+
return { role: message.role, at: Number.isFinite(at) ? at : 0, text };
|
|
54
|
+
}
|
|
33
55
|
/**
|
|
34
56
|
* One entry folded into what a listing needs. Three answers, not two:
|
|
35
57
|
* `undefined` is "no header yet" (blank and unparseable lines before it are
|
|
36
58
|
* skipped, as Pi's own reader does), `null` is "not a session file" — a first
|
|
37
59
|
* entry that is not a session header — and anything else is the state so far.
|
|
38
60
|
*/
|
|
39
|
-
function fold(acc, line) {
|
|
40
|
-
// The bulk of a transcript is
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
if (acc
|
|
61
|
+
function fold(acc, line, clean) {
|
|
62
|
+
// The bulk of a transcript is entries this has no use for — tool results,
|
|
63
|
+
// model changes, compaction — each longer than this file. Two substring tests
|
|
64
|
+
// say whether a line could matter, at a fraction of parsing one that does not.
|
|
65
|
+
if (acc && !line.includes('"message"') && !line.includes('"session_info"'))
|
|
44
66
|
return acc;
|
|
45
67
|
let entry;
|
|
46
68
|
try {
|
|
@@ -58,26 +80,55 @@ function fold(acc, line) {
|
|
|
58
80
|
const timestamp = str(entry.timestamp);
|
|
59
81
|
if (entry.type !== "session" || !id || !timestamp)
|
|
60
82
|
return null;
|
|
61
|
-
return { id, cwd: str(entry.cwd) ?? "", created: Date.parse(timestamp), at: 0 };
|
|
83
|
+
return { id, cwd: str(entry.cwd) ?? "", created: Date.parse(timestamp), said: [], at: 0 };
|
|
62
84
|
}
|
|
63
85
|
if (entry?.type === "session_info")
|
|
64
86
|
return { ...acc, name: str(entry.name)?.trim() || undefined };
|
|
65
|
-
if (entry?.type === "message"
|
|
87
|
+
if (entry?.type === "message") {
|
|
66
88
|
const message = entry.message;
|
|
67
|
-
|
|
89
|
+
const said = saidIn(message, str(entry.timestamp), clean);
|
|
90
|
+
if (!said)
|
|
91
|
+
return acc;
|
|
92
|
+
acc.said.push(said);
|
|
93
|
+
// The title keeps the header the index drops: every surface reads it back
|
|
94
|
+
// through core/identity.ts, and a second rule here would be a second rule.
|
|
95
|
+
if (acc.first || said.role !== "user")
|
|
68
96
|
return acc;
|
|
69
|
-
|
|
70
|
-
return text ? { ...acc, first: text.slice(0, SESSION_TITLE_MAX) } : acc;
|
|
97
|
+
return { ...acc, first: textOf(message?.content).trim().slice(0, SESSION_TITLE_MAX) };
|
|
71
98
|
}
|
|
72
99
|
return acc;
|
|
73
100
|
}
|
|
101
|
+
/** Hits a search answers with at most: a picker, not a results page. */
|
|
102
|
+
const SEARCH_LIMIT = 8;
|
|
103
|
+
/** Characters a snippet runs to. A trigram token is one character, so FTS's
|
|
104
|
+
* `snippet()` is asked for its ceiling of 64 tokens — the default 12 fits one
|
|
105
|
+
* word and its neighbours — and the LIKE path centres the same span itself. */
|
|
106
|
+
const SNIPPET_CHARS = 64;
|
|
107
|
+
/** The match and its neighbourhood, delimited like `snippet()` does it
|
|
108
|
+
* (\u0001 … \u0002, `…` for a cut), so a surface draws one shape for both
|
|
109
|
+
* paths. Case folds ASCII only — LIKE found the row by the same rule. */
|
|
110
|
+
function around(text, query) {
|
|
111
|
+
const hit = text.toLowerCase().indexOf(query.toLowerCase());
|
|
112
|
+
if (hit < 0)
|
|
113
|
+
return text.slice(0, SNIPPET_CHARS);
|
|
114
|
+
const start = Math.max(0, hit - SNIPPET_CHARS / 2);
|
|
115
|
+
const end = Math.min(text.length, hit + query.length + SNIPPET_CHARS / 2);
|
|
116
|
+
return `${start ? "…" : ""}${text.slice(start, hit)}\u0001${text.slice(hit, hit + query.length)}\u0002${text.slice(hit + query.length, end)}${end < text.length ? "…" : ""}`;
|
|
117
|
+
}
|
|
74
118
|
/** The listing Pier runs on: Pi's session directory, remembered in pier.db. */
|
|
75
119
|
export class IndexedListing {
|
|
76
120
|
dir;
|
|
121
|
+
clean;
|
|
77
122
|
#db;
|
|
78
123
|
#statements;
|
|
79
|
-
constructor(dir = join(defaultAgentDir(), "sessions"), db
|
|
124
|
+
constructor(dir = join(defaultAgentDir(), "sessions"), db,
|
|
125
|
+
/** What to take off a message before it is indexed as said. Handed in
|
|
126
|
+
* rather than imported: the header rule is core's (identity.ts), and
|
|
127
|
+
* agent/ does not import core at runtime — main.ts, which imports both,
|
|
128
|
+
* joins them. */
|
|
129
|
+
clean = (text) => text) {
|
|
80
130
|
this.dir = dir;
|
|
131
|
+
this.clean = clean;
|
|
81
132
|
this.#db = db;
|
|
82
133
|
}
|
|
83
134
|
/** Opened on the first scan rather than in the constructor: building an
|
|
@@ -114,13 +165,15 @@ export class IndexedListing {
|
|
|
114
165
|
// length with a different mtime, which resuming would answer with the
|
|
115
166
|
// old derived data stamped with the new mtime: a row that then matches on
|
|
116
167
|
// every later scan and is never corrected.
|
|
117
|
-
const
|
|
168
|
+
const resumed = row !== undefined && file.size > row.size;
|
|
169
|
+
const parsed = await this.#read(file.path, resumed
|
|
118
170
|
? {
|
|
119
171
|
id: row.id,
|
|
120
172
|
cwd: row.cwd,
|
|
121
173
|
created: row.created_at,
|
|
122
174
|
...(row.name === null ? {} : { name: row.name }),
|
|
123
175
|
...(row.first_message === null ? {} : { first: row.first_message }),
|
|
176
|
+
said: [],
|
|
124
177
|
at: row.parsed_bytes,
|
|
125
178
|
}
|
|
126
179
|
: undefined);
|
|
@@ -136,6 +189,8 @@ export class IndexedListing {
|
|
|
136
189
|
size: file.size,
|
|
137
190
|
mtime: file.modified,
|
|
138
191
|
parsed_bytes: parsed.at,
|
|
192
|
+
said: parsed.said,
|
|
193
|
+
whole: !resumed,
|
|
139
194
|
});
|
|
140
195
|
const title = titleOf(parsed.name, parsed.first);
|
|
141
196
|
records.push({
|
|
@@ -181,6 +236,39 @@ export class IndexedListing {
|
|
|
181
236
|
this.#save([], stale);
|
|
182
237
|
return stale.length;
|
|
183
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Sessions by what was said in them, best hit first and one per session.
|
|
241
|
+
* Three characters or more is a trigram phrase, ranked by FTS; shorter is a
|
|
242
|
+
* substring scan, newest first — the tokenizer has nothing to match under
|
|
243
|
+
* three, and a two-character query is what a CJK word often is. Counted in
|
|
244
|
+
* code points: the tokenizer counts characters, not UTF-16 units.
|
|
245
|
+
*/
|
|
246
|
+
search(query, limit = SEARCH_LIMIT) {
|
|
247
|
+
const sql = this.#sql();
|
|
248
|
+
const rows = [...query].length >= 3
|
|
249
|
+
// A quoted phrase: the query is a string to find, never FTS syntax.
|
|
250
|
+
? sql(`SELECT session_id, role, at, snippet(session_fts, 0, char(1), char(2), '…', ${SNIPPET_CHARS}) AS snippet
|
|
251
|
+
FROM session_fts WHERE text MATCH ? ORDER BY bm25(session_fts), at DESC`).iterate(`"${query.replaceAll('"', '""')}"`)
|
|
252
|
+
: sql(`SELECT session_id, role, at, text FROM session_fts WHERE text LIKE ? ESCAPE '\\' ORDER BY at DESC`).iterate(`%${query.replaceAll(/[\\%_]/g, "\\$&")}%`);
|
|
253
|
+
const hits = [];
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
// Walked, not fetched: a chatty session has hundreds of rows for one hit,
|
|
256
|
+
// and the walk stops at the first row of the `limit`th session.
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
if (seen.has(row.session_id))
|
|
259
|
+
continue;
|
|
260
|
+
seen.add(row.session_id);
|
|
261
|
+
hits.push({
|
|
262
|
+
sessionId: row.session_id,
|
|
263
|
+
role: row.role,
|
|
264
|
+
at: row.at,
|
|
265
|
+
snippet: row.snippet ?? around(row.text ?? "", query),
|
|
266
|
+
});
|
|
267
|
+
if (hits.length >= limit)
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
return hits;
|
|
271
|
+
}
|
|
184
272
|
/** Every session file with its size and mtime — the only syscalls a scan
|
|
185
273
|
* where nothing changed makes. */
|
|
186
274
|
async #files() {
|
|
@@ -211,7 +299,7 @@ export class IndexedListing {
|
|
|
211
299
|
const line = buffer.slice(0, nl);
|
|
212
300
|
buffer = buffer.slice(nl + 1);
|
|
213
301
|
at += Buffer.byteLength(line) + 1;
|
|
214
|
-
const next = fold(acc, line);
|
|
302
|
+
const next = fold(acc, line, this.clean);
|
|
215
303
|
if (next === null)
|
|
216
304
|
return null;
|
|
217
305
|
acc = next;
|
|
@@ -227,12 +315,16 @@ export class IndexedListing {
|
|
|
227
315
|
return acc ? { ...acc, at } : null;
|
|
228
316
|
}
|
|
229
317
|
/** One transaction, after all reading: a half-written index would hand the
|
|
230
|
-
* next scan a size it never parsed to
|
|
318
|
+
* next scan a size it never parsed to — or a search rows the index row does
|
|
319
|
+
* not account for. A file read whole drops what it had said before, since
|
|
320
|
+
* its bytes are all being said again; a resumed read only adds. */
|
|
231
321
|
#save(rows, gone) {
|
|
232
322
|
if (!rows.length && !gone.length)
|
|
233
323
|
return;
|
|
234
324
|
const db = this.#store();
|
|
235
325
|
const sql = this.#sql();
|
|
326
|
+
const forget = sql("DELETE FROM session_fts WHERE path = ?");
|
|
327
|
+
const say = sql("INSERT INTO session_fts(text, session_id, path, role, at) VALUES (?, ?, ?, ?, ?)");
|
|
236
328
|
const upsert = sql(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
|
|
237
329
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
238
330
|
ON CONFLICT(path) DO UPDATE SET
|
|
@@ -243,10 +335,16 @@ export class IndexedListing {
|
|
|
243
335
|
const drop = sql("DELETE FROM session_index WHERE path = ?");
|
|
244
336
|
transact(db, () => {
|
|
245
337
|
for (const r of rows) {
|
|
338
|
+
if (r.whole)
|
|
339
|
+
forget.run(r.path);
|
|
340
|
+
for (const s of r.said)
|
|
341
|
+
say.run(s.text, r.id, r.path, s.role, s.at);
|
|
246
342
|
upsert.run(r.path, r.id, r.cwd, r.created_at, r.name, r.first_message, r.size, r.mtime, r.parsed_bytes);
|
|
247
343
|
}
|
|
248
|
-
for (const path of gone)
|
|
344
|
+
for (const path of gone) {
|
|
249
345
|
drop.run(path);
|
|
346
|
+
forget.run(path);
|
|
347
|
+
}
|
|
250
348
|
});
|
|
251
349
|
}
|
|
252
350
|
}
|
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
|
@@ -251,6 +251,10 @@ export class Router {
|
|
|
251
251
|
state: payload.state,
|
|
252
252
|
});
|
|
253
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" });
|
|
254
258
|
// An error the session itself reported (a tool that threw, a model
|
|
255
259
|
// refusal, a lost connection). Without this it lands only in the web
|
|
256
260
|
// timeline and the IM side goes quiet for no visible reason.
|
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;
|