@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 +7 -0
- package/dist/extension/build-info.js +1 -1
- package/dist/history/queries.js +24 -4
- package/dist/history/worker.js +27 -6
- package/docs/performance.md +3 -3
- package/package.json +1 -1
- package/src/history/queries.ts +22 -4
- package/src/history/worker.ts +26 -6
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.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.44.6","sha256":"e9a0bfe1bf57b2b67e3b32c6f449996dadf8bc897fe2577eea3d919158b40466"});
|
package/dist/history/queries.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 })),
|
package/dist/history/worker.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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();
|
package/docs/performance.md
CHANGED
|
@@ -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
|
|
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
package/src/history/queries.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 })),
|
package/src/history/worker.ts
CHANGED
|
@@ -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 {
|
|
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)
|
|
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"))
|
|
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();
|