@timqi/pier 0.0.29 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -1,46 +1,40 @@
1
- // Which sessions exist on disk, and what to call them — without reading a
2
- // transcript that has not changed since the last scan.
3
- //
4
- // Pi's SessionManager.listAll() parses every line of every session file to
5
- // answer that: ~250ms for 84 sessions of 30MB here, growing with the total
6
- // bytes ever written rather than with the number of sessions. Every surface
7
- // asks (the rail, Activity, a task lookup, every reconnect), so the scan sits
8
- // on the interactive path and gets slower for the life of the instance. But a
9
- // transcript is append-only and the facts a listing needs are all derived from
10
- // bytes already read, so each byte is read once: rows live in pier.db, keyed by
11
- // path and validated by (size, mtime), and a file that grew is picked up at the
12
- // byte the last scan stopped on.
13
- //
14
- // Reading Pi's on-disk entries is the cost of that; agent/ is where the
15
- // knowledge of Pi's formats already lives, and Pi's own reader is not
16
- // incremental.
1
+ // Which sessions exist on disk and what to call them, reading each transcript
2
+ // byte once: Pi's own listing parses every file whole (~250ms for 30MB) and
3
+ // every surface asks. Rows live in pier.db keyed by path and validated by
4
+ // (size, mtime); a file that grew resumes where the last scan stopped. The same
5
+ // pass indexes what was said into `session_fts` for the palette.
17
6
  import { createReadStream, promises as fs } from "node:fs";
18
7
  import { join } from "node:path";
19
8
  import { pierDb, statements, transact } from "../db.js";
20
- import { SESSION_TITLE_MAX } from "../limits.js";
9
+ import { SESSION_TITLE_MAX } from "../core/types.js";
21
10
  import { logger } from "../log.js";
22
11
  import { defaultAgentDir } from "./config.js";
23
- import { textOf } from "./events.js";
12
+ import { hasToolCalls, textOf } from "./events.js";
24
13
  const log = logger("agent");
25
- /** How many of the newest sessions a cross-check reads: enough that a format
26
- * change surfaces within a boot or two, few enough to cost nothing. */
14
+ /** Enough that a format change surfaces within a boot or two. */
27
15
  const AUDIT_SAMPLE = 5;
28
- /** The name a session was given, else its first message. Two arguments rather
29
- * than a row, because the stored row and the parsed one spell the second one
30
- * differently and an object would silently accept either. */
31
16
  const titleOf = (name, first) => name || first || undefined;
32
17
  const str = (v) => (typeof v === "string" ? v : undefined);
