@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Credential-blind projection of the portable agent configuration.
|
|
2
|
+
import { isThinkingLevel } from "../core/types.js";
|
|
2
3
|
/** Never leaves the instance, at any depth of a models.json provider: the
|
|
3
4
|
* credentials, and the endpoint they authenticate against — a sharing link is
|
|
4
5
|
* public. Every other field is metadata Pi's own schema already validates, so
|
|
@@ -110,11 +111,19 @@ function normalizeDefaultModel(raw) {
|
|
|
110
111
|
only(ref, ["provider", "id"], "default model");
|
|
111
112
|
return { provider: providerId(ref.provider), id: modelId(ref.id) };
|
|
112
113
|
}
|
|
114
|
+
/** A default reasoning effort is one of the levels Pi accepts, nothing else. */
|
|
115
|
+
function normalizeDefaultThinkingLevel(raw) {
|
|
116
|
+
if (raw === null)
|
|
117
|
+
return null;
|
|
118
|
+
if (!isThinkingLevel(raw))
|
|
119
|
+
throw new Error("default reasoning effort must be a level Pi accepts");
|
|
120
|
+
return raw;
|
|
121
|
+
}
|
|
113
122
|
/** Strict import boundary: a source that states a private field is refused,
|
|
114
123
|
* never trusted as a mask over the local one. */
|
|
115
124
|
export function normalizeAgentSnapshot(raw) {
|
|
116
125
|
const snapshot = record(raw, "agent snapshot");
|
|
117
|
-
only(snapshot, ["files", "providers", "defaultModel"], "agent snapshot");
|
|
126
|
+
only(snapshot, ["files", "providers", "defaultModel", "defaultThinkingLevel"], "agent snapshot");
|
|
118
127
|
const files = record(snapshot.files, "snapshot files");
|
|
119
128
|
only(files, ["SYSTEM.md", "AGENTS.md"], "snapshot files");
|
|
120
129
|
for (const name of ["SYSTEM.md", "AGENTS.md"]) {
|
|
@@ -130,6 +139,8 @@ export function normalizeAgentSnapshot(raw) {
|
|
|
130
139
|
files: { "SYSTEM.md": files["SYSTEM.md"], "AGENTS.md": files["AGENTS.md"] },
|
|
131
140
|
providers,
|
|
132
141
|
...(snapshot.defaultModel !== undefined ? { defaultModel: normalizeDefaultModel(snapshot.defaultModel) } : {}),
|
|
142
|
+
...(snapshot.defaultThinkingLevel !== undefined
|
|
143
|
+
? { defaultThinkingLevel: normalizeDefaultThinkingLevel(snapshot.defaultThinkingLevel) } : {}),
|
|
133
144
|
};
|
|
134
145
|
}
|
|
135
146
|
/** A model or override arrives whole; only the local private fields survive it. */
|
package/dist/agent/config.js
CHANGED
|
@@ -9,8 +9,9 @@ import { pierPath } from "../paths.js";
|
|
|
9
9
|
import { mergeSnapshotProviders, normalizeAgentSnapshot, snapshotProviders } from "./config-sync.js";
|
|
10
10
|
const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
|
|
11
11
|
const PROJECT_FILES = ["AGENTS.md"];
|
|
12
|
-
// settings.json is on the list for
|
|
13
|
-
// else in it is machine-local and survives an
|
|
12
|
+
// settings.json is on the list for two fields: the default model and its
|
|
13
|
+
// reasoning effort. Everything else in it is machine-local and survives an
|
|
14
|
+
// import untouched.
|
|
14
15
|
const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
|
|
15
16
|
const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
|
|
16
17
|
/** Pier owns the Pi runtime dir: config lives in the syncable `~/.pier/pi`
|
|
@@ -19,6 +20,21 @@ const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
|
|
|
19
20
|
export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
|
|
20
21
|
/** Stable mask: mapped back by field, without exposing key fragments. */
|
|
21
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
|
+
}
|
|
22
38
|
const missing = (err) => err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
23
39
|
const readNullable = async (path) => {
|
|
24
40
|
try {
|
|
@@ -130,9 +146,11 @@ export class PiConfigStore {
|
|
|
130
146
|
if (typeof model !== "object" || model === null || typeof model.id !== "string") {
|
|
131
147
|
return [];
|
|
132
148
|
}
|
|
149
|
+
const effort = effortOf(asRecord(model.thinkingLevelMap));
|
|
133
150
|
return [{
|
|
134
151
|
id: model.id,
|
|
135
152
|
reasoning: model.reasoning === true,
|
|
153
|
+
...(effort ? { effort } : {}),
|
|
136
154
|
}];
|
|
137
155
|
})
|
|
138
156
|
: undefined;
|
|
@@ -179,6 +197,11 @@ export class PiConfigStore {
|
|
|
179
197
|
next.reasoning = true;
|
|
180
198
|
else
|
|
181
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;
|
|
182
205
|
return next;
|
|
183
206
|
});
|
|
184
207
|
}
|
|
@@ -217,10 +240,14 @@ export class PiConfigStore {
|
|
|
217
240
|
const parsed = raw?.trim() ? parseModels(raw) : {};
|
|
218
241
|
if (!parsed)
|
|
219
242
|
throw new Error("models.json must be valid JSON before exporting configuration");
|
|
243
|
+
const settings = readSettings(rawSettings);
|
|
220
244
|
return normalizeAgentSnapshot({
|
|
221
245
|
files: { "SYSTEM.md": system, "AGENTS.md": agents },
|
|
222
246
|
providers: snapshotProviders(parsed.providers),
|
|
223
|
-
defaultModel: defaultModelRef(
|
|
247
|
+
defaultModel: defaultModelRef(settings),
|
|
248
|
+
// Unlike the pair, a lone level needs no reading of its own; the
|
|
249
|
+
// snapshot boundary is the one place that says which ones exist.
|
|
250
|
+
defaultThinkingLevel: settings.defaultThinkingLevel ?? null,
|
|
224
251
|
});
|
|
225
252
|
});
|
|
226
253
|
}
|
|
@@ -236,12 +263,7 @@ export class PiConfigStore {
|
|
|
236
263
|
const providers = mergeSnapshotProviders(parsed.providers, incoming.providers);
|
|
237
264
|
const models = JSON.stringify(providers) === JSON.stringify(parsed.providers ?? {})
|
|
238
265
|
? raw : `${JSON.stringify({ ...parsed, providers }, null, 2)}\n`;
|
|
239
|
-
const
|
|
240
|
-
// A source that never states a default (an older one) leaves the local
|
|
241
|
-
// settings.json alone; `null` is a stated "no default" and clears it.
|
|
242
|
-
const settings = incoming.defaultModel === undefined
|
|
243
|
-
? rawSettings
|
|
244
|
-
: withDefaultModel(rawSettings, incoming.defaultModel);
|
|
266
|
+
const settings = withDefaults(before[3], incoming);
|
|
245
267
|
const after = [incoming.files["SYSTEM.md"], incoming.files["AGENTS.md"], models, settings];
|
|
246
268
|
const changes = names.flatMap((name, index) => before[index] === after[index] ? [] : [{
|
|
247
269
|
path: join(this.agentDir, name), before: before[index], after: after[index],
|
|
@@ -459,9 +481,9 @@ function restoreHeaders(incoming, current) {
|
|
|
459
481
|
headers[name] = original;
|
|
460
482
|
}
|
|
461
483
|
}
|
|
462
|
-
/** settings.json is read for
|
|
463
|
-
* JSON is refused rather than read as "no default":
|
|
464
|
-
* import, a cleared default nobody asked for. */
|
|
484
|
+
/** settings.json is read for the deployment defaults and never rewritten
|
|
485
|
+
* wholesale. Malformed JSON is refused rather than read as "no default":
|
|
486
|
+
* that would publish, or import, a cleared default nobody asked for. */
|
|
465
487
|
function readSettings(raw) {
|
|
466
488
|
if (!raw?.trim())
|
|
467
489
|
return {};
|
|
@@ -472,8 +494,7 @@ function readSettings(raw) {
|
|
|
472
494
|
}
|
|
473
495
|
/** Pi writes defaultProvider and defaultModel as a pair; half of one is a hand
|
|
474
496
|
* edit that a shared default cannot represent, so it is refused. */
|
|
475
|
-
function defaultModelRef(
|
|
476
|
-
const settings = readSettings(raw);
|
|
497
|
+
function defaultModelRef(settings) {
|
|
477
498
|
const provider = settings.defaultProvider;
|
|
478
499
|
const id = settings.defaultModel;
|
|
479
500
|
if (provider === undefined && id === undefined)
|
|
@@ -483,18 +504,27 @@ function defaultModelRef(raw) {
|
|
|
483
504
|
}
|
|
484
505
|
return { provider, id };
|
|
485
506
|
}
|
|
486
|
-
/** The incoming
|
|
507
|
+
/** The incoming defaults replace their own fields and nothing else; a field
|
|
508
|
+
* the source never states (an older one) leaves the local settings.json
|
|
509
|
+
* alone, and `null` is a stated "no default" that clears it. Unchanged
|
|
487
510
|
* content returns the original bytes so the import stays a no-op. */
|
|
488
|
-
function
|
|
511
|
+
function withDefaults(raw, incoming) {
|
|
512
|
+
if (incoming.defaultModel === undefined && incoming.defaultThinkingLevel === undefined)
|
|
513
|
+
return raw;
|
|
489
514
|
const settings = readSettings(raw);
|
|
490
515
|
const next = { ...settings };
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
516
|
+
const set = (key, value) => {
|
|
517
|
+
if (value === undefined)
|
|
518
|
+
delete next[key];
|
|
519
|
+
else
|
|
520
|
+
next[key] = value;
|
|
521
|
+
};
|
|
522
|
+
if (incoming.defaultModel !== undefined) {
|
|
523
|
+
set("defaultProvider", incoming.defaultModel?.provider);
|
|
524
|
+
set("defaultModel", incoming.defaultModel?.id);
|
|
525
|
+
}
|
|
526
|
+
if (incoming.defaultThinkingLevel !== undefined) {
|
|
527
|
+
set("defaultThinkingLevel", incoming.defaultThinkingLevel ?? undefined);
|
|
498
528
|
}
|
|
499
529
|
return JSON.stringify(next) === JSON.stringify(settings) ? raw : `${JSON.stringify(next, null, 2)}\n`;
|
|
500
530
|
}
|
package/dist/agent/events.js
CHANGED
|
@@ -3,6 +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
|
+
/** 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");
|
|
6
10
|
export function textOf(content) {
|
|
7
11
|
if (typeof content === "string")
|
|
8
12
|
return content;
|
|
@@ -109,6 +113,7 @@ export function toChatTurns(messages) {
|
|
|
109
113
|
const turns = [];
|
|
110
114
|
let steps = []; // activity seen since the last emitted turn
|
|
111
115
|
const pendingTools = new Map();
|
|
116
|
+
let candidate; // text-only reply, until another assistant message follows
|
|
112
117
|
const flush = (role, text, meta, origin, at) => {
|
|
113
118
|
const turn = { role, text };
|
|
114
119
|
if (meta)
|
|
@@ -142,6 +147,7 @@ export function toChatTurns(messages) {
|
|
|
142
147
|
}
|
|
143
148
|
const origin = systemOrigin(m);
|
|
144
149
|
if (origin) {
|
|
150
|
+
candidate = undefined;
|
|
145
151
|
const text = textOf(m.content);
|
|
146
152
|
if (text)
|
|
147
153
|
flush("system", text, undefined, origin, m.timestamp);
|
|
@@ -149,9 +155,22 @@ export function toChatTurns(messages) {
|
|
|
149
155
|
}
|
|
150
156
|
if (m.role !== "user" && m.role !== "assistant")
|
|
151
157
|
continue;
|
|
158
|
+
if (m.role === "user")
|
|
159
|
+
candidate = undefined;
|
|
160
|
+
else if (candidate) {
|
|
161
|
+
// Some providers separate commentary from the following tool-call message.
|
|
162
|
+
turns.pop();
|
|
163
|
+
steps = candidate.steps ?? [];
|
|
164
|
+
steps.push({ kind: "progress", text: candidate.text });
|
|
165
|
+
candidate = undefined;
|
|
166
|
+
}
|
|
167
|
+
const hasTools = m.role === "assistant" && hasToolCalls(m);
|
|
152
168
|
if (m.role === "assistant" && Array.isArray(m.content)) {
|
|
153
169
|
for (const part of m.content) {
|
|
154
|
-
if (part.type === "
|
|
170
|
+
if (hasTools && part.type === "text" && part.text) {
|
|
171
|
+
steps.push({ kind: "progress", text: part.text });
|
|
172
|
+
}
|
|
173
|
+
else if (part.type === "thinking" && part.thinking) {
|
|
155
174
|
steps.push({ kind: "thinking", text: part.thinking });
|
|
156
175
|
}
|
|
157
176
|
else if (part.type === "toolCall") {
|
|
@@ -168,10 +187,12 @@ export function toChatTurns(messages) {
|
|
|
168
187
|
}
|
|
169
188
|
}
|
|
170
189
|
const text = textOf(m.content);
|
|
171
|
-
//
|
|
172
|
-
if (!text)
|
|
190
|
+
// Tool-bearing messages are intermediate work, even when they include text.
|
|
191
|
+
if (!text || hasTools)
|
|
173
192
|
continue;
|
|
174
193
|
flush(m.role, text, m.role === "assistant" ? turnMetaAt(messages, i) : undefined, undefined, m.timestamp);
|
|
194
|
+
if (m.role === "assistant")
|
|
195
|
+
candidate = turns[turns.length - 1];
|
|
175
196
|
}
|
|
176
197
|
// Activity with no answer after it (aborted run) still belongs on the page.
|
|
177
198
|
if (steps.length)
|
|
@@ -199,7 +220,7 @@ export function toSessionEvents(e) {
|
|
|
199
220
|
? final.errorMessage || "unknown agent error"
|
|
200
221
|
: undefined;
|
|
201
222
|
const out = [
|
|
202
|
-
{ type: "turn-end", text: textOf(final?.content), ...(failure ? { error: failure } : {}) },
|
|
223
|
+
{ type: "turn-end", text: hasToolCalls(final) ? "" : textOf(final?.content), ...(failure ? { error: failure } : {}) },
|
|
203
224
|
];
|
|
204
225
|
if (failure)
|
|
205
226
|
out.push({ type: "error", message: failure });
|
|
@@ -221,6 +242,8 @@ export function toSessionEvents(e) {
|
|
|
221
242
|
const m = e.message;
|
|
222
243
|
if (!m)
|
|
223
244
|
return [];
|
|
245
|
+
if (m.role === "assistant")
|
|
246
|
+
return [{ type: "text-start" }];
|
|
224
247
|
const origin = systemOrigin(m);
|
|
225
248
|
const text = textOf(m.content);
|
|
226
249
|
if (origin)
|
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
|
}
|