@fitchmultz/pi-subagents 0.44.5 → 0.44.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.44.6] - 2026-10-09
4
+
5
+ ### Fixed
6
+ - Keep full Agents pages available for long native histories by sharing bounded configuration results across rows and repeated queries. Committed publication, source generations and exact terminal/time/leaf boundaries invalidate reuse; strict errors and source integrity checks remain unchanged.
7
+ - Restrict scoped saved-text search to eligible records before aggregating matches, retaining the complete corpus for global relevance scores, cross-field word matching, phrases, attempt boundaries and pagination.
8
+ - Publish one settled-freshness notification for unchanged history census work instead of one per run/source. Real commits, canonical failures and source error/recovery observations still notify; polling, census and watches remain active.
9
+
3
10
  ## [0.44.5] - 2026-10-08
4
11
 
5
12
  ### Added
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.44.5","sha256":"53f1d8cd3c71b05fdfc903bfcecd06eb5d80bb367a16a09d235199d3f93266f2"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.44.6","sha256":"e9a0bfe1bf57b2b67e3b32c6f449996dadf8bc897fe2577eea3d919158b40466"});
@@ -7,7 +7,9 @@ import { hash, HistoryStore, integer, pageLimit } from "./store.js";
7
7
  export class HistoryQueries {
8
8
  store;
9
9
  info;
10
+ configurations = new Map();
10
11
  constructor(store, info) { this.store = store; this.info = info; }
12
+ sourcesRemoved() { this.configurations.clear(); }
11
13
  cursor(query, keys, offset) {
12
14
  return Buffer.from(JSON.stringify({ generation: this.store.generation, owner: this.store.owner, version: this.info().version, query, keys, offset })).toString("base64url");
13
15
  }
@@ -120,6 +122,13 @@ export class HistoryQueries {
120
122
  return {};
121
123
  throw error;
122
124
  }
125
+ // Only committed source changes invalidate ancestry, not unrelated run projections.
126
+ // Validate boundaries before reuse; explicit null leaves returned above remain empty.
127
+ const leafKey = input.leaf === undefined ? null : [typeof input.leaf, String(input.leaf)];
128
+ const key = JSON.stringify([source.id, source.generation, source.cursor, source.stamp, input.terminalEntryId || null, input.terminalEntryId ? null : input.endedAt ?? null, leafKey, strict]);
129
+ const cached = this.configurations.get(key);
130
+ if (cached)
131
+ return { ...cached };
123
132
  const leaf = input.leaf ?? this.store.get(`SELECT id FROM entries WHERE ${boundary.clauses.join(" AND ")} ORDER BY start DESC LIMIT 1`, ...boundary.params)?.id;
124
133
  if (!leaf)
125
134
  return {};
@@ -139,7 +148,12 @@ export class HistoryQueries {
139
148
  return {};
140
149
  throw new HistoryIndexError("MALFORMED_ANCESTRY", "Selected native configuration has cyclic ancestry.");
141
150
  }
142
- return { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
151
+ const configuration = { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
152
+ // Bound memory across arbitrary sources, boundaries and pages. Eviction only recomputes.
153
+ if (this.configurations.size >= 128)
154
+ this.configurations.delete(this.configurations.keys().next().value);
155
+ this.configurations.set(key, configuration);
156
+ return { ...configuration };
143
157
  }
144
158
  historyPage(input) {
145
159
  const { source } = this.child(input.runId, input.index);
@@ -224,6 +238,12 @@ export class HistoryQueries {
224
238
  clauses.push("c.agent=?");
225
239
  params.push(input.agent);
226
240
  }
241
+ const joins = "JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1";
242
+ // Scope records before materializing/grouping hits, while bm25 still sees the
243
+ // complete corpus. Scan each linked source once, not once per continuation.
244
+ // Attempt boundaries, attribution and seek filtering remain in the final join.
245
+ const eligible = input.runId || input.agent ? `eligible AS MATERIALIZED (SELECT e.rowid FROM entries e JOIN sources s ON s.id=e.source_id WHERE e.published=1 AND e.generation=s.generation AND e.source_id IN (SELECT c.source_id FROM children c WHERE ${clauses.slice(4).join(" AND ")})),` : "";
246
+ const eligibleParams = eligible ? params.slice(expression.length) : [];
227
247
  // Ordering/filters apply globally before limit. Common terms may still require an FTS
228
248
  // scan/sort; the parent enforces a hard deadline by killing this separate process.
229
249
  const score = sort === "relevance" ? "matched.score" : "-COALESCE(e.timestamp,0)";
@@ -235,8 +255,8 @@ export class HistoryQueries {
235
255
  }
236
256
  // Match words across all visible fields/windows of one native record. A quoted
237
257
  // phrase remains a single FTS expression, so it cannot span unrelated fields.
238
- const rows = this.store.all(`WITH hits AS MATERIALIZED (
239
- ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?`).join(" UNION ALL ")}
258
+ const rows = this.store.all(`WITH ${eligible} hits AS MATERIALIZED (
259
+ ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?${eligible ? " AND d.entry_rowid IN (SELECT rowid FROM eligible)" : ""}`).join(" UNION ALL ")}
240
260
  ), terms AS (
241
261
  SELECT entry_rowid,term,MIN(score) AS score FROM hits GROUP BY entry_rowid,term
242
262
  ), matched AS (
@@ -244,7 +264,7 @@ export class HistoryQueries {
244
264
  ), excerpts AS (
245
265
  SELECT id,entry_rowid,ROW_NUMBER() OVER (PARTITION BY entry_rowid ORDER BY score,id) AS choice FROM hits
246
266
  )
247
- SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1 WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...params, limit + 1);
267
+ SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid ${joins} WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...eligibleParams, ...params, limit + 1);
248
268
  const more = rows.length > limit, page = rows.slice(0, limit);
