@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.
Files changed (95) hide show
  1. package/dist/agent/config-sync.js +12 -1
  2. package/dist/agent/config.js +53 -23
  3. package/dist/agent/events.js +27 -4
  4. package/dist/agent/listing.js +114 -16
  5. package/dist/agent/pi.js +158 -16
  6. package/dist/channels/lark-panel.js +1 -1
  7. package/dist/core/inbox.js +1 -1
  8. package/dist/core/router.js +14 -0
  9. package/dist/core/types.js +13 -1
  10. package/dist/db.js +48 -0
  11. package/dist/drain.js +1 -1
  12. package/dist/extensions/web/content.js +2 -2
  13. package/dist/main.js +8 -1
  14. package/dist/secrets.js +1 -1
  15. package/dist/service.js +2 -2
  16. package/dist/settings.js +31 -10
  17. package/dist/tasks/agent.js +109 -73
  18. package/dist/tasks/callbacks.js +1 -3
  19. package/dist/tasks/command.js +27 -8
  20. package/dist/tasks/execution.js +6 -4
  21. package/dist/tasks/groups.js +35 -26
  22. package/dist/tasks/messages.js +33 -27
  23. package/dist/tasks/outbox.js +48 -21
  24. package/dist/tasks/routes.js +1 -4
  25. package/dist/tasks/runs.js +10 -4
  26. package/dist/tasks/service.js +24 -12
  27. package/dist/tasks/store.js +17 -3
  28. package/dist/tasks/tool.js +97 -25
  29. package/dist/tools.js +2 -2
  30. package/dist/update.js +1 -1
  31. package/dist/web/instance.js +10 -3
  32. package/dist/web/providers.js +9 -2
  33. package/dist/web/public/assets/{activity-DVamP8fs.js → activity-Bl3vZukb.js} +2 -2
  34. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  35. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  36. package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
  37. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  38. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  39. package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
  40. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  41. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  42. package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
  43. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  44. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  45. package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
  46. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  47. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  48. package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
  49. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  50. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  51. package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
  52. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  53. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  54. package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
  55. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  56. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  57. package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
  58. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  59. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
  60. package/dist/web/public/index.html +103 -134
  61. package/dist/web/public/index.html.br +0 -0
  62. package/dist/web/public/index.html.gz +0 -0
  63. package/dist/web/public/manifest.webmanifest +2 -2
  64. package/dist/web/public/manifest.webmanifest.br +0 -0
  65. package/dist/web/public/manifest.webmanifest.gz +0 -0
  66. package/dist/web/server.js +55 -36
  67. package/dist/web/session-state.js +54 -33
  68. package/package.json +2 -1
  69. package/skills/pier-tasks/SKILL.md +146 -127
  70. package/dist/web/public/assets/activity-DVamP8fs.js.br +0 -0
  71. package/dist/web/public/assets/activity-DVamP8fs.js.gz +0 -0
  72. package/dist/web/public/assets/boards-BKtjPwus.js +0 -1
  73. package/dist/web/public/assets/boards-BKtjPwus.js.br +0 -0
  74. package/dist/web/public/assets/boards-BKtjPwus.js.gz +0 -0
  75. package/dist/web/public/assets/explorer-CjO9NAKQ.js +0 -4
  76. package/dist/web/public/assets/explorer-CjO9NAKQ.js.br +0 -0
  77. package/dist/web/public/assets/explorer-CjO9NAKQ.js.gz +0 -0
  78. package/dist/web/public/assets/index-C23sXyS3.css +0 -2
  79. package/dist/web/public/assets/index-C23sXyS3.css.br +0 -0
  80. package/dist/web/public/assets/index-C23sXyS3.css.gz +0 -0
  81. package/dist/web/public/assets/index-DzWnvhXb.js +0 -85
  82. package/dist/web/public/assets/index-DzWnvhXb.js.br +0 -0
  83. package/dist/web/public/assets/index-DzWnvhXb.js.gz +0 -0
  84. package/dist/web/public/assets/runs-CxIZAx1s.js +0 -1
  85. package/dist/web/public/assets/runs-CxIZAx1s.js.br +0 -0
  86. package/dist/web/public/assets/runs-CxIZAx1s.js.gz +0 -0
  87. package/dist/web/public/assets/settings-B8JWLbpL.js +0 -5
  88. package/dist/web/public/assets/settings-B8JWLbpL.js.br +0 -0
  89. package/dist/web/public/assets/settings-B8JWLbpL.js.gz +0 -0
  90. package/dist/web/public/assets/task-runs-BZ1brkLC.js +0 -3
  91. package/dist/web/public/assets/task-runs-BZ1brkLC.js.br +0 -0
  92. package/dist/web/public/assets/task-runs-BZ1brkLC.js.gz +0 -0
  93. package/dist/web/public/assets/tasks-BoYiBQpq.js +0 -4
  94. package/dist/web/public/assets/tasks-BoYiBQpq.js.br +0 -0
  95. 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. */
@@ -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 one field: the default model. Everything
13
- // else in it is machine-local and survives an import untouched.
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(rawSettings),
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 rawSettings = before[3];
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 one pair and never rewritten wholesale. Malformed
463
- * JSON is refused rather than read as "no default": that would publish, or
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(raw) {
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 default replaces the local pair and nothing else; unchanged
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 withDefaultModel(raw, model) {
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
- if (model) {
492
- next.defaultProvider = model.provider;
493
- next.defaultModel = model.id;
494
- }
495
- else {
496
- delete next.defaultProvider;
497
- delete next.defaultModel;
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
  }
@@ -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 === "thinking" && part.thinking) {
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
- // step-only assistant messages keep buffering activity
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)
@@ -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 message entries, each longer than this file.
41
- // Once the first user message is in hand only a rename still matters, and a
42
- // substring test costs a fraction of parsing one of them.
43
- if (acc?.first && !line.includes('"session_info"'))
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" && !acc.first) {
87
+ if (entry?.type === "message") {
66
88
  const message = entry.message;
67
- if (message?.role !== "user")
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
- const text = textOf(message.content).trim();
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 parsed = await this.#read(file.path, row && file.size > row.size
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
  }