software-defence-factory 0.15.3 → 0.15.5

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/docs/npm.md CHANGED
@@ -282,3 +282,13 @@ reject tampered selections/images/policies and unsupported future writers, and
282
282
  verify that readback does not rewrite retained evidence. Image adoption is a
283
283
  separate idle operator action; existing attempt images are not migrated by a CLI
284
284
  update. See [the migration/rollback recipe](setup.md#local-context-budget-and-worker-image-0152-100).
285
+
286
+
287
+ The 0.15.5 compatibility audit adds that exact writer to the explicit v1/v2/v3
288
+ allowlists. The examined change is confined to local Pi projection, proactive
289
+ recovery and completion validation. Evidence schemas, frozen commands, model and
290
+ image selections, policy hashes, candidate reconstruction and acceptance guards
291
+ remain unchanged from 0.15.4. Profile tests exercise all phases and inherited,
292
+ cloud, hybrid and local definitions, reject changed selections and future writers,
293
+ and verify immutable readback. This does not upgrade or qualify historical local
294
+ attempts; installed image adoption and actual inference remain separate gates.
package/docs/recovery.md CHANGED
@@ -44,6 +44,22 @@ retry around a retained writer or reuse earlier story evidence for a changed
44
44
  candidate or policy. Browser traces and screenshots are private evidence and
45
45
  are presented through the shared interfaces. See [the web contract](web-verification.md).
46
46
 
47
+ ## Local Pi history exhaustion
48
+
49
+ The 0.15.5 candidate adds bounded proactive recovery for short histories, including
50
+ productive writes whose immutable arguments require fewer retained groups;
51
+ see [limits and image adoption](setup.md#local-context-budget-and-worker-image-0152-100).
52
+ A failed/empty summary, exhausted recovery limit, cancellation or irreducible
53
+ request remains a failed attempt even if a tool already wrote a report. Keep its
54
+ checkout, raw events and private diagnostic evidence. Reconcile the stopped
55
+ worker before admitting a smaller task or explicitly selecting another qualified
56
+ profile as a separate attempt. Do not enlarge the allocation, rewrite a failed
57
+ history or label an explicit cloud retry as local success. Successful transport
58
+ recovery still needs semantic checks and independent Review. The reviewed first
59
+ checkpoint failed real local qualification; the revised artifact needs a new
60
+ lead-owned installed and real-task pass before acceptance. See the
61
+ [rejected trial and source regression](proof.md#short-local-pi-history-recovery-0155-candidate-105).
62
+
47
63
  ## Review feedback or phase retry
48
64
 
49
65
  Use **Request changes** on a failed software review only when its validated
package/docs/setup.md CHANGED
@@ -509,6 +509,40 @@ guessed or silently rewritten. Do not add project Pi settings to bypass
509
509
  the admitted binding or rely on custom tuning that has not been qualified.
510
510
  See [pinned compaction behavior](https://github.com/earendil-works/pi/blob/v0.87.1/packages/coding-agent/docs/compaction.md).
511
511
 
512
+ Starting with the **0.15.5 candidate (#105)**, the local adapter also checks
513
+ history pressure at Pi's completed-tool-turn boundary. This conservative byte
514
+ signal counts the system/task/tool envelope once, call arguments, non-tool text
515
+ and capped tool excerpts, separately from provider-reported usage. At 65% of the
516
+ input budget it considers Pi's supported summary generator and append-only
517
+ compaction entry. The newest complete tool group is retained; up to three older
518
+ complete groups can remain as additional evidence when the actual summary and
519
+ retained envelope fit. The original task is restored exactly on every generation;
520
+ raw events and existing session entries are not rewritten. Large-result bursts
521
+ still use upstream compaction and the final request projection. Projection gives
522
+ recent short results (up to 2 KiB each, 8 KiB total and at most a quarter of the
523
+ input budget) priority when they fit.
524
+
525
+ Recovery allows at most 16 proactive summaries per process and two retries per
526
+ summary. It must reduce pressure by at least 4 KiB and leave headroom of 8 KiB
527
+ (or 15% of the input budget when smaller). Its preferred reduction target adapts
528
+ to the immutable latest-group/envelope floor and actual summary size; it cannot
529
+ require that floor to fit an arbitrary fraction of the trigger. The next trigger
530
+ also leaves room for new work. If the latest write/group alone makes recovery
531
+ unproductive, the adapter defers to a later complete turn under the unchanged
532
+ wire guard, when that group can become summary input. This does not permit an
533
+ oversized request through transport. Empty, failed, cancelled or ineffective
534
+ summaries fail the attempt. Oversized instructions, arguments or retained groups
535
+ can still fail closed. These are bounded recovery limits, not automatic
536
+ context/output tuning or a guarantee of useful model work.
537
+
538
+ The selected context, generation output and model remain unchanged; summary
539
+ output is separately capped (6,553 for proactive summaries with 8,192 selected
540
+ output on pinned Pi). `factory_context_recovery` JSONL events record only
541
+ pressure, envelope/floor sizes, group counts and recovery progress, without
542
+ prompts/content. Pi token accounting stays partial; this summary path is not
543
+ added to ordinary assistant usage totals. See
544
+ [source measurements, rejected real trial and pending qualification](proof.md#short-local-pi-history-recovery-0155-candidate-105).
545
+
512
546
  Before adoption, stop and reconcile all work, retain the old immutable image ID
513
547
  and a stopped private-state backup. Build/qualify the exact candidate image, then
514
548
  explicitly select it with `factory install --image LOCAL_IMAGE_REF --state PRIVATE_STATE`
package/docs/usage.md CHANGED
@@ -46,3 +46,60 @@ Historical recovery is optional and budgeted to eight lookups per second and
46
46
  512 cached terminal attempts per controller lifetime. Further attempts remain
47
47
  unknown; new persisted measurements bypass these legacy read limits. This
48
48
  prevents large old queues from rereading all logs on every status poll.
49
+
50
+ ## Pi stdout measurements (0.15.4)
51
+
52
+ New Pi attempts use `source: "pi_jsonl"`. Factory collects stdout before bounded
53
+ log truncation; stderr, tool results and streaming updates cannot supply usage.
54
+ The audited official `@earendil-works/pi-coding-agent` **0.87.1** OpenAI
55
+ completions adapter reports uncached `input`, `cacheRead`, `cacheWrite`, `output`
56
+ and their sum in `totalTokens`. Factory maps them to the existing contract:
57
+
58
+ - `input_tokens = input + cacheRead + cacheWrite` (inclusive input).
59
+ - `cached_input_tokens = cacheRead`, and `cache_write_input_tokens = cacheWrite`.
60
+ - `token_usage = input_tokens + output_tokens`; neither cache subset is added again.
61
+
62
+ Cache-write attribution is optional for historical/Codex records, not assumed
63
+ zero when absent. Pi requires all five counters, validates their sum, and
64
+ preserves exact decimal strings. Unsafe numeric values are rejected. An all-zero
65
+ Pi usage object remains unknown: the installed provider also emits this initial
66
+ placeholder when no usage arrived, including failed requests. Provider cost
67
+ fields and configured zero prices do not establish monetary consumption; local
68
+ hardware/electricity and unpriced provider cost remain unknown.
69
+
70
+ The pinned agent loop emits completed `message_end` assistant messages and
71
+ repeats them, in order, in that agent turn's `agent_end.messages`. Factory
72
+ reconciles positions within each `agent_start`/`agent_end` interval. Equal token
73
+ counts and equal timestamps are not unique IDs: distinct positions and explicit
74
+ new agent turns count separately. Repeated snapshots do not add tokens; a final
75
+ aggregate alone can supply counts. Contradictory or ambiguous snapshots never
76
+ add unmatched counts. Missing, malformed and oversized evidence cannot turn an
77
+ observation into complete coverage or a measured zero.
78
+
79
+ All Pi observations currently have **partial** coverage, even after a successful
80
+ run. Pi 0.87.1 performs compaction summary calls outside the visible assistant
81
+ aggregate and can retry provider requests without independent failed-request
82
+ usage accounting. A recovered job can succeed while these totals remain
83
+ incomplete. Absence of a retry/compaction event is not proof of full coverage.
84
+ No scorer, cost estimate, quality claim or acceptance authority follows from
85
+ these measurements.
86
+
87
+ Parsing is bounded to an 8 MiB line buffer, 262,144 framed lines, 2,048 observed
88
+ assistant messages and at most 1 MiB of retained counter signatures per turn.
89
+ Message content is discarded after each line. Counts and aggregate totals are
90
+ bounded to 128 decimal digits. Beyond a limit, already observed totals remain
91
+ partial; with no usable evidence the result is unknown.
92
+
93
+ A private, atomic `jobs/JOB/RUN/usage.json` checkpoint contains only usage and
94
+ job/run/phase/frozen execution identity. It is controller-owned, outside the
95
+ worker output mount, and preserves observations when cancellation kills the
96
+ executor before its final result. Normal completion stores the same normalized
97
+ usage in SQLite and measurement artifacts. CLI `status`, the status API and
98
+ existing Analytics/issue detail consume that shared contract after restart.
99
+ Model/provider/role attribution still comes from the frozen execution profile,
100
+ never from usage event fields. Failed/cancelled runs keep their outcome.
101
+
102
+ There is **no Pi historical log backfill**. Existing records and mixed or
103
+ truncated legacy logs are not rewritten. This bounded measurement slice leaves
104
+ #51's scoring/benchmark engine, #69's model qualification and #70's improvement
105
+ proposal workflow open.
@@ -27,16 +27,20 @@ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
27
27
  // 0.15.3 isolates local Pi mutable state and denies project resource trust.
28
28
  // Evidence schemas, candidate reconstruction and frozen profile guards are unchanged;
29
29
  // compatibility does not qualify an old local startup or accept a failed attempt.
30
+ // 0.15.4 adds Pi usage metadata only; protected evidence schemas, frozen
31
+ // execution selection, isolation and acceptance policy remain unchanged.
32
+ // 0.15.5 changes only local context recovery/projection; v1/v2/v3 schemas,
33
+ // frozen selection hashes, provenance, checks and acceptance guards are unchanged.
30
34
  // Older writers still require exact patch/tree reconstruction before publication.
31
35
  // Deliberately independent of VERSION: a release bump is not
32
36
  // evidence compatibility. Re-audit this list for every trust-relevant writer,
33
37
  // isolation or validation change; remove versions whose guarantees no longer
34
38
  // satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
35
- const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0', '0.13.0', '0.13.1', '0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3']);
39
+ const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0', '0.13.0', '0.13.1', '0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5']);
36
40
 
37
41
  export function isSupportedExecutionProfile(profile) {
38
- if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2', '0.15.3'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
39
- : ['0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
42
+ if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
43
+ : ['0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
40
44
  && profile.role === (Object.keys(ROLE_PHASES).find(role => ROLE_PHASES[role] === profile.phase) || null)
41
45
  && (profile.reasoningEffort === null || ['low', 'medium', 'high'].includes(profile.reasoningEffort))
42
46
  && /^[a-f0-9]{64}$/.test(profile.selectionHash || '');
@@ -9,6 +9,7 @@ import { ROOT, run, save, json, digest, instanceLabel, stopContainers } from './
9
9
  import { incidentFor, validateReport } from './incident.mjs';
10
10
  import { BoundedLog } from './bounded-log.mjs';
11
11
  import { CodexUsageParser, emptyUsage, usageFields } from './usage.mjs';
12
+ import { PiUsageParser } from './pi-usage.mjs';
12
13
  import { assertRetainedSource, publicSourceAdmission, publicContinuation, restoreBuildCheckout } from './source-admission.mjs';
13
14
  import { runCandidateGit, runCandidateGitRaw } from './git-environment.mjs';
14
15
  import { selectedInferenceSecrets, writeSelectedModelEnvironment } from './model-environment.mjs';
@@ -103,7 +104,9 @@ async function container(mode, input, command, options = {}) {
103
104
  if (scratch) args.push('--mount',`type=bind,source=${scratch},target=/scratch`);
104
105
  if (mode === 'verify') args.push('--env',`FACTORY_BASE_REVISION=${git('rev-parse',`${metadata().base}^{commit}`)}`);
105
106
  const logPath = join(folder, attempt, `${mode}.log`);
106
- const log = new BoundedLog(), usageParser = execution.executor === 'codex' ? new CodexUsageParser() : null;
107
+ const log = new BoundedLog(), usageParser = mode === 'verify' ? null
108
+ : execution.executor === 'codex' ? new CodexUsageParser() : execution.executor === 'pi' ? new PiUsageParser() : null;
109
+ let savedObservations = 0;
107
110
  let exitSignal, selectedModelEnvironment = false, inferenceSecrets = [], code, cleanupError, reportError;
108
111
  try {
109
112
  if (localDirectory) {
@@ -127,7 +130,17 @@ async function container(mode, input, command, options = {}) {
127
130
  console.log(JSON.stringify({ phase: mode, event: 'started', synthetic: harnessOf(config) === 'mock' }));
128
131
  code = await new Promise((ok, fail) => {
129
132
  const child = spawn('docker', args, { stdio: ['pipe','pipe','pipe'] });
130
- child.stdout.on('data', bytes => { log.write('stdout', bytes); usageParser?.write(bytes); });
133
+ child.stdout.on('data', bytes => {
134
+ usageParser?.write(bytes);
135
+ // Controller-owned metadata, outside the worker's /output mount. Atomic
136
+ // checkpoints survive cancellation without consulting mixed log tails.
137
+ if (usageParser instanceof PiUsageParser && usageParser.observed !== savedObservations) {
138
+ observedUsage = usageParser.snapshot();
139
+ save(join(folder, attempt, 'usage.json'), { version: 1, job, attempt, phase, execution, usage: observedUsage });
140
+ savedObservations = usageParser.observed;
141
+ }
142
+ log.write('stdout', bytes);
143
+ });
131
144
  child.stderr.on('data', bytes => log.write('stderr', bytes));
132
145
  child.stdin.on('error',error => { if (error.code !== 'EPIPE') fail(error); });
133
146
  child.on('error',fail); child.on('close',(code,signal) => { exitSignal=signal; ok(code); }); child.stdin.end(input);
@@ -35,6 +35,16 @@ function excerpt(text, limit) {
35
35
  return Buffer.byteLength(shortened) < Buffer.byteLength(text) ? shortened : text;
36
36
  }
37
37
 
38
+ // A pressure signal, not a token estimate: cap large tool bodies so upstream's
39
+ // existing large-result recovery still owns bursts, but count every call/argument
40
+ // and a useful short excerpt. Provider usage cannot measure this growing floor.
41
+ export function historyPressure(messages, envelope) {
42
+ const copy = structuredClone(messages);
43
+ for (const message of copy) if (message.role === 'toolResult')
44
+ for (const part of message.content || []) if (part.type === 'text') part.text = excerpt(part.text, 1024);
45
+ return envelope + bytes(copy) + 64 * copy.length;
46
+ }
47
+
38
48
  // Uniform cap shares space among large results without penalizing small ones.
39
49
  // Calls, arguments, identities, ordering and all non-tool text are immutable.
40
50
  export function fitToolText(value, budget, measure = bytes, native = false) {
@@ -49,8 +59,27 @@ export function fitToolText(value, budget, measure = bytes, native = false) {
49
59
  for (const part of message.content) if (part.type === 'text') slots.push([part, 'text', part.text]);
50
60
  }
51
61
  }
52
- const cap = limit => { for (const [object, key, text] of slots) object[key] = excerpt(text, limit); };
62
+ // Prefer recent short discoveries within a bounded allowance; large batches
63
+ // still share the remaining space. Prefix stability is measured separately.
64
+ const recent = new Set();
65
+ let recentBytes = 0;
66
+ for (const slot of [...slots].reverse()) {
67
+ const size = bytes(slot[2]);
68
+ if (size <= 2048 && recentBytes + size <= Math.min(8192, budget / 4)) {
69
+ recent.add(slot); recentBytes += size;
70
+ }
71
+ }
72
+ const cap = limit => { for (const slot of slots) {
73
+ const [object, key, text] = slot;
74
+ object[key] = recent.has(slot) ? text : excerpt(text, limit);
75
+ } };
53
76
  cap(0);
77
+ // Release older preferences first if the complete preferred set cannot fit.
78
+ // Even one useful recent result is better than uniformly shortening them all.
79
+ for (const slot of [...recent].reverse()) {
80
+ if (measure(original) <= budget) break;
81
+ recent.delete(slot); cap(0);
82
+ }
54
83
  if (measure(original) > budget) throw new Error('Local Pi context budget cannot fit required instructions, tool pairs and truncation guidance; use smaller task/input or qualify the configured allocation. No fallback.');
55
84
  let low = 0, high = Math.max(0, ...slots.map(([, , text]) => text.length));
56
85
  while (low < high) {
@@ -1,19 +1,87 @@
1
1
  // Explicitly mounted Factory adapter, never discovered from the host/repository.
2
2
  import { readFileSync, writeSync } from 'node:fs';
3
3
  import { openAICompletionsApi } from '@earendil-works/pi-ai';
4
- import { bytes, fitToolText, projectedTokens, restoreOutputBudget, validatePairs } from './pi-context-budget.mjs';
4
+ import { generateSummaryWithUsage } from '@earendil-works/pi-coding-agent';
5
+ import { historyPressure, bytes, fitToolText, projectedTokens, restoreOutputBudget, validatePairs } from './pi-context-budget.mjs';
5
6
 
6
7
  export default function factoryLocalContext(pi) {
7
8
  const registry = JSON.parse(readFileSync(new URL('./models.json', import.meta.url), 'utf8'));
8
9
  const configured = registry.providers['factory-local'].models[0];
9
10
  const window = configured.contextWindow, output = configured.maxTokens;
10
- let task;
11
+ let task, taskEntryId, envelope = 0, recoveries = 0, nextRecovery = 0;
11
12
  // Pi catches extension exceptions and can continue. Budget violations must
12
13
  // stop before transport, even during summarization, with a durable diagnostic.
13
14
  const fatal = error => {
14
15
  writeSync(2, `Factory local Pi context protection failed: ${error.message}\n`);
15
16
  process.exit(1);
16
17
  };
18
+ // Pi 0.87.1's public manual compact() aborts the active run. Use its
19
+ // supported completed-turn compaction draft instead: all tools have finished,
20
+ // and the next request rebuilds the canonical projection from this entry.
21
+ pi.on('turn_end', async (event, ctx) => {
22
+ if (event.outcome !== 'completed' || event.message.stopReason !== 'toolUse') return;
23
+ try {
24
+ const entries = event.context.contextEntries;
25
+ taskEntryId ??= entries.find(entry => entry.messages.some(m => m.role === 'user'))?.sourceEntry.id;
26
+ // Pi includes the system message in its boundary projection. The wire
27
+ // envelope already counts it and the restored task: count each once and
28
+ // use the same basis before/after, so removal cannot fake progress.
29
+ const history = list => list.filter(entry => entry.sourceEntry.id !== taskEntryId)
30
+ .flatMap(entry => entry.messages).filter(message => message.role !== 'system');
31
+ const budget = window - output;
32
+ const threshold = Math.max(Math.floor(budget * 0.65), nextRecovery);
33
+ const pressure = historyPressure(history(entries), envelope);
34
+ if (pressure < threshold) return;
35
+ const starts = entries.flatMap((entry, index) => entry.messages.some(m => m.role === 'assistant'
36
+ && m.content?.some(p => p.type === 'toolCall')) ? [index] : []);
37
+ // The newest complete group is the retention floor, including immutable
38
+ // write arguments. Summarize the older prefix once; after seeing its real
39
+ // size we can retain up to four groups as additional recent evidence.
40
+ // Never split a group or summarize only part of a pending tool batch.
41
+ if (starts.length < 2) return;
42
+ const newest = starts.at(-1);
43
+ const floor = historyPressure(history(entries.slice(newest)), envelope);
44
+ const headroom = Math.min(8192, Math.floor(budget * 0.15));
45
+ // A large current call may leave no useful recovery yet. Let it reach the
46
+ // next ordinary turn under the unchanged wire guard, then it can enter
47
+ // the summarized prefix. Avoid paying for a predictably futile summary.
48
+ if (floor + 1024 > budget - headroom || pressure - floor < 4096) return;
49
+ if (++recoveries > 16) throw new Error('Local Pi proactive recovery limit reached; split the task. No fallback.');
50
+ if (ctx.signal?.aborted) throw new Error('Local Pi proactive recovery cancelled');
51
+ // Pi's summary serializer ignores system messages. Do not charge their
52
+ // bytes against the summary input projection; generation still keeps the
53
+ // exact system envelope. Keep the task and all summarized tool pairs.
54
+ const older = entries.slice(0, newest).flatMap(entry => entry.messages).filter(m => m.role !== 'system');
55
+ writeSync(1, `${JSON.stringify({ type: 'factory_context_recovery', phase: 'start', pressure, threshold, floor, envelope, groups: starts.length, recovery: recoveries })}\n`);
56
+ const input = fitToolText(older, window - output - 8192, bytes, true);
57
+ const result = await generateSummaryWithUsage(input, ctx.model, Math.min(16384, output),
58
+ 'factory-local-keyless', undefined, ctx.signal,
59
+ 'Preserve the task, verified edits, failed attempts, discoveries and remaining work. Do not claim completion without evidence.',
60
+ undefined, ctx.thinkingLevel, streamLocal, undefined, { enabled: true, maxRetries: 2, baseDelayMs: 250 });
61
+ if (ctx.signal?.aborted) throw new Error('Local Pi proactive recovery cancelled');
62
+ if (!result.text?.trim()) throw new Error('Local Pi proactive recovery returned an empty summary');
63
+ // Include framing for Pi's summary message. A fixed percentage cannot be
64
+ // required below the immutable envelope/newest-group floor. Bound both
65
+ // remaining headroom and actual progress instead, using the real summary.
66
+ const summaryBytes = bytes(result.text) + 1024;
67
+ const target = Math.min(budget - headroom, pressure - 4096,
68
+ Math.max(Math.floor(budget * 0.65 * 0.75), floor + summaryBytes));
69
+ let cut, after;
70
+ for (const candidate of starts.slice(-4)) {
71
+ const candidatePressure = historyPressure(history(entries.slice(candidate)), envelope + summaryBytes);
72
+ if (candidatePressure <= target) { cut = candidate; after = candidatePressure; break; }
73
+ }
74
+ if (cut === undefined) throw new Error('Local Pi proactive recovery did not free enough budget; split the task. No fallback.');
75
+ // Re-arm above the measured retained floor, so a productive large write
76
+ // does not cause an immediate repeat summary with nothing new to reclaim.
77
+ nextRecovery = Math.min(budget - Math.floor(headroom / 2), after + 4096);
78
+ writeSync(1, `${JSON.stringify({ type: 'factory_context_recovery', phase: 'complete', pressure: after, target,
79
+ summaryBytes, keptGroups: starts.filter(index => index >= cut).length, recovery: recoveries })}\n`);
80
+ return { entries: [{ type: 'compaction', summary: result.text,
81
+ firstKeptEntryId: entries[cut].sourceEntry.id, usage: result.usage,
82
+ details: { factoryLocalRecovery: 1 } }] };
83
+ } catch (error) { fatal(error); }
84
+ });
17
85
  pi.on('session_before_compact', event => {
18
86
  try {
19
87
  // These are request-local copies. Upstream still selects cut points and
@@ -25,39 +93,39 @@ export default function factoryLocalContext(pi) {
25
93
  preparation[key] = fitToolText(preparation[key], budget, bytes, true);
26
94
  } catch (error) { fatal(error); }
27
95
  });
28
- pi.registerProvider('factory-local', {
29
- api: 'openai-completions',
30
- streamSimple(model, context, options) {
31
- if (model.id !== configured.id || model.provider !== 'factory-local') fatal(new Error('Selected local model changed'));
32
- // Capture before streamSimple/buildBaseOptions clamps against unprojected
33
- // history. Generation defaults to the binding; summaries supply their cap.
34
- const requestedOutput = options?.maxTokens ?? output;
35
- return openAICompletionsApi().streamSimple(model, context, {
36
- ...options,
37
- onPayload: async (payload, requestModel) => {
38
- try {
39
- payload = await options?.onPayload?.(payload, requestModel) ?? payload;
40
- payload = structuredClone(payload);
41
- if (payload.model !== configured.id
42
- || !payload.messages?.some(m => m.role === 'system')) throw new Error('Unsupported local request structure or output budget');
43
- restoreOutputBudget(payload, configured, requestedOutput, options?.reasoning);
44
- if (payload.tools?.length) {
45
- task ??= structuredClone(payload.messages.find(m => m.role === 'user'));
46
- if (!task) throw new Error('Missing required task');
47
- if (!payload.messages.some(m => m.role === 'user' && JSON.stringify(m) === JSON.stringify(task)))
48
- payload.messages.splice(1, 0, structuredClone(task));
49
- validatePairs(payload.messages);
50
- }
51
- if (!payload.messages.some(m => m.role === 'user')) throw new Error('Missing required user message');
52
- // Restore BEFORE measuring so the final output field is counted.
53
- // No request is sent unless projection fits with the full configured
54
- // reserve, even when a summary requests a smaller bounded output.
55
- return fitToolText(payload, window - output, projectedTokens);
56
- } catch (error) { fatal(error); }
57
- },
58
- });
59
- },
60
- });
96
+ function streamLocal(model, context, options) {
97
+ if (model.id !== configured.id || model.provider !== 'factory-local') fatal(new Error('Selected local model changed'));
98
+ // Capture before streamSimple/buildBaseOptions clamps against unprojected
99
+ // history. Generation defaults to the binding; summaries supply their cap.
100
+ const requestedOutput = options?.maxTokens ?? output;
101
+ return openAICompletionsApi().streamSimple(model, context, {
102
+ ...options,
103
+ onPayload: async (payload, requestModel) => {
104
+ try {
105
+ payload = await options?.onPayload?.(payload, requestModel) ?? payload;
106
+ payload = structuredClone(payload);
107
+ if (payload.model !== configured.id
108
+ || !payload.messages?.some(m => m.role === 'system')) throw new Error('Unsupported local request structure or output budget');
109
+ restoreOutputBudget(payload, configured, requestedOutput, options?.reasoning);
110
+ if (payload.tools?.length) {
111
+ task ??= structuredClone(payload.messages.find(m => m.role === 'user'));
112
+ if (!task) throw new Error('Missing required task');
113
+ if (!payload.messages.some(m => m.role === 'user' && JSON.stringify(m) === JSON.stringify(task)))
114
+ payload.messages.splice(1, 0, structuredClone(task));
115
+ validatePairs(payload.messages);
116
+ envelope = bytes(payload.tools) + bytes(payload.messages.filter(m => m.role === 'system'))
117
+ + bytes(task) + 4096;
118
+ }
119
+ if (!payload.messages.some(m => m.role === 'user')) throw new Error('Missing required user message');
120
+ // Restore BEFORE measuring so the final output field is counted.
121
+ // No request is sent unless projection fits with the full configured
122
+ // reserve, even when a summary requests a smaller bounded output.
123
+ return fitToolText(payload, window - output, projectedTokens);
124
+ } catch (error) { fatal(error); }
125
+ },
126
+ });
127
+ }
128
+ pi.registerProvider('factory-local', { api: 'openai-completions', streamSimple: streamLocal });
61
129
  // Launcher requires this handshake; an extension load failure cannot bypass it.
62
130
  writeSync(1, `${JSON.stringify({ type: 'factory_context_ready', version: 1 })}\n`);
63
131
  }
@@ -24,6 +24,8 @@ export class PiCompletion {
24
24
  settled = false;
25
25
  lastAssistant = null;
26
26
  contextReady = false;
27
+ proactiveRecovery = false;
28
+ proactiveCount = 0;
27
29
 
28
30
  write(chunk) {
29
31
  this.pending += chunk;
@@ -42,6 +44,17 @@ export class PiCompletion {
42
44
  event(value) {
43
45
  if (!value || typeof value.type !== 'string') { this.failed = true; return; }
44
46
  if (value.type === 'factory_context_ready' && value.version === 1) this.contextReady = true;
47
+ if (value.type === 'factory_context_recovery') {
48
+ if (value.phase === 'start') {
49
+ if (this.proactiveRecovery || value.recovery !== this.proactiveCount + 1 || value.recovery > 16) this.failed = true;
50
+ this.proactiveCount = value.recovery;
51
+ this.proactiveRecovery = true;
52
+ this.settled = false;
53
+ } else if (value.phase === 'complete') {
54
+ if (!this.proactiveRecovery || value.recovery !== this.proactiveCount) this.failed = true;
55
+ this.proactiveRecovery = false;
56
+ } else this.failed = true;
57
+ }
45
58
  if (value.type === 'agent_start' || value.type === 'message_start') {
46
59
  this.completed = false; this.settled = false;
47
60
  }
@@ -95,7 +108,7 @@ export class PiCompletion {
95
108
 
96
109
  finish() {
97
110
  return !this.pending && !this.failed && !this.error && !this.retry && !this.compacting && !this.recovery
98
- && this.completed && (!this.modern || this.settled);
111
+ && !this.proactiveRecovery && this.completed && (!this.modern || this.settled);
99
112
  }
100
113
  }
101
114
 
@@ -0,0 +1,106 @@
1
+ import { UsageLineParser, tokenCount, MAX_USAGE_EVENTS, MAX_USAGE_COUNT_DIGITS } from './usage.mjs';
2
+
3
+ // Pi's final aggregate includes message content and can be much larger than a
4
+ // Codex usage line. Discard content after parsing; retain only bounded counters.
5
+ export const MAX_PI_USAGE_LINE_BYTES = 8 * 1024 * 1024;
6
+ export const MAX_PI_USAGE_METADATA_BYTES = 1024 * 1024;
7
+ export const MAX_PI_USAGE_EVENTS = 262144;
8
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+
10
+ function counts(message) {
11
+ if (!object(message.usage) || !['stop', 'toolUse', 'length', 'error', 'aborted'].includes(message.stopReason)) return null;
12
+ try {
13
+ const { input, output, cacheRead, cacheWrite, totalTokens } = message.usage;
14
+ const uncached = tokenCount(input), out = tokenCount(output), read = tokenCount(cacheRead), write = tokenCount(cacheWrite);
15
+ const inclusive = uncached + read + write;
16
+ if (inclusive + out !== tokenCount(totalTokens)) return null;
17
+ if (Object.hasOwn(message.usage, 'reasoning') && tokenCount(message.usage.reasoning) > out) return null;
18
+ // The provider initializes missing/failed-request usage to zero. Even a
19
+ // successful stop with all zeros does not establish a measured zero call.
20
+ if (inclusive + out === 0n) return null;
21
+ return [inclusive, out, read, write].map(value => value.toString());
22
+ } catch { return null; }
23
+ }
24
+
25
+ export class PiUsageParser extends UsageLineParser {
26
+ constructor() {
27
+ super(MAX_PI_USAGE_LINE_BYTES);
28
+ this.totals = [0n, 0n, 0n, 0n];
29
+ this.observed = 0;
30
+ this.events = 0;
31
+ this.metadataBytes = 0;
32
+ this.messages = [];
33
+ this.ended = false;
34
+ this.ambiguous = false;
35
+ this.exhausted = false;
36
+ }
37
+
38
+ lineComplete() {
39
+ if (!this.exhausted && ++this.events > MAX_PI_USAGE_EVENTS) this.exhausted = true;
40
+ super.lineComplete();
41
+ }
42
+
43
+ parseLine() {
44
+ if (!this.length || this.exhausted) return;
45
+ let event;
46
+ try { event = JSON.parse(this.line.toString('utf8', 0, this.length)); }
47
+ catch { this.ambiguous = true; return; }
48
+ if (!object(event)) return;
49
+ // The pinned agent loop's newMessages starts afresh at each agent_start.
50
+ // Positions within that turn, not timestamps or equal token counts, match
51
+ // message_end to the repeated agent_end snapshot. New turns may be identical.
52
+ if (event.type === 'agent_start') {
53
+ this.messages = []; this.metadataBytes = 0;
54
+ this.ended = false; this.ambiguous = false;
55
+ } else if (event.type === 'message_end') {
56
+ if (!object(event.message)) { this.ambiguous = true; return; }
57
+ if (event.message.role !== 'assistant') return;
58
+ if (this.ended) { this.ambiguous = true; return; }
59
+ this.append(counts(event.message));
60
+ } else if (event.type === 'agent_end') {
61
+ if (!Array.isArray(event.messages) || event.messages.length > MAX_USAGE_EVENTS) { this.ambiguous = true; return; }
62
+ let index = 0;
63
+ for (const message of event.messages) {
64
+ if (!object(message)) { this.ambiguous = true; break; }
65
+ if (message.role !== 'assistant') continue;
66
+ const value = counts(message), signature = JSON.stringify(value);
67
+ if (index < this.messages.length) {
68
+ if (signature !== this.messages[index]) this.ambiguous = true;
69
+ } else if (!this.ended && !this.ambiguous && !this.incomplete) this.append(value);
70
+ else this.ambiguous = true;
71
+ index++;
72
+ }
73
+ if (index !== this.messages.length) this.ambiguous = true;
74
+ this.ended = true;
75
+ }
76
+ }
77
+
78
+ append(value) {
79
+ if (this.exhausted) return;
80
+ const signature = JSON.stringify(value);
81
+ if (this.observed >= MAX_USAGE_EVENTS || this.messages.length >= MAX_USAGE_EVENTS || this.metadataBytes + signature.length > MAX_PI_USAGE_METADATA_BYTES) {
82
+ this.exhausted = true; return;
83
+ }
84
+ this.messages.push(signature); this.metadataBytes += signature.length;
85
+ if (!value) return;
86
+ const next = this.totals.map((total, index) => total + BigInt(value[index]));
87
+ // Keep aggregate counts and their shared total representable on readback.
88
+ if ([...next, next[0] + next[1]].some(total => total.toString().length > MAX_USAGE_COUNT_DIGITS)) {
89
+ this.exhausted = true; return;
90
+ }
91
+ this.totals = next; this.observed++;
92
+ }
93
+
94
+ snapshot() {
95
+ if (!this.observed) return null;
96
+ const [input, output, cached, writes] = this.totals.map(String);
97
+ return { input_tokens: input, output_tokens: output, cached_input_tokens: cached,
98
+ cache_write_input_tokens: writes, source: 'pi_jsonl', coverage: 'partial' };
99
+ }
100
+
101
+ finish() {
102
+ // JSONL requires a newline. A trailing fragment is not a completed event.
103
+ if (this.length || this.overflow) this.incomplete = true;
104
+ return this.snapshot();
105
+ }
106
+ }
@@ -84,10 +84,35 @@ export function retainedCodexUsage(state, job, attempt) {
84
84
  } catch { return null; }
85
85
  }
86
86
 
87
+ // New Pi measurements are controller-owned metadata, never historical log
88
+ // backfill. A stopped executor may have checkpointed before producing a result.
89
+ export function retainedPiUsage(state, job, attempt) {
90
+ try {
91
+ if (!/^job_[a-z0-9]+$/.test(job?.id || '') || !/^run_[a-z0-9]+$/.test(attempt?.id || '')
92
+ || !['build', 'review', 'defence'].includes(attempt.command) || attempt.execution?.executor !== 'pi') return null;
93
+ const folder = join(state, 'jobs', job.id), runFolder = join(folder, attempt.id);
94
+ for (const path of [join(state, 'jobs'), folder, runFolder]) {
95
+ const stat = lstatSync(path);
96
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) return null;
97
+ }
98
+ const path = join(runFolder, 'usage.json'), stat = lstatSync(path);
99
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.size > 16384) return null;
100
+ const saved = JSON.parse(readFileSync(path, 'utf8'));
101
+ if (saved.version !== 1 || saved.job !== job.id || saved.attempt !== attempt.id || saved.phase !== attempt.command
102
+ || !isDeepStrictEqual(saved.execution, attempt.execution) || saved.usage?.source !== 'pi_jsonl') return null;
103
+ const normalized = usageFields(saved.usage, attempt.execution, attempt.command).usage;
104
+ return normalized.status ? null : normalized;
105
+ } catch { return null; }
106
+ }
107
+
108
+ function retainedUsage(state, job, attempt) {
109
+ return retainedPiUsage(state, job, attempt) || retainedCodexUsage(state, job, attempt);
110
+ }
111
+
87
112
  function usageForAttempt(state, job, attempt) {
88
113
  const stored = usageFields(attempt.usage, attempt.execution, attempt.command);
89
114
  if (stored.usage.status !== 'unknown') return stored;
90
- const recovered = retainedCodexUsage(state, job, attempt);
115
+ const recovered = retainedUsage(state, job, attempt);
91
116
  return recovered ? usageFields(recovered, attempt.execution, attempt.command) : stored;
92
117
  }
93
118
 
@@ -186,7 +211,7 @@ export function executors(state, recovery = {}) {
186
211
  try { exit = await done; }
187
212
  finally { clearTimeout(deadline); children.delete(job.id); }
188
213
  if (exit.error) throw exit.error;
189
- const recovered = retainedCodexUsage(state, job, attempt);
214
+ const recovered = retainedUsage(state, job, attempt);
190
215
  if (!existsSync(resultPath)) return { outcome: 'blocked', ...usageFields(recovered || emptyUsage(attempt.execution, attempt.command), attempt.execution, attempt.command), summary: `Executor exited ${exit.code} without a result` };
191
216
  let outcome;
192
217
  try { outcome = JSON.parse(readFileSync(resultPath, 'utf8')); }