249
269
  return { ...this.info(), matches: page.map((row) => ({ id: row.document_id, runId: row.run_id, index: row.child_index, agent: row.agent, entryId: row.id, nativeId: row.native_id, sessionId: row.session_id, sessionFile: row.path, timestamp: row.timestamp,
250
270
  ref: { sourceId: row.source_id, generation: row.generation, start: row.start, end: row.end, digest: row.digest }, preview: row.document_preview, field: row.field, textStart: row.text_start, textEnd: row.text_end, score: row.score })),
@@ -18,6 +18,8 @@ const sourceWatchers = new Map();
18
18
  const watchIdentities = new WeakMap();
19
19
  const watchedSources = new Map();
20
20
  let job, scheduled = false, closed = false;
21
+ let sourceObservation;
22
+ let notifiedVersion;
21
23
  let retryTimer;
22
24
  let runErrors = new Set();
23
25
  const barriers = [];
@@ -31,7 +33,15 @@ function send(response) {
31
33
  process.send(response, undefined, undefined, (error) => { if (error)
32
34
  shutdown(); });
33
35
  }
34
- function changed() { send({ changed: true }); }
36
+ function changed(observationChanged = false) {
37
+ const version = store?.get("SELECT value FROM meta WHERE key='version'").value;
38
+ // Unchanged census projections need only one final freshness notification.
39
+ // Non-versioned errors/recovery and real commits still publish immediately.
40
+ if (observationChanged || version !== notifiedVersion || !hasPending()) {
41
+ notifiedVersion = version;
42
+ send({ changed: true });
43
+ }
44
+ }
35
45
  function errorReply(id, error) {
36
46
  const code = error instanceof HistoryIndexError ? error.code : "UNAVAILABLE";
37
47
  // Parser errors can include fragments from the malformed body. IPC/logs must never carry them.
@@ -191,8 +201,11 @@ function prune(previousSources) {
191
201
  store.run("DELETE FROM sources WHERE id=?", source.id);
192
202
  removed = true;
193
203
  }
194
- if (removed)
204
+ if (removed) {
195
205
  store.bump();
206
+ // A later admission of the same path can recreate source generation 1.
207
+ queries?.sourcesRemoved();
208
+ }
196
209
  });
197
210
  }