33
- /**
34
- * One entry folded into what a listing needs. Three answers, not two:
35
- * `undefined` is "no header yet" (blank and unparseable lines before it are
36
- * skipped, as Pi's own reader does), `null` is "not a session file" — a first
37
- * entry that is not a session header — and anything else is the state so far.
38
- */
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"'))
18
+ /** A reply that calls a tool is a step, not a reply (events.ts), and steps are
19
+ * not indexed. `clean` takes off the speaker header (core/identity.ts), which
20
+ * as text would make "operator" hit every session. */
21
+ function saidIn(message, entryAt, clean) {
22
+ if (message?.role !== "user" && message?.role !== "assistant")
23
+ return undefined;
24
+ if (message.role === "assistant" && hasToolCalls(message))
25
+ return undefined;
26
+ const text = clean(textOf(message.content)).trim();
27
+ if (!text)
28
+ return undefined;
29
+ const at = typeof message.timestamp === "number" ? message.timestamp : Date.parse(entryAt ?? "");
30
+ return { role: message.role, at: Number.isFinite(at) ? at : 0, text };
31
+ }
32
+ /** `undefined` is "no header yet" (leading junk is skipped, as Pi's reader
33
+ * does), `null` is "not a session file". */
34
+ function fold(acc, line, clean) {
35
+ // The bulk of a transcript is tool results; two substring tests cost a
36
+ // fraction of parsing one.
37
+ if (acc && !line.includes('"message"') && !line.includes('"session_info"'))
44
38
  return acc;
45
39
  let entry;
46
40
  try {
@@ -49,6 +43,7 @@ function fold(acc, line) {
49
43
  entry = value;
50
44
  }
51
45
  catch {
46
+ // The tail of a transcript Pi is still appending to; the next scan reads it whole.
52
47
  return acc ?? undefined;
53
48
  }
54
49
  if (!acc) {
@@ -58,35 +53,57 @@ function fold(acc, line) {
58
53
  const timestamp = str(entry.timestamp);
59
54
  if (entry.type !== "session" || !id || !timestamp)
60
55
  return null;
61
- return { id, cwd: str(entry.cwd) ?? "", created: Date.parse(timestamp), at: 0 };
56
+ return { id, cwd: str(entry.cwd) ?? "", created: Date.parse(timestamp), said: [], at: 0 };
62
57
  }
63
58
  if (entry?.type === "session_info")
64
59
  return { ...acc, name: str(entry.name)?.trim() || undefined };
65
- if (entry?.type === "message" && !acc.first) {
60
+ if (entry?.type === "message") {
66
61
  const message = entry.message;
67
- if (message?.role !== "user")
62
+ const said = saidIn(message, str(entry.timestamp), clean);
63
+ if (!said)
64
+ return acc;
65
+ acc.said.push(said);
66
+ // The title keeps the header the index drops; surfaces read it back
67
+ // through core/identity.ts.
68
+ if (acc.first || said.role !== "user")
68
69
  return acc;
69
- const text = textOf(message.content).trim();
70
- return text ? { ...acc, first: text.slice(0, SESSION_TITLE_MAX) } : acc;
70
+ return { ...acc, first: textOf(message?.content).trim().slice(0, SESSION_TITLE_MAX) };
71
71
  }
72
72
  return acc;
73
73
  }
74
- /** The listing Pier runs on: Pi's session directory, remembered in pier.db. */
74
+ /** A picker, not a results page. */
75
+ const SEARCH_LIMIT = 8;
76
+ /** A trigram token is one character, so `snippet()` is asked for its ceiling
77
+ * of 64 tokens; the default 12 fits one word. */
78
+ const SNIPPET_CHARS = 64;
79
+ /** Delimited like `snippet()` (\u0001 … \u0002, `…` for a cut), so a surface
80
+ * draws one shape for both paths. ASCII case folding: LIKE found the row by
81
+ * the same rule. */
82
+ function around(text, query) {
83
+ const hit = text.toLowerCase().indexOf(query.toLowerCase());
84
+ if (hit < 0)
85
+ return text.slice(0, SNIPPET_CHARS);
86
+ const start = Math.max(0, hit - SNIPPET_CHARS / 2);
87
+ const end = Math.min(text.length, hit + query.length + SNIPPET_CHARS / 2);
88
+ return `${start ? "…" : ""}${text.slice(start, hit)}\u0001${text.slice(hit, hit + query.length)}\u0002${text.slice(hit + query.length, end)}${end < text.length ? "…" : ""}`;
89
+ }
75
90
  export class IndexedListing {
76
91
  dir;
92
+ clean;
77
93
  #db;
78
94
  #statements;
79
- constructor(dir = join(defaultAgentDir(), "sessions"), db) {
95
+ constructor(dir = join(defaultAgentDir(), "sessions"), db,
96
+ /** Handed in rather than imported: the header rule is core's, and agent/
97
+ * does not import core at runtime. */
98
+ clean = (text) => text) {
80
99
  this.dir = dir;
100
+ this.clean = clean;
81
101
  this.#db = db;
82
102
  }
83
- /** Opened on the first scan rather than in the constructor: building an
84
- * agent factory must not open the instance database — tests build bare ones
85
- * and never list. */
103
+ /** Opened on the first scan: building a factory must not open the database. */
86
104
  #store() {
87
105
  return (this.#db ??= pierDb());
88
106
  }
89
- /** The scan's three statements, compiled on the first scan and not again. */
90
107
  #sql() {
91
108
  return (this.#statements ??= statements(this.#store()));
92
109
  }
@@ -109,18 +126,17 @@ export class IndexedListing {
109
126
  });
110
127
  continue;
111
128
  }
112
- // Only a file that *grew* can be resumed mid-way. Anything else is read
113
- // whole — new, truncated, or rewritten. Rewritten includes the same
114
- // length with a different mtime, which resuming would answer with the
115
- // old derived data stamped with the new mtime: a row that then matches on
116
- // every later scan and is never corrected.
117
- const parsed = await this.#read(file.path, row && file.size > row.size
129
+ // Only a file that grew can be resumed. Same length with a new mtime is
130
+ // rewritten, and resuming would stamp old data with the new mtime forever.
131
+ const resumed = row !== undefined && file.size > row.size;
132
+ const parsed = await this.#read(file.path, resumed
118
133
  ? {
119
134
  id: row.id,
120
135
  cwd: row.cwd,
121
136
  created: row.created_at,
122
137
  ...(row.name === null ? {} : { name: row.name }),
123
138
  ...(row.first_message === null ? {} : { first: row.first_message }),
139
+ said: [],
124
140
  at: row.parsed_bytes,
125
141
  }
126
142
  : undefined);
@@ -136,6 +152,8 @@ export class IndexedListing {
136
152
  size: file.size,
137
153
  mtime: file.modified,
138
154
  parsed_bytes: parsed.at,
155
+ said: parsed.said,
156
+ whole: !resumed,
139
157
  });
140
158
  const title = titleOf(parsed.name, parsed.first);
141
159
  records.push({
@@ -150,14 +168,9 @@ export class IndexedListing {
150
168
  this.#save(write, [...known.keys()]);
151
169
  return records.sort((a, b) => b.modified - a.modified);
152
170
  }
153
- /**
154
- * The reason this file is allowed to exist: it parses Pi's transcripts
155
- * itself, and nothing but a comparison keeps it honest when that format
156
- * moves. Reads the newest few both ways; a disagreement is logged with both
157
- * answers and the index row is dropped, so the next scan reads that file
158
- * whole rather than trusting what this one derived. Returns how many
159
- * disagreed — off the interactive path, never blocking a boot.
160
- */
171
+ /** This file parses Pi's transcripts itself, and only a comparison keeps it
172
+ * honest when the format moves. A disagreement drops the index row, so the
173
+ * next scan reads that file whole. */
161
174
  async audit(native) {
162
175
  const sample = (await this.scan()).slice(0, AUDIT_SAMPLE);
163
176
  if (!sample.length)
@@ -181,8 +194,33 @@ export class IndexedListing {
181
194
  this.#save([], stale);
182
195
  return stale.length;
183
196
  }
184
- /** Every session file with its size and mtime — the only syscalls a scan
185
- * where nothing changed makes. */
197
+ /** Under three code points the trigram tokenizer has nothing to match, and a
198
+ * two-character query is what a CJK word often is: substring scan instead. */
199
+ search(query, limit = SEARCH_LIMIT) {
200
+ const sql = this.#sql();
201
+ const rows = [...query].length >= 3
202
+ // Quoted: the query is a string to find, never FTS syntax.
203
+ ? sql(`SELECT session_id, role, at, snippet(session_fts, 0, char(1), char(2), '…', ${SNIPPET_CHARS}) AS snippet
204
+ FROM session_fts WHERE text MATCH ? ORDER BY bm25(session_fts), at DESC`).iterate(`"${query.replaceAll('"', '""')}"`)
205
+ : sql(`SELECT session_id, role, at, text FROM session_fts WHERE text LIKE ? ESCAPE '\\' ORDER BY at DESC`).iterate(`%${query.replaceAll(/[\\%_]/g, "\\$&")}%`);
206
+ const hits = [];
207
+ const seen = new Set();
208
+ // Walked, not fetched: a chatty session has hundreds of rows for one hit.
209
+ for (const row of rows) {
210
+ if (seen.has(row.session_id))
211
+ continue;
212
+ seen.add(row.session_id);
213
+ hits.push({
214
+ sessionId: row.session_id,
215
+ role: row.role,
216
+ at: row.at,
217
+ snippet: row.snippet ?? around(row.text ?? "", query),
218
+ });
219
+ if (hits.length >= limit)
220
+ break;
221
+ }
222
+ return hits;
223
+ }
186
224
  async #files() {
187
225
  const entries = await fs.readdir(this.dir, { withFileTypes: true }).catch(() => []);
188
226
  const dirs = entries.filter((e) => e.isDirectory() || e.isSymbolicLink());
@@ -199,7 +237,6 @@ export class IndexedListing {
199
237
  }));
200
238
  return found.flat().filter((f) => !!f);
201
239
  }
202
- /** From `from.at`, or from the start when there is nothing to resume. */
203
240
  async #read(path, from) {
204
241
  let acc = from;
205
242
  let at = from?.at ?? 0;
@@ -211,7 +248,7 @@ export class IndexedListing {
211
248
  const line = buffer.slice(0, nl);
212
249
  buffer = buffer.slice(nl + 1);
213
250
  at += Buffer.byteLength(line) + 1;
214
- const next = fold(acc, line);
251
+ const next = fold(acc, line, this.clean);
215
252
  if (next === null)
216
253
  return null;
217
254
  acc = next;
@@ -219,20 +256,22 @@ export class IndexedListing {
219
256
  }
220
257
  }
221
258
  catch (err) {
222
- // A file that cannot be read is left out of this listing and out of the
223
- // index, so the next scan tries it again rather than remembering a gap.
259
+ // Left out of the index too, so the next scan tries it again.
224
260
  log.warn(`session file ${path} could not be read`, err);
225
261
  return null;
226
262
  }
227
263
  return acc ? { ...acc, at } : null;
228
264
  }
