@fitchmultz/pi-subagents 0.44.4 → 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 +16 -0
- package/README.md +4 -0
- package/dist/extension/build-info.js +1 -1
- package/dist/extension/compact-tools.js +1 -1
- package/dist/extension/schemas.js +9 -9
- package/dist/history/index.js +14 -9
- package/dist/history/ingest.js +26 -5
- package/dist/history/queries.js +24 -4
- package/dist/history/worker.js +40 -14
- package/dist/pi-intercom/bridge.js +750 -0
- package/dist/runs/foreground/question-control.js +23 -12
- package/dist/runs/foreground/wait-run.js +5 -3
- package/dist/runs/shared/run-history.js +8 -1
- package/dist/runs/shared/supervisor-questions.js +32 -2
- package/dist/shared/status-format.js +1 -1
- package/docs/intercom-bridge.md +212 -0
- package/docs/intercom.md +3 -1
- package/docs/performance.md +3 -3
- package/package.json +2 -1
- package/scripts/intercom-bridge-credentials.mjs +143 -0
- package/scripts/intercom-remote.mjs +103 -0
- package/scripts/package-smoke.mjs +4 -0
- package/src/extension/compact-tools.ts +1 -1
- package/src/extension/schemas.ts +9 -9
- package/src/history/index.ts +13 -9
- package/src/history/ingest.ts +22 -5
- package/src/history/queries.ts +22 -4
- package/src/history/worker.ts +37 -13
- package/src/pi-intercom/bridge.ts +668 -0
- package/src/runs/foreground/question-control.ts +23 -11
- package/src/runs/foreground/wait-run.ts +5 -2
- package/src/runs/shared/run-history.ts +8 -1
- package/src/runs/shared/supervisor-questions.ts +45 -2
- package/src/shared/status-format.ts +2 -2
- package/src/shared/types.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.44.5] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Optional loopback-only mTLS Intercom HTTPS bridge over SSH reverse forwarding, with certificate-bound remote peer identity, bounded messaging/inbox API, metadata audit logs, remote CLI, and credential issue/rotation/revocation tooling. The same-machine broker and extension startup remain unchanged.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Keep supervisor-question tool results bounded: page compact question/answer previews without repeating frozen launch metadata or native attempt baselines. Full question, interview and immutable answer files remain available; answering, cancellation and revival retain their original durable contracts.
|
|
17
|
+
- Keep the history worker's cost per child append proportional to the new bytes. It used to re-hash every indexed byte of a linked session on each change, so a live child writing to a source of several hundred megabytes held the worker at full CPU for hours. Each check now re-hashes the last indexed line. Replacement, truncation and in-place rewrites of that line still start a new generation, and detail reads still verify their record digests. Indexes from earlier releases are verified in full once and then switch to the bounded check without re-indexing.
|
|
18
|
+
|
|
3
19
|
## [0.44.4] - 2026-10-04
|
|
4
20
|
|
|
5
21
|
### Added
|
package/README.md
CHANGED
|
@@ -450,6 +450,8 @@ The files in `prompts/` document common workflows without registering additional
|
|
|
450
450
|
|
|
451
451
|
## Bundled intercom
|
|
452
452
|
|
|
453
|
+
For a remote agent on Linux, the optional [secure remote bridge](docs/intercom-bridge.md) provides a loopback-only mTLS HTTPS API over an SSH reverse forward. It registers a certificate-bound peer such as `grok-bot` and permits only messaging operations; the private Unix broker socket remains local. The guide includes setup, remote commands, credential rotation and revocation.
|
|
454
|
+
|
|
453
455
|
The same `pi-subagents` install registers the intercom extension and skill. `load_intercom({})` exposes the full peer tool explicitly; actionable peer inbound and recovered reply needs also expose it before use. Presence, passive breadcrumbs, routine subagent notices, and human messages answered in chat do not. Broker presence and delivery continue even with the schema hidden. Managed children get a private coordination channel back to the parent Pi session unless an explicit agent extension allowlist excludes it. See [the intercom guide](docs/intercom.md) for direct peer messaging, keyboard UI, tool actions, configuration, and broker details.
|
|
454
456
|
|
|
455
457
|
If you previously installed the standalone package, remove that old settings entry once to avoid loading two intercom extensions:
|
|
@@ -489,6 +491,8 @@ agent_runs({ action: "stop", id: "<run-id>" })
|
|
|
489
491
|
|
|
490
492
|
Resume the **same saved supervisor session**, even from another cwd, to recover its questions. A different session does not silently adopt them. Ordinary `intercom` replies also save the answer while the live waiter is connected. After supervisor/broker restart, prefer `agent_runs` questions/answer; a nudge is guidance, not an answer to a blocking question.
|
|
491
493
|
|
|
494
|
+
`questions` returns 20 compact summaries by default (use `limit: 1–100` and `details.questionList.nextOffset` as `offset`), with unresolved questions first. Filter by run `id`, child `index`, or exact `questionId`. Question and answer previews are capped at 2,048 characters; truncation is marked. Read `questionPath` for the complete question, structured interview and saved launch contract, or `answerPath` for the exact immutable answer. These files remain unchanged by browsing; repeated tool results do not copy launch metadata or native attempt baselines. If questions change while paging, restart at offset 0 or look up the exact question ID.
|
|
495
|
+
|
|
492
496
|
Answers are saved once. Repeating the same answer does not start duplicate work; conflicting answers are rejected without replacing the original. A live child reads the saved answer; an exited child resumes from its saved session in a new run, retaining the original acceptance contract. An answer receipt is not execution completion. `stop` cancels outstanding questions and aborts a live waiter using its saved run identity, including after a parent restart.
|
|
493
497
|
|
|
494
498
|
If an answer launch was interrupted before a continuation existed, the reply gives an explicit `continue` recovery call. It refuses to restart when launch evidence is uncertain; inspect the advertised continuation first. Questions, answers, launch contracts, and results live under `${PI_CODING_AGENT_DIR:-~/.pi/agent}/sessions/subagent-runs/<run-id>/`, separate from temporary logs. Ownership and review use native parent `subagent-run` entries; pending intercom delivery is journaled in that same saved Pi session. Temporary cleanup does not erase these records, but deleting saved sessions or metadata removes their recovery data. Pending questions are not age-cleaned.
|
|
@@ -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"});
|
|
@@ -60,7 +60,7 @@ export function registerCompactSubagentTools(pi, options) {
|
|
|
60
60
|
name: "agent_runs",
|
|
61
61
|
defaultActive: false,
|
|
62
62
|
label: "Agent Runs",
|
|
63
|
-
description: `List ${childSafe ? "only this child's directly owned" : "your delegated"} runs across working directories (questions/failures, then live work, then unreviewed results; 20 per page). Filter globally by agent/state/text, sort, and page with the returned cursor. history reads 100 bounded native-entry previews; search finds saved visible text using words or quoted phrases, not operators or prefixes. Browse freshness is not completion or delivery proof. Inspect concise results, paths and continuations; full:true includes the full task/configuration. Answer durable questions, nudge, stop, continue, or save parent-only review. Review notes are not sent to children; put actionable instructions in continue/nudge. Inspect/review/nudge never restart finished work. Continue/answer can launch a saved child; async:false waits for its actual result. Saved continuations keep settings unless agent selects a current profile; model overrides win. Live guidance never mutates model or acceptance. profiles lists roles, sources, context and model/thinking/fallback defaults. History survives reload.`,
|
|
63
|
+
description: `List ${childSafe ? "only this child's directly owned" : "your delegated"} runs across working directories (questions/failures, then live work, then unreviewed results; 20 per page). Filter globally by agent/state/text, sort, and page with the returned cursor. history reads 100 bounded native-entry previews; search finds saved visible text using words or quoted phrases, not operators or prefixes. questions pages 20 compact previews (offset/limit); questionId selects one, and questionPath/answerPath retain full records. Browse freshness is not completion or delivery proof. Inspect concise results, paths and continuations; full:true includes the full task/configuration. Answer durable questions, nudge, stop, continue, or save parent-only review. Review notes are not sent to children; put actionable instructions in continue/nudge. Inspect/review/nudge never restart finished work. Continue/answer can launch a saved child; async:false waits for its actual result. Saved continuations keep settings unless agent selects a current profile; model overrides win. Live guidance never mutates model or acceptance. profiles lists roles, sources, context and model/thinking/fallback defaults. History survives reload.`,
|
|
64
64
|
parameters: AgentRunsParams,
|
|
65
65
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
66
66
|
const normalized = normalizeEverydayParams(params, true);
|
|
@@ -255,13 +255,13 @@ const HistoryQueryFields = {
|
|
|
255
255
|
export const AgentRunsValidationParams = Type.Object({
|
|
256
256
|
action: Type.Enum(["list", "inspect", "history", "search", "nudge", "stop", "continue", "profiles", "questions", "answer", "review"], { type: "string" }),
|
|
257
257
|
id: Type.Optional(Type.String({ minLength: 1, description: "Run ID or unambiguous prefix." })),
|
|
258
|
-
questionId: Type.Optional(Type.String({ minLength: 1, description: "Durable supervisor question ID for answer." })),
|
|
258
|
+
questionId: Type.Optional(Type.String({ minLength: 1, description: "Durable supervisor question ID for exact questions lookup or answer." })),
|
|
259
259
|
async: Type.Optional(Type.Boolean({ description: "Continue/answer: false waits for the actual continuation result." })),
|
|
260
260
|
index: Type.Optional(Type.Integer({ minimum: 0, description: "Child index for a multi-child run." })),
|
|
261
261
|
message: Type.Optional(Type.String({ minLength: 1, description: "Guidance, follow-up, answer, or optional parent-only review note. Review notes are not sent to the child; put actionable instructions in continue/nudge. Only continue/answer can start a saved child." })),
|
|
262
262
|
decision: Type.Optional(Type.Enum(["accepted", "needs_changes"], { type: "string", description: "Parent-only review outcome; not sent to the child. Separate from execution and runtime acceptance checks." })),
|
|
263
|
-
offset: Type.Optional(Type.Integer({ minimum: 0, description: "List offset; history is retained regardless of page size." })),
|
|
264
|
-
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Results per list/search/history page (default 20; history 100)." })),
|
|
263
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "List/questions offset; history is retained regardless of page size." })),
|
|
264
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Results per list/questions/search/history page (default 20; history 100)." })),
|
|
265
265
|
...HistoryQueryFields,
|
|
266
266
|
full: Type.Optional(Type.Boolean({ description: "Inspect only: include the full task and saved launch configuration. Default is a concise report; stored data is unchanged." })),
|
|
267
267
|
agent: Type.Optional(Type.String({ minLength: 1, description: "List/search: filter by child agent. Continue/answer: adopt this current profile, including model, thinking and fallbacks; a separate model override wins. Otherwise keep saved settings. Required for old runs without a saved profile." })),
|
|
@@ -279,8 +279,8 @@ export const AgentRunsValidationParams = Type.Object({
|
|
|
279
279
|
{ if: { properties: { action: { enum: ["review"] } } }, then: requiredObject("decision") },
|
|
280
280
|
{ if: { anyOf: [requiredObject("async"), requiredObject("model"), requiredObject("cwd"), requiredObject("output"), requiredObject("acceptance")] }, then: { properties: { action: { enum: ["continue", "answer"] } } } },
|
|
281
281
|
{ if: requiredObject("agent"), then: { properties: { action: { enum: ["list", "search", "continue", "answer"] } } } },
|
|
282
|
-
{ if: requiredObject("offset"), then: { properties: { action: { enum: ["list"] } } } },
|
|
283
|
-
{ if: requiredObject("limit"), then: { properties: { action: { enum: ["list", "history", "search"] } } } },
|
|
282
|
+
{ if: requiredObject("offset"), then: { properties: { action: { enum: ["list", "questions"] } } } },
|
|
283
|
+
{ if: requiredObject("limit"), then: { properties: { action: { enum: ["list", "questions", "history", "search"] } } } },
|
|
284
284
|
{ if: requiredObject("cursor"), then: { properties: { action: { enum: ["list", "history", "search"] } } } },
|
|
285
285
|
{ if: requiredObject("sort"), then: { properties: { action: { enum: ["list", "search"] } } } },
|
|
286
286
|
{ not: { anyOf: [requiredObject("cursor", "offset"), requiredObject("cursor", "before")] } },
|
|
@@ -313,9 +313,9 @@ export const SubagentParams = Type.Object({
|
|
|
313
313
|
runId: Type.Optional(Type.String({
|
|
314
314
|
description: "Target run ID; prefer id. Defaults to the most recently active controllable run for interrupt/extend/nudge."
|
|
315
315
|
})),
|
|
316
|
-
questionId:
|
|
316
|
+
questionId: AgentRunsValidationParams.properties.questionId,
|
|
317
317
|
decision: Type.Optional(Type.Enum(["accepted", "needs_changes"], { type: "string", description: "Parent-only review decision; not sent to the child. Put actionable instructions in resume/nudge. Does not launch work or change runtime acceptance." })),
|
|
318
|
-
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Status list offset." })),
|
|
318
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Status list/questions offset." })),
|
|
319
319
|
limit: AgentRunsValidationParams.properties.limit,
|
|
320
320
|
...HistoryQueryFields,
|
|
321
321
|
full: Type.Optional(Type.Boolean({ description: "Exact status only: include the full task and saved launch configuration; default is concise." })),
|
|
@@ -390,8 +390,8 @@ export const SubagentParams = Type.Object({
|
|
|
390
390
|
{ if: requiredObject("decision"), then: { ...requiredObject("action"), properties: { action: { enum: ["review"] } } } },
|
|
391
391
|
{ if: { ...requiredObject("action"), properties: { action: { enum: ["history"] } } }, then: { anyOf: [requiredObject("id"), requiredObject("runId")] } },
|
|
392
392
|
{ if: { ...requiredObject("action"), properties: { action: { enum: ["search"] } } }, then: requiredObject("query") },
|
|
393
|
-
{ if: requiredObject("offset"), then: { ...requiredObject("action"), properties: { action: { enum: ["status"] } } } },
|
|
394
|
-
{ if: requiredObject("limit"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "history", "search"] } } } },
|
|
393
|
+
{ if: requiredObject("offset"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "questions"] } } } },
|
|
394
|
+
{ if: requiredObject("limit"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "questions", "history", "search"] } } } },
|
|
395
395
|
{ if: requiredObject("cursor"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "history", "search"] } } } },
|
|
396
396
|
{ if: requiredObject("sort"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "search"] } } } },
|
|
397
397
|
{ if: { allOf: [{ ...requiredObject("action"), properties: { action: { enum: ["search"] } } }, requiredObject("index")] }, then: { anyOf: [requiredObject("id"), requiredObject("runId")] } },
|
package/dist/history/index.js
CHANGED
|
@@ -114,6 +114,16 @@ export class SubagentHistoryIndex {
|
|
|
114
114
|
}
|
|
115
115
|
await this.ready;
|
|
116
116
|
}
|
|
117
|
+
/** Arms an ordinary deadline only in the continuation that saw the current admission and worker settle, never behind a replacement admission. */
|
|
118
|
+
async admitted(method, input, signal, timeout) {
|
|
119
|
+
await this.ensure();
|
|
120
|
+
const ready = this.ready, child = this.process;
|
|
121
|
+
await ready;
|
|
122
|
+
// setOwner() or a restart may have replaced readiness during either await; wait for the replacement.
|
|
123
|
+
if (!child || ready !== this.ready || child !== this.process)
|
|
124
|
+
return this.admitted(method, input, signal, timeout);
|
|
125
|
+
return this.send(method, input, signal, timeout);
|
|
126
|
+
}
|
|
117
127
|
async setOwner(input) {
|
|
118
128
|
if (this.closed)
|
|
119
129
|
throw new HistoryIndexError("CLOSED", "History index is closed.");
|
|
@@ -138,21 +148,17 @@ export class SubagentHistoryIndex {
|
|
|
138
148
|
if (run.ownerSessionId !== this.owner.ownerSessionId || foreground && foreground.runId !== run.runId)
|
|
139
149
|
throw new HistoryIndexError("OWNERSHIP", "Run does not belong to the current owner.");
|
|
140
150
|
const copy = structuredClone(run), fg = foreground && structuredClone(foreground);
|
|
141
|
-
await this.
|
|
151
|
+
await this.admitted("updateRun", { run: copy, foreground: fg });
|
|
142
152
|
this.owner.runs = [...this.owner.runs.filter((entry) => entry.runId !== run.runId), copy];
|
|
143
153
|
if (fg)
|
|
144
154
|
this.owner.foregroundRuns = [...(this.owner.foregroundRuns ?? []).filter((entry) => entry.runId !== run.runId), fg];
|
|
145
155
|
}
|
|
146
156
|
async query(method, input) {
|
|
147
|
-
await this.ensure();
|
|
148
157
|
const { signal, ...wire } = input;
|
|
149
|
-
return this.
|
|
158
|
+
return this.admitted(method, wire, signal);
|
|
150
159
|
}
|
|
151
160
|
/** Tool discovery waits for run metadata only, not transcript backfill. */
|
|
152
|
-
|
|
153
|
-
await this.ensure();
|
|
154
|
-
return this.send("needsControls", undefined, undefined, 15_000);
|
|
155
|
-
}
|
|
161
|
+
needsControls() { return this.admitted("needsControls", undefined, undefined, 15_000); }
|
|
156
162
|
listRuns(options = {}) { return this.query("listRuns", options); }
|
|
157
163
|
historyPage(input) { return this.query("historyPage", input); }
|
|
158
164
|
search(input) { return this.query("search", input); }
|
|
@@ -166,8 +172,7 @@ export class SubagentHistoryIndex {
|
|
|
166
172
|
async refresh(runId, options = {}) {
|
|
167
173
|
if (!this.closed)
|
|
168
174
|
this.unavailable = undefined;
|
|
169
|
-
|
|
170
|
-
return this.send("refresh", { runId }, options.signal, 120_000);
|
|
175
|
+
return this.admitted("refresh", { runId }, options.signal, 120_000);
|
|
171
176
|
}
|
|
172
177
|
onChanged(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
173
178
|
/** Hard cancellation affects this instance's outstanding work, not native sessions or other indexers. */
|
package/dist/history/ingest.js
CHANGED
|
@@ -232,8 +232,13 @@ export class SourceIngest {
|
|
|
232
232
|
fd;
|
|
233
233
|
initial;
|
|
234
234
|
source;
|
|
235
|
-
prefix = createHash("sha256");
|
|
236
235
|
recordDigest = createHash("sha256");
|
|
236
|
+
/** prefix_digest is "start:sha256" of the last indexed line; earlier releases hashed the whole prefix. */
|
|
237
|
+
legacy = false;
|
|
238
|
+
expected = null;
|
|
239
|
+
verified = createHash("sha256");
|
|
240
|
+
line = createHash("sha256");
|
|
241
|
+
lineStart = 0;
|
|
237
242
|
verifyAt = 0;
|
|
238
243
|
position;
|
|
239
244
|
frames;
|
|
@@ -274,6 +279,11 @@ export class SourceIngest {
|
|
|
274
279
|
this.headerSeen = source.cursor > 0;
|
|
275
280
|
store.clearUnpublished(source.id);
|
|
276
281
|
store.run("DELETE FROM pending_text");
|
|
282
|
+
// Appends re-hash only the last indexed line, so live sources cost their new bytes, not their size.
|
|
283
|
+
const anchor = /^(\d+):([0-9a-f]{64})$/.exec(source.prefix_digest ?? "");
|
|
284
|
+
this.legacy = !anchor;
|
|
285
|
+
this.expected = anchor ? anchor[2] : source.prefix_digest;
|
|
286
|
+
this.verifyAt = this.lineStart = anchor ? Number(anchor[1]) : 0;
|
|
277
287
|
this.phase = !force && source.stamp === stamp(this.initial) ? "done" : source.cursor ? "verify" : "read";
|
|
278
288
|
if (this.phase !== "done")
|
|
279
289
|
store.run("UPDATE sources SET state='indexing',error=NULL WHERE id=?", source.id);
|
|
@@ -294,7 +304,7 @@ export class SourceIngest {
|
|
|
294
304
|
if (!current || current.generation !== this.source.generation || current.cursor !== this.source.cursor)
|
|
295
305
|
throw new HistoryIndexError("SOURCE_CHANGED", "Another writer advanced this source; reconcile before replaying.");
|
|
296
306
|
work?.();
|
|
297
|
-
this.store.run("UPDATE sources SET cursor=?,prefix_digest=?,identity=? WHERE id=?", end, this.
|
|
307
|
+
this.store.run("UPDATE sources SET cursor=?,prefix_digest=?,identity=? WHERE id=?", end, `${this.source.cursor}:${this.recordDigest.copy().digest("hex")}`, identity(this.initial), this.id);
|
|
298
308
|
this.store.bump();
|
|
299
309
|
});
|
|
300
310
|
this.source.cursor = end;
|
|
@@ -392,13 +402,25 @@ export class SourceIngest {
|
|
|
392
402
|
if (count !== bytes.length)
|
|
393
403
|
throw new Error("Journal truncated during prefix verification.");
|
|
394
404
|
this.store.operations.sourceBytesRead += count;
|
|
395
|
-
this.
|
|
405
|
+
this.verified.update(bytes);
|
|
406
|
+
if (this.legacy) {
|
|
407
|
+
// A whole-prefix check also finds the last indexed line, which anchors every later check.
|
|
408
|
+
const before = this.source.cursor - 2 - this.verifyAt, lf = before < 0 ? -1 : bytes.lastIndexOf(10, before);
|
|
409
|
+
if (lf < 0)
|
|
410
|
+
this.line.update(bytes);
|
|
411
|
+
else {
|
|
412
|
+
this.lineStart = this.verifyAt + lf + 1;
|
|
413
|
+
this.line = createHash("sha256").update(bytes.subarray(lf + 1));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
396
416
|
this.verifyAt += count;
|
|
397
417
|
if (this.verifyAt === this.source.cursor) {
|
|
398
|
-
if (this.
|
|
418
|
+
if (this.verified.digest("hex") !== this.expected) {
|
|
399
419
|
this.store.resetSource(this.id);
|
|
400
420
|
throw new HistoryIndexError("SOURCE_CHANGED", "Journal prefix changed; a new physical generation was scheduled.");
|
|
401
421
|
}
|
|
422
|
+
if (this.legacy)
|
|
423
|
+
this.store.run("UPDATE sources SET prefix_digest=? WHERE id=?", `${this.lineStart}:${this.line.digest("hex")}`, this.id);
|
|
402
424
|
this.phase = "read";
|
|
403
425
|
}
|
|
404
426
|
return false;
|
|
@@ -418,7 +440,6 @@ export class SourceIngest {
|
|
|
418
440
|
const newline = this.buffer.indexOf(10);
|
|
419
441
|
const bytes = newline < 0 ? this.buffer : this.buffer.subarray(0, newline + 1);
|
|
420
442
|
this.recordDigest.update(bytes);
|
|
421
|
-
this.prefix.update(bytes);
|
|
422
443
|
this.frames.write(bytes);
|
|
423
444
|
this.text.flush(this.store);
|
|
424
445
|
this.buffer = this.buffer.subarray(bytes.length);
|
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
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { ownedRunView } from "../runs/shared/run-records.js";
|
|
4
4
|
import { acceptanceHumanAction } from "../runs/shared/acceptance-evaluation.js";
|
|
5
|
-
import { getRunMetadataDir, listRunQuestions } from "../runs/shared/supervisor-questions.js";
|
|
5
|
+
import { getRunMetadataDir, listRunQuestions, summarizeSupervisorQuestion } from "../runs/shared/supervisor-questions.js";
|
|
6
6
|
import { HistoryIndexError } from "./types.js";
|
|
7
7
|
import { exactTextDigest, HistoryStore, safeText } from "./store.js";
|
|
8
8
|
import { readCanonicalOutput, readSavedOutput } from "./canonical-result.js";
|
|
@@ -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 = [];
|
|
@@ -25,9 +27,21 @@ const controlQueries = [];
|
|
|
25
27
|
function replyControls(id) {
|
|
26
28
|
send({ id, value: Boolean(store.get("SELECT id FROM runs WHERE attention<4 OR state='live' LIMIT 1")) });
|
|
27
29
|
}
|
|
28
|
-
function send(response) {
|
|
29
|
-
process.
|
|
30
|
-
|
|
30
|
+
function send(response) {
|
|
31
|
+
// The parent can disconnect after process.connected was read; then this and later replies are undeliverable, so stop as on disconnect.
|
|
32
|
+
if (process.connected)
|
|
33
|
+
process.send(response, undefined, undefined, (error) => { if (error)
|
|
34
|
+
shutdown(); });
|
|
35
|
+
}
|
|
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
|
+
}
|
|
31
45
|
function errorReply(id, error) {
|
|
32
46
|
const code = error instanceof HistoryIndexError ? error.code : "UNAVAILABLE";
|
|
33
47
|
// Parser errors can include fragments from the malformed body. IPC/logs must never carry them.
|
|
@@ -41,10 +55,10 @@ function compactView(view) {
|
|
|
41
55
|
return {
|
|
42
56
|
...view, task: safeText(view.task, 2048), error: view.error && safeText(view.error), diagnosis: view.diagnosis && safeText(view.diagnosis), recoveryError: view.recoveryError && safeText(view.recoveryError),
|
|
43
57
|
review: view.review && { ...view.review, message: view.review.message && safeText(view.review.message) }, continuations: [],
|
|
44
|
-
questions: view.questions?.map((question) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
questions: view.questions?.map((question) => {
|
|
59
|
+
const summary = summarizeSupervisorQuestion(question);
|
|
60
|
+
return { ...summary, message: safeText(question.message, 2048), answer: summary.answer && { ...summary.answer, message: safeText(question.answer?.message, 2048) } };
|
|
61
|
+
}),
|
|
48
62
|
children: view.children.map((child) => ({
|
|
49
63
|
agent: child.agent, index: child.index, workflowNodeId: child.workflowNodeId, sessionFile: child.sessionFile, task: child.task && safeText(child.task), label: child.label && safeText(child.label, 256),
|
|
50
64
|
state: child.state, configuration: child.configuration, missingSession: child.missingSession, identityUnavailable: child.identityUnavailable, modelSelection: child.modelSelection,
|
|
@@ -187,8 +201,11 @@ function prune(previousSources) {
|
|
|
187
201
|
store.run("DELETE FROM sources WHERE id=?", source.id);
|
|
188
202
|
removed = true;
|
|
189
203
|
}
|
|
190
|
-
if (removed)
|
|
204
|
+
if (removed) {
|
|
191
205
|
store.bump();
|
|
206
|
+
// A later admission of the same path can recreate source generation 1.
|
|
207
|
+
queries?.sourcesRemoved();
|
|
208
|
+
}
|
|
192
209
|
});
|
|
193
210
|
}
|
|
194
211
|
function admit(run) {
|
|
@@ -243,6 +260,7 @@ function pump() {
|
|
|
243
260
|
if (dirtyRuns.size) {
|
|
244
261
|
const id = dirtyRuns.values().next().value;
|
|
245
262
|
dirtyRuns.delete(id);
|
|
263
|
+
const failed = runErrors.has(id);
|
|
246
264
|
installWatches(id);
|
|
247
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);
|
|
248
266
|
try {
|
|
@@ -261,7 +279,7 @@ function pump() {
|
|
|
261
279
|
if (child.source_id !== job?.id)
|
|
262
280
|
dirtySources.set(child.source_id, dirtySources.get(child.source_id) ?? false);
|
|
263
281
|
installWatches(id);
|
|
264
|
-
changed();
|
|
282
|
+
changed(failed !== runErrors.has(id));
|
|
265
283
|
}
|
|
266
284
|
else if (job) {
|
|
267
285
|
if (job.step()) {
|
|
@@ -269,7 +287,8 @@ function pump() {
|
|
|
269
287
|
if (source)
|
|
270
288
|
watchSources(path.dirname(source.path));
|
|
271
289
|
job = undefined;
|
|
272
|
-
changed();
|
|
290
|
+
changed(source?.state !== sourceObservation?.state || source?.error !== sourceObservation?.error);
|
|
291
|
+
sourceObservation = undefined;
|
|
273
292
|
}
|
|
274
293
|
}
|
|
275
294
|
else if (dirtySources.size) {
|
|
@@ -278,6 +297,7 @@ function pump() {
|
|
|
278
297
|
const source = store.source(id);
|
|
279
298
|
if (source) {
|
|
280
299
|
activeSource = id;
|
|
300
|
+
sourceObservation = source;
|
|
281
301
|
job = new SourceIngest(store, source, force);
|
|
282
302
|
}
|
|
283
303
|
}
|
|
@@ -298,8 +318,11 @@ function pump() {
|
|
|
298
318
|
else
|
|
299
319
|
store.run("UPDATE sources SET state='error',error=?,checked_at=? WHERE id=?", "Linked native conversation could not be indexed.", Date.now(), id);
|
|
300
320
|
}
|
|
301
|
-
if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY"))
|
|
302
|
-
|
|
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;
|
|
303
326
|
}
|
|
304
327
|
activeSource = undefined;
|
|
305
328
|
finishBarriers();
|
|
@@ -324,6 +347,8 @@ function clear() {
|
|
|
324
347
|
retryTimer = undefined;
|
|
325
348
|
job?.close();
|
|
326
349
|
job = undefined;
|
|
350
|
+
sourceObservation = undefined;
|
|
351
|
+
notifiedVersion = undefined;
|
|
327
352
|
for (const watcher of watchers.values())
|
|
328
353
|
watcher.close();
|
|
329
354
|
watchers.clear();
|
|
@@ -458,4 +483,5 @@ async function handle(request) {
|
|
|
458
483
|
}
|
|
459
484
|
}
|
|
460
485
|
process.on("message", (request) => { void handle(request); });
|
|
461
|
-
|
|
486
|
+
function shutdown() { closed = true; clearInterval(timer); clear(); process.exit(0); }
|
|
487
|
+
process.on("disconnect", shutdown);
|