198
211
  function admit(run) {
@@ -247,6 +260,7 @@ function pump() {
247
260
  if (dirtyRuns.size) {
248
261
  const id = dirtyRuns.values().next().value;
249
262
  dirtyRuns.delete(id);
263
+ const failed = runErrors.has(id);
250
264
  installWatches(id);
251
265
  const previousSources = store.all("SELECT source_id FROM children WHERE run_id=? AND source_id IS NOT NULL", id).map((child) => child.source_id);
252
266
  try {
@@ -265,7 +279,7 @@ function pump() {
265
279
  if (child.source_id !== job?.id)
266
280
  dirtySources.set(child.source_id, dirtySources.get(child.source_id) ?? false);
267
281
  installWatches(id);
268
- changed();
282
+ changed(failed !== runErrors.has(id));
269
283
  }
270
284
  else if (job) {
271
285
  if (job.step()) {
@@ -273,7 +287,8 @@ function pump() {
273
287
  if (source)
274
288
  watchSources(path.dirname(source.path));
275
289
  job = undefined;
276
- changed();
290
+ changed(source?.state !== sourceObservation?.state || source?.error !== sourceObservation?.error);
291
+ sourceObservation = undefined;
277
292
  }
278
293
  }
279
294
  else if (dirtySources.size) {
@@ -282,6 +297,7 @@ function pump() {
282
297
  const source = store.source(id);
283
298
  if (source) {
284
299
  activeSource = id;
300
+ sourceObservation = source;
285
301
  job = new SourceIngest(store, source, force);
286
302
  }
287
303
  }
@@ -302,8 +318,11 @@ function pump() {
302
318
  else
303
319
  store.run("UPDATE sources SET state='error',error=?,checked_at=? WHERE id=?", "Linked native conversation could not be indexed.", Date.now(), id);
304
320
  }
305
- if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY"))
306
- changed();
321
+ if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY")) {
322
+ const source = id ? store.source(id) : undefined;
323
+ changed(Boolean(source && (source.state !== sourceObservation?.state || source.error !== sourceObservation?.error)));
324
+ }
325
+ sourceObservation = undefined;
307
326
  }
308
327
  activeSource = undefined;
309
328
  finishBarriers();
@@ -328,6 +347,8 @@ function clear() {
328
347
  retryTimer = undefined;
329
348
  job?.close();
330
349
  job = undefined;
350
+ sourceObservation = undefined;
351
+ notifiedVersion = undefined;
331
352
  for (const watcher of watchers.values())
332
353
  watcher.close();
333
354
  watchers.clear();
@@ -9,10 +9,10 @@ Startup recovers saved ownership, unanswered questions and Intercom receipts in
9
9
  ### Indexed browsing and lazy Agents work
10
10
 
11
11
  - A separate Node process incrementally indexes only the current saved parent's admitted runs and linked native sources. A fresh session with no owned work starts no history process or database until it browses. No directory-wide transcript backfill runs at startup.
12
- - List filters and attention/newest/oldest ordering apply globally before a bounded page. Ordinary tool lists return 20 runs by default, at most 100; the Agents picker loads 50 runs and searches full assignments, not just shortened labels. Latest-assignment queries retain unsuperseded siblings while hiding older continuation attempts.
12
+ - List filters and attention/newest/oldest ordering apply globally before a bounded page. Ordinary tool lists return 20 runs by default, at most 100; the Agents picker loads 50 runs and searches full assignments, not just shortened labels. Latest-assignment queries retain unsuperseded siblings while hiding older continuation attempts. Native branch configuration reuses at most 128 compact results across rows and queries, keyed by physical source generation, committed cursor, observed stamp and exact terminal/time/leaf boundary. Publication (including backdated records), replacement and reset invalidate reuse; null leaves and strict missing/cyclic-boundary errors retain their original meaning.
13
13
  - The parent UI does not scan child journals on startup, ticks, filtering or pagination. Only an opened conversation loads a 100-physical-record page; components are retained for that page. Full details and contextual Reply validate selected native byte references off-thread, including paired tool results outside the current page. Explicit navigation supersedes older requests; background refreshes are coalesced and retain the settled display rather than briefly adding loading rows. Reopening the picker restores its visible filter and page.
14
14
  - Catch-up, degraded sources, loading, no matches and unavailable history are distinct states. F5 explicitly retries. Drafts, reading anchors, unread boundaries and pins survive same-parent reopen; a changed owner or abandoned overlay cannot accept late results.
15
- - Search indexes saved visible text, not thinking, tool arguments, image payloads, hidden custom data or provider fields. Terminal sequences are removed incrementally before tokenization, including sequences split across read/parser chunks. Its grammar is lexical words or one quoted phrase. Unquoted words must all occur in the same native record, across any of its visible fields/windows; quoted phrases must remain contiguous in one field. Each record contributes one excerpt per matching child attempt. Filters and attempt terminal/time boundaries apply before ranking/paging. Common terms may still require broad matching and sorting: a small result page is not a constant-work guarantee. Deadlines and cancellation stop the owned history process, which can reopen and replay committed index progress.
15
+ - Search indexes saved visible text, not thinking, tool arguments, image payloads, hidden custom data or provider fields. Terminal sequences are removed incrementally before tokenization, including sequences split across read/parser chunks. Its grammar is lexical words or one quoted phrase. Unquoted words must all occur in the same native record, across any of its visible fields/windows; quoted phrases must remain contiguous in one field. Each record contributes one excerpt per matching child attempt. Filters and attempt terminal/time boundaries apply before ranking/paging. Run/child/agent scopes restrict linked physical sources before match aggregation without changing global relevance scores. Each scoped source is scanned once, even when many continuation attempts share it; terminal/time boundaries and attempt attribution still apply before ranking/paging in the final join. Common terms still scan global FTS postings, and global searches may require broad matching and sorting: a small result page is not a constant-work guarantee. Deadlines and cancellation stop the owned history process, which can reopen and replay committed index progress.
16
16
  - Browse freshness is never authoritative. Selected controls recheck canonical owner facts before acting. Human-message delivery and a later nonempty assistant reply are separate observations. Receipt integrity and native accounting safeguards remain unchanged.
17
17
 
18
18
  ### Storage, budgets and recovery
@@ -34,7 +34,7 @@ The changed boundaries are exercised through real extension functions and regist
34
34
  - Native Intercom reload restores all 1,024 historical receipts while servicing input. Cleared-queue recovery also services input before its 100 followers finish appending, retaining each message once and one final wake-up across an in-flight reload or a cleared busy handoff. New results and explicit steers are staged before acknowledgement and handed to native steering for the next tool boundary; ordinary queued and passive arrivals wait.
35
35
  - 125 owned assignments: global filtering finds an item beyond the first two picker pages; all later pages remain reachable without UI-thread source access.
36
36
  - 65 retained runs: off-page reads/stats and migration scans are zero while displayed controls, questions, review and continuation identities remain fresh.
37
- - Warm indexed list/search queries perform no source checks, opens or canonical projections. Cold ingestion and explicit refresh still do necessary verification off-thread.
37
+ - Warm indexed list/search queries perform no source checks, opens or canonical projections. Cold ingestion and explicit refresh still do necessary verification off-thread. Fifty genuinely admitted live rows sharing an 80,004-entry native journal retain correct state, model, thinking and bounded history through the real worker's unchanged three-second deadline under a 96 MiB JavaScript heap; completed terminal branches remain correct. An unchanged 1,000-run native census keeps projecting all handles but emits only settled freshness, while canonical changes and source failure/recovery still notify. This removes redundant notifications, not the census or UI polling.
38
38
  - A 512 MiB discarded native field preserves exact eligible usage/IDs/configuration/history, with unchanged source bytes, under a **96 MiB** heap limit. Structural over-budget image/deep/key records degrade explicitly while later visible text remains searchable.
39
39
  - Completed-attempt search cannot return a successor's later text from a shared physical source. Full selected details reject missing/replaced/truncated sources; canonical output reads reject concurrent growth beyond their snapshot.
40
40
  - Competing history processes preserve staged configuration/text on normal completion and recover after a writer is killed. Schema-4 indexes with intact cursors but missing entries rebuild from unchanged journals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.44.5",
3
+ "version": "0.44.6",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,7 +11,9 @@ interface Cursor { generation: string; owner: string; version: number; query: st
11
11
  export class HistoryQueries {
12
12
  private store: HistoryStore;
13
13
  private info: () => HistoryVersion;
14
+ private configurations = new Map<string, HistoryConfiguration>();
14
15
  constructor(store: HistoryStore, info: () => HistoryVersion) { this.store = store; this.info = info; }
16
+ sourcesRemoved(): void { this.configurations.clear(); }
15
17
  private cursor(query: string, keys: (string | number)[], offset: number): string {
16
18
  return Buffer.from(JSON.stringify({ generation: this.store.generation, owner: this.store.owner, version: this.info().version, query, keys, offset } satisfies Cursor)).toString("base64url");
17
19
  }
@@ -80,6 +82,12 @@ export class HistoryQueries {
80
82
  if (input.leaf === null) return {};
81
83
  let boundary: ReturnType<HistoryQueries["boundary"]>;
82
84
  try { boundary = this.boundary(source, input); } catch (error) { if (!strict && error instanceof HistoryIndexError && error.code === "BOUNDARY_UNAVAILABLE") return {}; throw error; }
85
+ // Only committed source changes invalidate ancestry, not unrelated run projections.
86
+ // Validate boundaries before reuse; explicit null leaves returned above remain empty.
87
+ const leafKey = input.leaf === undefined ? null : [typeof input.leaf, String(input.leaf)];
88
+ const key = JSON.stringify([source.id, source.generation, source.cursor, source.stamp, input.terminalEntryId || null, input.terminalEntryId ? null : input.endedAt ?? null, leafKey, strict]);
89
+ const cached = this.configurations.get(key);
90
+ if (cached) return { ...cached };
83
91
  const leaf = input.leaf ?? this.store.get(`SELECT id FROM entries WHERE ${boundary.clauses.join(" AND ")} ORDER BY start DESC LIMIT 1`, ...boundary.params)?.id;
84
92
  if (!leaf) return {};
85
93
  // Fix recursive loop order: look up each child's parent by ID instead of
@@ -93,7 +101,11 @@ export class HistoryQueries {
93
101
  (SELECT configuration_thinking FROM branch WHERE configuration_thinking IS NOT NULL ORDER BY start DESC LIMIT 1) AS thinking`, ...boundary.params, leaf);
94
102
  if (!facts.count && input.leaf && strict) throw new HistoryIndexError("BOUNDARY_UNAVAILABLE", "Selected configuration leaf is not available in the indexed generation.");
95
103
  if (facts.count && !facts.rooted) { if (!strict) return {}; throw new HistoryIndexError("MALFORMED_ANCESTRY", "Selected native configuration has cyclic ancestry."); }
96
- return { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
104
+ const configuration = { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
105
+ // Bound memory across arbitrary sources, boundaries and pages. Eviction only recomputes.
106
+ if (this.configurations.size >= 128) this.configurations.delete(this.configurations.keys().next().value!);
107
+ this.configurations.set(key, configuration);
108
+ return { ...configuration };
97
109
  }
98
110
  historyPage(input: HistoryPageInput): HistoryPage {
99
111
  const { source } = this.child(input.runId, input.index);
@@ -149,6 +161,12 @@ export class HistoryQueries {
149
161
  if (input.runId) { clauses.push("c.run_id=?"); params.push(input.runId); }
150
162
  if (input.index !== undefined) { clauses.push("c.child_index=?"); params.push(input.index); }
151
163
  if (input.agent) { if (input.agent.length > 256) throw new HistoryIndexError("INVALID", "Agent filter is too long."); clauses.push("c.agent=?"); params.push(input.agent); }
164
+ const joins = "JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1";
165
+ // Scope records before materializing/grouping hits, while bm25 still sees the
166
+ // complete corpus. Scan each linked source once, not once per continuation.
167
+ // Attempt boundaries, attribution and seek filtering remain in the final join.
168
+ const eligible = input.runId || input.agent ? `eligible AS MATERIALIZED (SELECT e.rowid FROM entries e JOIN sources s ON s.id=e.source_id WHERE e.published=1 AND e.generation=s.generation AND e.source_id IN (SELECT c.source_id FROM children c WHERE ${clauses.slice(4).join(" AND ")})),` : "";
169
+ const eligibleParams = eligible ? params.slice(expression.length) : [];
152
170
  // Ordering/filters apply globally before limit. Common terms may still require an FTS
153
171
  // scan/sort; the parent enforces a hard deadline by killing this separate process.
154
172
  const score = sort === "relevance" ? "matched.score" : "-COALESCE(e.timestamp,0)";
@@ -158,8 +176,8 @@ export class HistoryQueries {
158
176
  }
159
177
  // Match words across all visible fields/windows of one native record. A quoted
160
178
  // phrase remains a single FTS expression, so it cannot span unrelated fields.
161
- const rows = this.store.all(`WITH hits AS MATERIALIZED (
162
- ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?`).join(" UNION ALL ")}
179
+ const rows = this.store.all(`WITH ${eligible} hits AS MATERIALIZED (
180
+ ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?${eligible ? " AND d.entry_rowid IN (SELECT rowid FROM eligible)" : ""}`).join(" UNION ALL ")}
163
181
  ), terms AS (
164
182
  SELECT entry_rowid,term,MIN(score) AS score FROM hits GROUP BY entry_rowid,term
165
183
  ), matched AS (
@@ -167,7 +185,7 @@ export class HistoryQueries {
167
185
  ), excerpts AS (
168
186
  SELECT id,entry_rowid,ROW_NUMBER() OVER (PARTITION BY entry_rowid ORDER BY score,id) AS choice FROM hits
169
187
  )
170
- SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1 WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...params, limit + 1);
188
+ SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid ${joins} WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...eligibleParams, ...params, limit + 1);
171
189
  const more = rows.length > limit, page = rows.slice(0, limit);
172
190
  return { ...this.info(), matches: page.map((row) => ({ id: row.document_id, runId: row.run_id, index: row.child_index, agent: row.agent, entryId: row.id, nativeId: row.native_id, sessionId: row.session_id, sessionFile: row.path, timestamp: row.timestamp,
173
191
  ref: { sourceId: row.source_id, generation: row.generation, start: row.start, end: row.end, digest: row.digest }, preview: row.document_preview, field: row.field, textStart: row.text_start, textEnd: row.text_end, score: row.score })),
@@ -21,6 +21,8 @@ const sourceWatchers = new Map<string, fs.FSWatcher>();
21
21
  const watchIdentities = new WeakMap<fs.FSWatcher, string>();
22
22
  const watchedSources = new Map<string, Map<string, string>>();
23
23
  let job: SourceIngest | undefined, scheduled = false, closed = false;
24
+ let sourceObservation: { state: string; error: string | null } | undefined;
25
+ let notifiedVersion: string | undefined;
24
26
  let retryTimer: ReturnType<typeof setTimeout> | undefined;
25
27
  let runErrors = new Set<string>();
26
28
  const barriers: Array<{ id: number; runId?: string }> = [];
@@ -32,7 +34,14 @@ function send(response: Response): void {
32
34
  // The parent can disconnect after process.connected was read; then this and later replies are undeliverable, so stop as on disconnect.
33
35
  if (process.connected) process.send!(response, undefined, undefined, (error) => { if (error) shutdown(); });
34
36
  }
35
- function changed(): void { send({ changed: true }); }
37
+ function changed(observationChanged = false): void {
38
+ const version = store?.get("SELECT value FROM meta WHERE key='version'").value;
39
+ // Unchanged census projections need only one final freshness notification.
40
+ // Non-versioned errors/recovery and real commits still publish immediately.
41
+ if (observationChanged || version !== notifiedVersion || !hasPending()) {
42
+ notifiedVersion = version; send({ changed: true });
43
+ }
44
+ }
36
45
  function errorReply(id: number, error: unknown): void {
37
46
  const code = error instanceof HistoryIndexError ? error.code : "UNAVAILABLE";
38
47
  // Parser errors can include fragments from the malformed body. IPC/logs must never carry them.
@@ -164,7 +173,11 @@ function prune(previousSources?: string[]): void {
164
173
  store!.deleteDocuments(source.id);
165
174
  store!.run("DELETE FROM entries WHERE source_id=?", source.id); store!.run("DELETE FROM sources WHERE id=?", source.id); removed = true;
166
175
  }
167
- if (removed) store!.bump();
176
+ if (removed) {
177
+ store!.bump();
178
+ // A later admission of the same path can recreate source generation 1.
179
+ queries?.sourcesRemoved();
180
+ }
168
181
  });
169
182
  }
170
183
  function admit(run: OwnedRun): void {
@@ -201,23 +214,25 @@ function pump(): void {
201
214
  try {
202
215
  if (dirtyRuns.size) {
203
216
  const id = dirtyRuns.values().next().value!; dirtyRuns.delete(id);
217
+ const failed = runErrors.has(id);
204
218
  installWatches(id);
205
219
  const previousSources = store.all("SELECT source_id FROM children WHERE run_id=? AND source_id IS NOT NULL", id).map((child) => child.source_id);
206
220
  try { store.operations.runProjections++; const view = projection(runs.get(id)!); store.putView(compactView(view), view); runErrors.delete(id); }
207
221
  catch { const view = unknown(runs.get(id)!, "Canonical owner summary is unavailable. Completion remains unconfirmed."); store.putView(compactView(view), view); runErrors.add(id); }
208
222
  prune(previousSources);
209
223
  for (const child of store.all("SELECT source_id FROM children WHERE run_id=? AND source_id IS NOT NULL", id)) if (child.source_id !== job?.id) dirtySources.set(child.source_id, dirtySources.get(child.source_id) ?? false);
210
- installWatches(id); changed();
224
+ installWatches(id); changed(failed !== runErrors.has(id));
211
225
  } else if (job) {
212
226
  if (job.step()) {
213
227
  const source = store.source(job.id);
214
228
  if (source) watchSources(path.dirname(source.path));
215
- job = undefined; changed();
229
+ job = undefined; changed(source?.state !== sourceObservation?.state || source?.error !== sourceObservation?.error);
230
+ sourceObservation = undefined;
216
231
  }
217
232
  } else if (dirtySources.size) {
218
233
  const [id, force] = dirtySources.entries().next().value!; dirtySources.delete(id);
219
234
  const source = store.source(id);
220
- if (source) { activeSource = id; job = new SourceIngest(store, source, force); }
235
+ if (source) { activeSource = id; sourceObservation = source; job = new SourceIngest(store, source, force); }
221
236
  }
222
237
  } catch (error) {
223
238
  const id = job?.id ?? activeSource;
@@ -230,7 +245,11 @@ function pump(): void {
230
245
  else if ((error as NodeJS.ErrnoException).code === "ENOENT") store.resetSource(id, "missing", "Linked native conversation is missing.");
231
246
  else store.run("UPDATE sources SET state='error',error=?,checked_at=? WHERE id=?", "Linked native conversation could not be indexed.", Date.now(), id);
232
247
  }
233
- if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY")) changed();
248
+ if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY")) {
249
+ const source = id ? store.source(id) : undefined;
250
+ changed(Boolean(source && (source.state !== sourceObservation?.state || source.error !== sourceObservation?.error)));
251
+ }
252
+ sourceObservation = undefined;
234
253
  }
235
254
  activeSource = undefined;
236
255
  finishBarriers();
@@ -247,6 +266,7 @@ const timer = setInterval(census, 30_000); timer.unref();
247
266
  function clear(): void {
248
267
  clearTimeout(retryTimer); retryTimer = undefined;
249
268
  job?.close(); job = undefined;
269
+ sourceObservation = undefined; notifiedVersion = undefined;
250
270
  for (const watcher of watchers.values()) watcher.close(); watchers.clear(); watchedSources.clear();
251
271
  for (const watcher of sourceWatchers.values()) watcher.close(); sourceWatchers.clear();
252
272
  dirtyRuns.clear(); dirtySources.clear(); runs.clear(); foreground.clear(); runErrors = new Set();