229
- /** One transaction, after all reading: a half-written index would hand the
230
- * next scan a size it never parsed to. */
265
+ /** One transaction after all reading: a half-written index would hand the
266
+ * next scan a size it never parsed to. A whole read replaces what the file
267
+ * had said; a resumed read only adds. */
231
268
  #save(rows, gone) {
232
269
  if (!rows.length && !gone.length)
233
270
  return;
234
271
  const db = this.#store();
235
272
  const sql = this.#sql();
273
+ const forget = sql("DELETE FROM session_fts WHERE path = ?");
274
+ const say = sql("INSERT INTO session_fts(text, session_id, path, role, at) VALUES (?, ?, ?, ?, ?)");
236
275
  const upsert = sql(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
237
276
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
238
277
  ON CONFLICT(path) DO UPDATE SET
@@ -243,10 +282,16 @@ export class IndexedListing {
243
282
  const drop = sql("DELETE FROM session_index WHERE path = ?");
244
283
  transact(db, () => {
245
284
  for (const r of rows) {
285
+ if (r.whole)
286
+ forget.run(r.path);
287
+ for (const s of r.said)
288
+ say.run(s.text, r.id, r.path, s.role, s.at);
246
289
  upsert.run(r.path, r.id, r.cwd, r.created_at, r.name, r.first_message, r.size, r.mtime, r.parsed_bytes);
247
290
  }
248
- for (const path of gone)
291
+ for (const path of gone) {
249
292
  drop.run(path);
293
+ forget.run(path);
294
+ }
250
295
  });
251
296
  }
252
297
  }