software-defence-factory 0.15.0 → 0.15.2

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.
@@ -255,7 +255,7 @@ The binding contract supports at most 16 named, keyless HTTP(S) endpoints. URLs
255
255
  with user information, queries, fragments, escapes or malformed syntax are refused.
256
256
  Model identifiers cannot contain shell syntax, whitespace or traversal. Context
257
257
  must be 1,024–1,048,576 tokens; output must be 1–32,000 and smaller than context.
258
- The output ceiling follows pinned Pi 0.73.1's simple chat adapter. Range acceptance
258
+ The output ceiling remains 32,000 with the pinned Pi 0.87.1 chat adapter. Range acceptance
259
259
  is **not** evidence of server allocation or useful task capacity. Only the three
260
260
  shown compatibility fields are supported. Authentication, custom headers, shell
261
261
  credential commands, arbitrary request fields, sampling overrides, custom thinking
@@ -271,7 +271,7 @@ The optional binding `reasoningEffort` accepts only these OpenAI-style requests:
271
271
  | `low`, `medium`, `high` | `reasoning_effort` set to that exact value |
272
272
 
273
273
  This is a request choice, not a measured thinking budget or quality claim. The
274
- pinned Pi 0.73.1 adapter uses a fixed internal `thinkingLevelMap.off="none"` for
274
+ pinned Pi 0.87.1 adapter uses a fixed internal `thinkingLevelMap.off="none"` for
275
275
  `none`; `--thinking off` alone does **not** disable a server's default thinking.
276
276
  Low/medium/high use the corresponding explicit Pi thinking level. Existing
277
277
  bindings with no choice retain their omitted request and private policy shape;
@@ -295,7 +295,12 @@ For the selected local role only, the executor creates a private single-model
295
295
  `models.json` and deterministic launcher, mounted read-only at `/factory-local`.
296
296
  `PI_CODING_AGENT_DIR` selects that directory; HOME is still ephemeral. The launcher
297
297
  checks explicit provider/model argv, removes inherited credential/override inputs
298
- and propagates Pi JSON provider failures even if Pi exits zero. The registry uses
298
+ and requires a completed JSON turn even if Pi exits zero. Explicit successful
299
+ retry/overflow recovery can clear an assistant error; failed/aborted compaction,
300
+ exhausted retries, malformed/truncated JSONL and missing completion remain failures.
301
+ The original event stream is retained in the bounded private log. The launcher also
302
+ passes the fixed placeholder as Pi's supported runtime `--api-key`, avoiding a
303
+ credential-store refresh against the read-only registry. No credentials are added. The registry uses
299
304
  Pi's required fixed non-secret key placeholder (`factory-local-keyless`), which may
300
305
  be sent as a bearer value; this is not endpoint authentication. No host home, provider
301
306
  catalog or credential store is mounted. Repository files cannot replace this mount.
@@ -308,3 +313,9 @@ automatic skills/extensions/templates but explicitly adds `--skill /factory-skil
308
313
  preserving the six packaged skills. Recommendations remain guidance, not per-role
309
314
  access controls. Build still returns to Factory's independent Review and operator
310
315
  handoff; no duplicate review workflow is launched.
316
+
317
+
318
+ For long tool-use runs, use the [context-boundary qualification and image migration
319
+ recipe](setup.md#local-context-budget-and-worker-image-0152-100). Declared context
320
+ and output budgets are the same shared binding fields in CLI, API and dashboard;
321
+ there are no Factory compaction knobs or automatic larger-window/model choices.
package/docs/npm.md CHANGED
@@ -94,6 +94,18 @@ jobs and history are preserved. Existing processes keep their original code.
94
94
  Set `SDF_AUTO_UPDATE=0` to skip automatic network checks for a command.
95
95
  Source checkouts remain managed by Git and do not update themselves.
96
96
 
97
+ The exact selected release is downloaded with npm's `--prefer-online` metadata
98
+ refresh and `--prefer-offline=false`; this does not change account settings.
99
+ An `ETARGET` visibility failure
100
+ gets at most three attempts, separated by one second, within the same total
101
+ 120-second download budget (including waits). Offline, authentication and package
102
+ identity errors are not retried by Factory. A successful download must match the
103
+ requested package name, version and CLI entry before immutable adoption. Exhaustion
104
+ cleans only that download's staging directory and retains the selected runtime,
105
+ configuration and job history. Managed updates release their idle reservations on
106
+ download failure; normal reservation/recovery rules in [services](services.md)
107
+ still apply. The retries add no extra download maintenance budget.
108
+
97
109
  CLI updates do not rebuild or update Docker images automatically. Stop the
98
110
  installation and run `install --state PATH` when intentionally adopting a new
99
111
  runtime image; repeat the relevant qualification before resuming jobs. Install retains the old and new
@@ -104,8 +116,8 @@ installation or retained attempt needs them.
104
116
 
105
117
  ## Protected evidence compatibility
106
118
 
107
- Factory 0.15.0 recognizes version-1 execution profiles emitted by native
108
- **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 and 0.15.0**. This is an exact allowlist in
119
+ Factory 0.15.2 recognizes version-1 execution profiles emitted by native
120
+ **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 and 0.15.2**. This is an exact allowlist in
109
121
  `factory/execution-profile.mjs`, independent of the installed package version;
110
122
  it is not a semver range or an automatic promise for later releases. Unknown
111
123
  runtime strings, unknown profile formats and incomplete legacy acceptance
@@ -172,7 +184,7 @@ Verify evidence and cannot authorize acceptance or publication.
172
184
 
173
185
  Version 0.14.0 preserves the v1 writer only for unchanged inherited installations:
174
186
  policy bytes, candidate/check/review/acceptance bindings, mounts and credential
175
- rules stay compatible. Adopted role overrides use **v2 from 0.14.0 and 0.15.0**, recording
187
+ rules stay compatible. Adopted role overrides use **v2 from 0.14.0, 0.15.0, 0.15.1 and 0.15.2**, recording
176
188
  role/provider/effort and an exact selection digest. The executor verifies the
177
189
  frozen common configuration before phase selection; continuation and both delivery
178
190
  validation paths require matching protected v2 evidence and private frozen config.
@@ -199,6 +211,15 @@ publishes an increased `package.json` version. Existing versions are skipped;
199
211
  registry errors fail the release instead of masquerading as a missing version.
200
212
  The workflow can also be started manually from `main`.
201
213
 
214
+ An accepted npm publication may still be processing. It is not yet a verified,
215
+ downloadable release: exact-version and `latest` metadata can appear at different
216
+ times, and an npm install can briefly report `ETARGET` even after both appear.
217
+ Verify an exact package download and compare its contents with the reviewed
218
+ artifact before reporting release availability. If the bounded updater reports
219
+ that the release is not yet downloadable, retain the prior runtime and retry the
220
+ update later. This does not establish whether a particular delay came from a
221
+ local cache or registry propagation.
222
+
202
223
  For a release, update both manifests with `npm version patch --no-git-tag-version`,
203
224
  review the change, and push through the project's normal review flow. A code
204
225
  push without a version bump is tested but does not overwrite a published package.
@@ -233,7 +254,7 @@ The 0.15.0 compatibility audit preserves unchanged v1/v2 policy construction,
233
254
  protected checks, candidate reconstruction, read-only review and acceptance guards.
234
255
  Historical 0.14.0 v2 validation compares its actual writer version rather than
235
256
  relabeling it as the installed version. Local registry selection is a new execution
236
- contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0** and its
257
+ contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0, 0.15.1 or 0.15.2** and its
237
258
  exact protected frozen configuration. v1/v2 cannot attest local binding fields,
238
259
  providers or a common policy containing a selected local role. A selected endpoint,
239
260
  model, limit or compatibility change invalidates the current policy; explicit
@@ -241,3 +262,23 @@ rollback can restore it without editing historical evidence. Controlled regressi
241
262
  exercise legacy v2 readback and local v3 rejection/tamper cases. Installed-package
242
263
  Docker and independent Review remain delivery qualification, not inferred from this
243
264
  compatibility audit.
265
+
266
+ The 0.15.1 patch changes release downloads and preserves the 0.15.0 v1/v2/v3
267
+ writers, frozen configuration and policy bytes. The explicit allowlists include
268
+ both patch versions; supported v3 readback retains the original writer version
269
+ while still comparing every execution selection, phase and policy field. It does
270
+ not rewrite historical evidence or allow v1/v2 to attest local bindings.
271
+
272
+
273
+ The 0.15.2 compatibility audit adds that exact writer to the v1/v2/v3 allowlists.
274
+ It changes the local launcher's interpretation of explicit recovery/completion
275
+ and the standard image recipe, without changing the admitted command, binding
276
+ schema, frozen config/image/selection/policy hashes, protected checks, candidate
277
+ reconstruction or independent review/acceptance guards. Historical 0.15.0 and
278
+ 0.15.1 evidence remains readable with its original bytes and writer version;
279
+ readability does not qualify those images for long local tasks. Regression cases
280
+ exercise inherited, cloud-role, hybrid and local profiles through all phases,
281
+ reject tampered selections/images/policies and unsupported future writers, and
282
+ verify that readback does not rewrite retained evidence. Image adoption is a
283
+ separate idle operator action; existing attempt images are not migrated by a CLI
284
+ update. See [the migration/rollback recipe](setup.md#local-context-budget-and-worker-image-0152-100).
package/docs/setup.md CHANGED
@@ -430,3 +430,75 @@ leaves Pi usage unknown; controlled protocol fixtures are not token or quality
430
430
  benchmarks. Include cloud Review in hybrid totals. No provider token billing for
431
431
  local inference does not mean zero electricity, hardware or operator cost. Only
432
432
  then qualify a suitable real Factory issue; no customer jobs are part of this slice.
433
+
434
+
435
+ ## Local context budget and worker image (0.15.2, #100)
436
+
437
+ The standard worker recipe pins **`@earendil-works/pi-coding-agent@0.87.1`** from
438
+ [earendil-works/pi](https://github.com/earendil-works/pi/tree/v0.87.1), replacing
439
+ `@mariozechner/pi-coding-agent@0.73.1`. Pi requires **Node >=22.19.0**; the image
440
+ installation enforces dependency engines. Factory's controller Node requirement
441
+ is unchanged. The separate Codex pin remains **0.156.1**. A CLI upgrade does not
442
+ rebuild or replace an admitted job image. Source transport proof is documented in
443
+ [proof](proof.md#pi-context-boundary-first-slice-0152-100); installed image and real
444
+ local-model qualification are separate acceptance gates.
445
+
446
+ Set the selected private binding's `contextWindow` no higher than the server's
447
+ **actually allocated** context for that exact model/request, and set `maxTokens`
448
+ within both endpoint support and available headroom. These existing fields have
449
+ the same CLI/API/dashboard validation and frozen policy. An advertised registry
450
+ window allocates no server memory. For Ollama, configure allocation through its
451
+ supported server/model controls and inspect the running allocation with
452
+ `ollama ps`; see [Ollama context length](https://docs.ollama.com/context-length).
453
+ Factory does not pass an undocumented `num_ctx`, patch a model template or tune
454
+ the host. Unsupported endpoint controls require an explicit capability limit.
455
+
456
+ Pi 0.87.1's upstream defaults reserve 16,384 tokens and keep approximately 20,000
457
+ recent tokens. At a declared 65,536, its threshold is 49,152, checked **after tool
458
+ results and before the next assistant request in the same run**. This repairs a
459
+ missing check in 0.73.1; increasing its reserve alone cannot add that check. Output
460
+ reservation, tool-result growth, system/tools overhead and summarization must all
461
+ fit the allocation. Prefix compaction alone cannot shrink one large retained
462
+ assistant/tool-result group. The bundled local adapter therefore projects tool
463
+ text into a conservative byte budget before generation and summarization, keeping
464
+ the exact task and tool identities/order. Truncation notices tell the model to
465
+ re-read with offset/limit or narrower queries; raw events/files stay independent
466
+ of this projection. Every request reserves the full configured output, includes
467
+ schemas/arguments and adds template headroom. This is conservative estimation,
468
+ not measured token usage or an allocation/completion guarantee. Unshrinkable
469
+ instructions/arguments fail explicitly rather than being silently removed.
470
+ Pi estimates native history before the wire projection and can reduce output to
471
+ one token. The adapter restores the selected generation allowance only with a
472
+ fitting final payload; explicit upstream summary caps stay bounded separately.
473
+ Both admitted `max_tokens` and `max_completion_tokens` compatibility fields are
474
+ supported. The existing OpenAI `reasoning_effort` selection remains inside that
475
+ total output ceiling; no extra reasoning allowance is added. Numeric/nested
476
+ thinking-budget formats are unsupported and fail explicitly instead of being
477
+ guessed or silently rewritten. Do not add project Pi settings to bypass
478
+ the admitted binding or rely on custom tuning that has not been qualified.
479
+ See [pinned compaction behavior](https://github.com/earendil-works/pi/blob/v0.87.1/packages/coding-agent/docs/compaction.md).
480
+
481
+ Before adoption, stop and reconcile all work, retain the old immutable image ID
482
+ and a stopped private-state backup. Build/qualify the exact candidate image, then
483
+ explicitly select it with `factory install --image LOCAL_IMAGE_REF --state PRIVATE_STATE`
484
+ while idle. The normal install path retains the previously selected image. Plain
485
+ `factory install` builds the standard recipe, now tagged `software-defence-factory-job:0.4.0`;
486
+ updating the CLI alone does neither. Confirm the selected image ID and `pi --version`
487
+ in that image. The protected local launcher requires exactly 0.87.1 and loads only
488
+ its bundled read-only adapter with automatic extension discovery disabled. Missing
489
+ adapter files or an older worker image fail explicitly; old evidence remains
490
+ readable and cloud profiles keep their existing wrapper. Roll back with the retained old image through the same idle install
491
+ path and restore the previously working role definition through revision-guarded
492
+ rollback. Do not change frozen attempts or relabel old failed results.
493
+
494
+ Lead's bounded actual-inference fixture must include enough tool results to trigger
495
+ summarization, resumed useful tool work, the required report, checks and independent
496
+ Review. Record the exact image/model digest, harness/server versions, allocated
497
+ window, output/reasoning request, tool growth, compaction ordering, elapsed time,
498
+ quality outcome and peak memory; unknown measurements remain null. Set an explicit
499
+ job deadline/resource budget and preserve any unfinished checkout and diagnostic
500
+ artifacts. A short successful prompt is insufficient. A larger actually allocated
501
+ window is a later measured comparison only if this evidence warrants it; do not
502
+ switch model or cloud provider automatically. The failed #83 local attempt is not
503
+ local delivery. An operator-selected fresh cloud retry and unfinished-checkpoint
504
+ recovery remain governed by [recovery](recovery.md) and #42.
@@ -21,16 +21,19 @@ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
21
21
  // selection digest. V1 can never attest an adopted role override.
22
22
  // 0.15.0 keeps unchanged v1/v2 policies and writers; local selections require
23
23
  // v3 for every phase. Legacy evidence cannot attest the new registry/launcher.
24
+ // 0.15.1 changes release downloads only; v1/v2/v3 writers and policy are unchanged.
25
+ // 0.15.2 updates the opt-in local launcher and standard image; schemas, frozen
26
+ // command/config/image hashes, protected checks and acceptance guards are unchanged.
24
27
  // Older writers still require exact patch/tree reconstruction before publication.
25
28
  // Deliberately independent of VERSION: a release bump is not
26
29
  // evidence compatibility. Re-audit this list for every trust-relevant writer,
27
30
  // isolation or validation change; remove versions whose guarantees no longer
28
31
  // satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
29
- 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']);
32
+ 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']);
30
33
 
31
34
  export function isSupportedExecutionProfile(profile) {
32
- if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? profile.runtimeVersion === '0.15.0' && Object.hasOwn(profile, 'localBinding')
33
- : ['0.14.0', '0.15.0'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
35
+ if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
36
+ : ['0.14.0', '0.15.0', '0.15.1', '0.15.2'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
34
37
  && profile.role === (Object.keys(ROLE_PHASES).find(role => ROLE_PHASES[role] === profile.phase) || null)
35
38
  && (profile.reasoningEffort === null || ['low', 'medium', 'high'].includes(profile.reasoningEffort))
36
39
  && /^[a-f0-9]{64}$/.test(profile.selectionHash || '');
@@ -132,7 +135,7 @@ export function assertFrozenExecution(config, execution, phase) {
132
135
  delete expected.hostName;
133
136
  // Audited legacy writers retain their real version. Never let v1/v2 attest
134
137
  // a local binding, including a deterministic phase of a hybrid attempt.
135
- if (expected.version < 3 && isSupportedExecutionProfile(execution) && execution.version === expected.version)
138
+ if (isSupportedExecutionProfile(execution) && execution.version === expected.version)
136
139
  expected.runtimeVersion = execution.runtimeVersion;
137
140
  const actual = { ...execution }; delete actual.hostName; delete actual.workerName;
138
141
  if (!isDeepStrictEqual(expected, actual)) throw new Error('Admitted execution profile does not match this attempt');
@@ -110,6 +110,8 @@ async function container(mode, input, command, options = {}) {
110
110
  mkdirSync(localDirectory, { mode: 0o700 });
111
111
  const { reference, ...binding } = config.localBinding;
112
112
  writeFileSync(join(localDirectory, 'launch.mjs'), readFileSync(join(ROOT, 'factory/pi-local-launch.mjs')), { mode: 0o600, flag: 'wx' });
113
+ for (const file of ['pi-context-extension.mjs', 'pi-context-budget.mjs'])
114
+ writeFileSync(join(localDirectory, file), readFileSync(join(ROOT, 'factory', file)), { mode: 0o600, flag: 'wx' });
113
115
  writeFileSync(join(localDirectory, 'models.json'), JSON.stringify(localRegistry(binding)), { mode: 0o600, flag: 'wx' });
114
116
  args.push('--mount', `type=bind,source=${localDirectory},target=${LOCAL_AGENT_DIR},readonly`,
115
117
  '--env', `PI_CODING_AGENT_DIR=${LOCAL_AGENT_DIR}`);
@@ -1,7 +1,7 @@
1
1
  FROM node:22-bookworm-slim@sha256:43ac6c60b8f89723f746e8a92ce91abd5017e627ce1ddfe4238355d3a30b772c
2
2
  RUN apt-get update && apt-get install -y --no-install-recommends git python3 python3-venv ca-certificates procps \
3
3
  && rm -rf /var/lib/apt/lists/* \
4
- && npm install --global @openai/codex@0.156.1 @mariozechner/pi-coding-agent@0.73.1 \
4
+ && npm install --global --engine-strict @openai/codex@0.156.1 @earendil-works/pi-coding-agent@0.87.1 \
5
5
  && npm cache clean --force
6
6
  COPY mock.mjs /opt/factory/mock.mjs
7
7
  USER node
@@ -0,0 +1,75 @@
1
+ // Request projection only. Never change raw tool events, files or session entries.
2
+ // UTF-8 bytes (not characters/4) conservatively cover text tokenization; explicit
3
+ // framing headroom also covers chat templates. This is a budget, not usage.
4
+ export const bytes = value => Buffer.byteLength(JSON.stringify(value), 'utf8');
5
+ export const projectedTokens = payload => bytes(payload) + 1024
6
+ + 64 * ((payload.messages?.length || 0) + (payload.tools?.length || 0));
7
+
8
+ // Pi 0.87.1 clamps against native history BEFORE onPayload. Recover the caller's
9
+ // allowance, not that obsolete clamp. Explicit summary caps remain explicit.
10
+ // Only the selected adapter's OpenAI effort control is supported: numeric or
11
+ // nested thinking budgets could also have been clamped and must not be guessed.
12
+ export function restoreOutputBudget(payload, configured, requested, reasoning) {
13
+ const field = configured.compat.maxTokensField;
14
+ const fields = ['max_tokens', 'max_completion_tokens'].filter(key => Object.hasOwn(payload, key));
15
+ if (!Number.isSafeInteger(requested) || requested < 1 || requested > configured.maxTokens
16
+ || fields.length !== 1 || fields[0] !== field
17
+ || !Number.isSafeInteger(payload[field]) || payload[field] < 1 || payload[field] > requested)
18
+ throw new Error('Unsupported local output budget; refusing to override selected limits');
19
+ if ((reasoning !== undefined && !['off', 'low', 'medium', 'high'].includes(reasoning))
20
+ || ['reasoning', 'thinking', 'thinking_token_budget', 'reasoning_budget', 'chat_template_kwargs', 'chat_template_args', 'enable_thinking']
21
+ .some(key => Object.hasOwn(payload, key)))
22
+ throw new Error('Unsupported local reasoning budget; qualify the endpoint controls before use');
23
+ const effort = configured.reasoning
24
+ ? (!reasoning || reasoning === 'off' ? configured.thinkingLevelMap?.off : reasoning) : undefined;
25
+ if (payload.reasoning_effort !== effort)
26
+ throw new Error('Local reasoning effort does not match the selected request');
27
+ payload[field] = requested;
28
+ }
29
+
30
+ function excerpt(text, limit) {
31
+ if (text.length <= limit) return text;
32
+ // Do not split a UTF-16 surrogate pair. JSON escaping is counted afterward.
33
+ const prefix = text.slice(0, limit).replace(/[\uD800-\uDBFF]$/, '');
34
+ const shortened = `${prefix}\n[Factory context projection: shortened tool result (${Buffer.byteLength(text, 'utf8')} original UTF-8 bytes). Raw tool event is unchanged; source files are unchanged by projection. Do not infer omitted contents. Re-read the original path with read offset/limit (start with limit: 40), or run a narrower query; keep subsequent batches small.]`;
35
+ return Buffer.byteLength(shortened) < Buffer.byteLength(text) ? shortened : text;
36
+ }
37
+
38
+ // Uniform cap shares space among large results without penalizing small ones.
39
+ // Calls, arguments, identities, ordering and all non-tool text are immutable.
40
+ export function fitToolText(value, budget, measure = bytes, native = false) {
41
+ const original = structuredClone(value);
42
+ if (measure(original) <= budget) return original;
43
+ const messages = Array.isArray(original) ? original : original.messages;
44
+ const slots = [];
45
+ for (const message of messages) {
46
+ if (message.role !== (native ? 'toolResult' : 'tool')) continue;
47
+ if (typeof message.content === 'string') slots.push([message, 'content', message.content]);
48
+ else if (Array.isArray(message.content)) {
49
+ for (const part of message.content) if (part.type === 'text') slots.push([part, 'text', part.text]);
50
+ }
51
+ }
52
+ const cap = limit => { for (const [object, key, text] of slots) object[key] = excerpt(text, limit); };
53
+ cap(0);
54
+ 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
+ let low = 0, high = Math.max(0, ...slots.map(([, , text]) => text.length));
56
+ while (low < high) {
57
+ const mid = Math.ceil((low + high) / 2); cap(mid);
58
+ if (measure(original) <= budget) low = mid; else high = mid - 1;
59
+ }
60
+ cap(low);
61
+ return original;
62
+ }
63
+
64
+ export function validatePairs(messages) {
65
+ const pending = new Set(), seen = new Set();
66
+ for (const message of messages) {
67
+ if (message.role !== 'tool' && pending.size) throw new Error('Local Pi context has interrupted tool pairs');
68
+ for (const call of message.tool_calls || []) {
69
+ if (!call.id || seen.has(call.id)) throw new Error('Local Pi context has duplicate tool identity');
70
+ pending.add(call.id); seen.add(call.id);
71
+ }
72
+ if (message.role === 'tool' && !pending.delete(message.tool_call_id)) throw new Error('Local Pi context has orphan tool result');
73
+ }
74
+ if (pending.size) throw new Error('Local Pi context has missing tool results');
75
+ }
@@ -0,0 +1,64 @@
1
+ // Explicitly mounted Factory adapter, never discovered from the host/repository.
2
+ import { readFileSync, writeSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { openAICompletionsApi } from '@earendil-works/pi-ai';
5
+ import { bytes, fitToolText, projectedTokens, restoreOutputBudget, validatePairs } from './pi-context-budget.mjs';
6
+
7
+ export default function factoryLocalContext(pi) {
8
+ const registry = JSON.parse(readFileSync(join(process.env.PI_CODING_AGENT_DIR, 'models.json'), 'utf8'));
9
+ const configured = registry.providers['factory-local'].models[0];
10
+ const window = configured.contextWindow, output = configured.maxTokens;
11
+ let task;
12
+ // Pi catches extension exceptions and can continue. Budget violations must
13
+ // stop before transport, even during summarization, with a durable diagnostic.
14
+ const fatal = error => {
15
+ writeSync(2, `Factory local Pi context protection failed: ${error.message}\n`);
16
+ process.exit(1);
17
+ };
18
+ pi.on('session_before_compact', event => {
19
+ try {
20
+ // These are request-local copies. Upstream still selects cut points and
21
+ // generates the summary. Reserve space for its pinned summary templates;
22
+ // the provider wrapper checks the exact serialized request as well.
23
+ const preparation = event.preparation;
24
+ const budget = window - output - 8192 - bytes(preparation.previousSummary || '');
25
+ for (const key of ['messagesToSummarize', 'turnPrefixMessages'])
26
+ preparation[key] = fitToolText(preparation[key], budget, bytes, true);
27
+ } catch (error) { fatal(error); }
28
+ });
29
+ pi.registerProvider('factory-local', {
30
+ api: 'openai-completions',
31
+ streamSimple(model, context, options) {
32
+ if (model.id !== configured.id || model.provider !== 'factory-local') fatal(new Error('Selected local model changed'));
33
+ // Capture before streamSimple/buildBaseOptions clamps against unprojected
34
+ // history. Generation defaults to the binding; summaries supply their cap.
35
+ const requestedOutput = options?.maxTokens ?? output;
36
+ return openAICompletionsApi().streamSimple(model, context, {
37
+ ...options,
38
+ onPayload: async (payload, requestModel) => {
39
+ try {
40
+ payload = await options?.onPayload?.(payload, requestModel) ?? payload;
41
+ payload = structuredClone(payload);
42
+ if (payload.model !== configured.id
43
+ || !payload.messages?.some(m => m.role === 'system')) throw new Error('Unsupported local request structure or output budget');
44
+ restoreOutputBudget(payload, configured, requestedOutput, options?.reasoning);
45
+ if (payload.tools?.length) {
46
+ task ??= structuredClone(payload.messages.find(m => m.role === 'user'));
47
+ if (!task) throw new Error('Missing required task');
48
+ if (!payload.messages.some(m => m.role === 'user' && JSON.stringify(m) === JSON.stringify(task)))
49
+ payload.messages.splice(1, 0, structuredClone(task));
50
+ validatePairs(payload.messages);
51
+ }
52
+ if (!payload.messages.some(m => m.role === 'user')) throw new Error('Missing required user message');
53
+ // Restore BEFORE measuring so the final output field is counted.
54
+ // No request is sent unless projection fits with the full configured
55
+ // reserve, even when a summary requests a smaller bounded output.
56
+ return fitToolText(payload, window - output, projectedTokens);
57
+ } catch (error) { fatal(error); }
58
+ },
59
+ });
60
+ },
61
+ });
62
+ // Launcher requires this handshake; an extension load failure cannot bypass it.
63
+ writeSync(1, `${JSON.stringify({ type: 'factory_context_ready', version: 1 })}\n`);
64
+ }
@@ -1,48 +1,143 @@
1
- // Mounted read-only beside the single selected registry. Pi 0.73.1 JSON mode
2
- // may exit zero after a provider error; preserve the stream but fail the job.
1
+ // Mounted read-only beside the single selected registry. Keep all JSON evidence;
2
+ // process exit alone does not establish a successfully completed Pi turn.
3
3
  import { readFileSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { spawn } from 'node:child_process';
7
- const directory = dirname(fileURLToPath(import.meta.url));
8
- const registry = JSON.parse(readFileSync(join(directory, 'models.json'), 'utf8'));
9
- const provider = registry.providers?.['factory-local'];
10
- const args = process.argv.slice(2);
11
- const selected = flag => args.filter(arg => arg === flag).length === 1 ? args[args.indexOf(flag) + 1] : null;
12
- const model = provider?.models?.[0];
13
- const thinking = selected('--thinking');
14
- const validThinking = model?.reasoning && model?.thinkingLevelMap?.off !== 'none'
15
- ? ['low', 'medium', 'high'].includes(thinking) : thinking === 'off';
16
- if (Object.keys(registry.providers || {}).length !== 1 || provider?.models?.length !== 1
17
- || selected('--provider') !== 'factory-local' || selected('--model') !== provider.models[0].id
18
- || selected('--mode') !== 'json' || !validThinking)
19
- throw new Error('Local Pi registry and explicit selection do not match; refusing fallback');
20
- // Never inherit credential inputs, provider overrides, NODE_OPTIONS or external
21
- // Pi directories. The runtime image supplies binaries; the repository supplies data.
22
- const env = Object.fromEntries(['PATH', 'HOME', 'LANG', 'TMPDIR', 'FACTORY_PHASE'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
23
- env.PI_CODING_AGENT_DIR = directory;
24
- const child = spawn('pi', args, { env, stdio: ['inherit', 'pipe', 'inherit'] });
25
- let pending = '', failed = false, completed = false;
26
- const event = line => {
27
- if (!line.trim()) return;
28
- try {
29
- const value = JSON.parse(line);
30
- if (value.type === 'message_end' && value.message?.role === 'assistant'
31
- && ['error', 'aborted'].includes(value.message.stopReason)) failed = true;
32
- if (value.type === 'agent_end') completed = true;
33
- } catch { failed = true; }
34
- };
35
- child.stdout.setEncoding('utf8');
36
- child.stdout.on('data', chunk => {
37
- process.stdout.write(chunk);
38
- pending += chunk;
39
- let end;
40
- while ((end = pending.indexOf('\n')) !== -1) { event(pending.slice(0, end)); pending = pending.slice(end + 1); }
41
- if (pending.length > 1024 * 1024) { failed = true; pending = ''; }
42
- });
43
- child.on('error', error => { console.error(`Local Pi launch failed: ${error.message}`); process.exitCode = 1; });
44
- child.on('close', code => {
45
- if (pending) event(pending);
46
- if (failed || !completed) console.error('Local Pi did not complete successfully; inspect the private log. No fallback was selected.');
47
- process.exitCode = code === 0 && !failed && completed ? 0 : (Number.isInteger(code) && code > 0 ? code : 1);
48
- });
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+
8
+ // A long run can emit a multi-megabyte agent_end aggregate even after compaction.
9
+ // Bound parsing separately from the much smaller retained diagnostic log.
10
+ const MAX_EVENT_CHARACTERS = 8 * 1024 * 1024;
11
+
12
+ // Pi 0.73.1 and 0.87.1 print JSONL. Errors can precede explicit recovery, but
13
+ // aborted/failed compaction, invalid framing and incomplete recovery stay fatal.
14
+ export class PiCompletion {
15
+ pending = '';
16
+ failed = false;
17
+ error = false;
18
+ retry = false;
19
+ compacting = false;
20
+ compactionReason = null;
21
+ recovery = false;
22
+ completed = false;
23
+ modern = false;
24
+ settled = false;
25
+ lastAssistant = null;
26
+ contextReady = false;
27
+
28
+ write(chunk) {
29
+ this.pending += chunk;
30
+ let end;
31
+ while ((end = this.pending.indexOf('\n')) !== -1) {
32
+ const line = this.pending.slice(0, end);
33
+ this.pending = this.pending.slice(end + 1);
34
+ if (line.length > MAX_EVENT_CHARACTERS) this.failed = true;
35
+ else if (line.trim()) {
36
+ try { this.event(JSON.parse(line)); } catch { this.failed = true; }
37
+ }
38
+ }
39
+ if (this.pending.length > MAX_EVENT_CHARACTERS) { this.failed = true; this.pending = ''; }
40
+ }
41
+
42
+ event(value) {
43
+ if (!value || typeof value.type !== 'string') { this.failed = true; return; }
44
+ if (value.type === 'factory_context_ready' && value.version === 1) this.contextReady = true;
45
+ if (value.type === 'agent_start' || value.type === 'message_start') {
46
+ this.completed = false; this.settled = false;
47
+ }
48
+ if (value.type === 'message_end' && (!value.message || typeof value.message.role !== 'string')) this.failed = true;
49
+ if (value.type === 'message_end' && value.message?.role === 'assistant') {
50
+ this.completed = false;
51
+ this.settled = false;
52
+ this.lastAssistant = value.message;
53
+ if (!['stop', 'toolUse', 'length', 'error', 'aborted'].includes(value.message.stopReason) || !Array.isArray(value.message.content)) this.failed = true;
54
+ if (value.message.stopReason === 'aborted') this.failed = true;
55
+ else if (['error', 'length'].includes(value.message.stopReason)) { this.error = true; this.recovery = false; }
56
+ else if (['stop', 'toolUse'].includes(value.message.stopReason)) {
57
+ if (this.error && (this.retry || this.recovery)) this.error = false;
58
+ this.recovery = false;
59
+ }
60
+ }
61
+ if (value.type === 'auto_retry_start') { this.retry = true; this.completed = false; this.settled = false; }
62
+ if (value.type === 'auto_retry_end') {
63
+ if (!this.retry || value.success !== true) this.failed = true;
64
+ this.retry = false;
65
+ }
66
+ // Both audited versions use compaction_*; retain the older public alias
67
+ // defensively so failed compaction never disappears as an ignored event.
68
+ if (['compaction_start', 'auto_compaction_start'].includes(value.type)) {
69
+ if (this.compacting || !['manual', 'threshold', 'overflow'].includes(value.reason)) this.failed = true;
70
+ this.compactionReason = value.reason;
71
+ this.compacting = true; this.settled = false;
72
+ }
73
+ if (['compaction_end', 'auto_compaction_end'].includes(value.type)) {
74
+ if (!this.compacting || value.reason !== this.compactionReason || typeof value.willRetry !== 'boolean'
75
+ || value.aborted !== false || value.errorMessage
76
+ || !value.result || typeof value.result.summary !== 'string' || !value.result.summary.trim()) this.failed = true;
77
+ this.compacting = false;
78
+ if (value.reason === 'overflow' && value.willRetry === true && !this.failed) this.recovery = true;
79
+ }
80
+ if (value.type === 'agent_end') {
81
+ this.settled = false;
82
+ if (Object.hasOwn(value, 'willRetry')) {
83
+ this.modern = true;
84
+ if (typeof value.willRetry !== 'boolean') this.failed = true;
85
+ }
86
+ const last = value.messages?.at(-1);
87
+ this.completed = value.willRetry !== true && last?.role === 'assistant' && last.stopReason === 'stop'
88
+ && this.lastAssistant?.stopReason === 'stop'
89
+ && JSON.stringify(last) === JSON.stringify(this.lastAssistant)
90
+ && Array.isArray(last.content) && last.content.some(part => part.type === 'text' && part.text?.trim())
91
+ && !last.content.some(part => part.type === 'toolCall');
92
+ }
93
+ if (value.type === 'agent_settled') this.settled = true;
94
+ }
95
+
96
+ finish() {
97
+ return !this.pending && !this.failed && !this.error && !this.retry && !this.compacting && !this.recovery
98
+ && this.completed && (!this.modern || this.settled);
99
+ }
100
+ }
101
+
102
+ function launch() {
103
+ const directory = dirname(fileURLToPath(import.meta.url));
104
+ const registry = JSON.parse(readFileSync(join(directory, 'models.json'), 'utf8'));
105
+ const provider = registry.providers?.['factory-local'];
106
+ const args = process.argv.slice(2);
107
+ const selected = flag => args.filter(arg => arg === flag).length === 1 ? args[args.indexOf(flag) + 1] : null;
108
+ const model = provider?.models?.[0];
109
+ const thinking = selected('--thinking');
110
+ const validThinking = model?.reasoning && model?.thinkingLevelMap?.off !== 'none'
111
+ ? ['low', 'medium', 'high'].includes(thinking) : thinking === 'off';
112
+ if (Object.keys(registry.providers || {}).length !== 1 || provider?.models?.length !== 1
113
+ || selected('--provider') !== 'factory-local' || selected('--model') !== provider.models[0].id
114
+ || selected('--mode') !== 'json' || !validThinking)
115
+ throw new Error('Local Pi registry and explicit selection do not match; refusing fallback');
116
+ // Never inherit credential inputs, provider overrides, NODE_OPTIONS or external
117
+ // Pi directories. The runtime image supplies binaries; the repository supplies data.
118
+ const env = Object.fromEntries(['PATH', 'HOME', 'LANG', 'TMPDIR', 'FACTORY_PHASE'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
119
+ env.PI_CODING_AGENT_DIR = directory;
120
+ for (const file of ['pi-context-extension.mjs', 'pi-context-budget.mjs']) readFileSync(join(directory, file));
121
+ const version = spawnSync('pi', ['--version'], { env, encoding: 'utf8', timeout: 10000 });
122
+ if (version.status !== 0 || version.stdout.trim() !== '0.87.1')
123
+ throw new Error('Local context protection requires pinned Pi 0.87.1; qualify and select the worker image while idle. No fallback.');
124
+ // The explicit non-secret runtime key bypasses Pi 0.87.1's credential-store
125
+ // refresh, which otherwise tries to lock auth.json in this read-only mount.
126
+ // --no-extensions still disables discovery. Only this read-only bundled path
127
+ // is explicitly enabled; it cannot load repository or host extensions.
128
+ const child = spawn('pi', [...args, '--extension', join(directory, 'pi-context-extension.mjs'), '--api-key', 'factory-local-keyless'], { env, stdio: ['inherit', 'pipe', 'inherit'] });
129
+ const completion = new PiCompletion();
130
+ child.stdout.setEncoding('utf8');
131
+ child.stdout.on('data', chunk => {
132
+ process.stdout.write(chunk);
133
+ completion.write(chunk);
134
+ });
135
+ child.on('error', error => { console.error(`Local Pi launch failed: ${error.message}`); process.exitCode = 1; });
136
+ child.on('close', code => {
137
+ const completed = completion.contextReady && completion.finish();
138
+ if (!completed) console.error('Local Pi did not complete successfully; inspect the private log. No fallback was selected.');
139
+ process.exitCode = code === 0 && completed ? 0 : (Number.isInteger(code) && code > 0 ? code : 1);
140
+ });
141
+ }
142
+
143
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) launch();
package/factory/pins.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "jobImage": "software-defence-factory-job:0.3.0"
2
+ "jobImage": "software-defence-factory-job:0.4.0"
3
3
  }
@@ -65,17 +65,33 @@ function installedEntry(dataHome, version) {
65
65
  return pkg.name === PACKAGE && pkg.version === version && existsSync(entry) ? entry : null;
66
66
  } catch { return null; }
67
67
  }
68
- export function installRelease(version, dataHome = DATA_HOME, runner = spawnSync) {
68
+ export function installRelease(version, dataHome = DATA_HOME, runner = spawnSync, {
69
+ now = () => performance.now(),
70
+ sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
71
+ } = {}) {
69
72
  if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error('Invalid release version');
70
73
  const existing = installedEntry(dataHome, version);
71
74
  if (existing) return existing;
72
75
  const releases = join(dataHome, 'releases'); privateDir(releases);
73
76
  const staging = mkdtempSync(join(releases, '.download-'));
74
77
  try {
75
- const result = runner('npm', ['install', '--prefix', staging, '--no-save', '--package-lock=false',
76
- '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', '--registry', REGISTRY, `${PACKAGE}@${version}`],
77
- { encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 });
78
- if (result.error || result.status !== 0) throw new Error(`npm update download failed: ${result.error?.message || result.stderr || result.status}`);
78
+ // Metadata refresh and visibility retries share the original download budget.
79
+ // Only ETARGET is retried; offline/auth/identity failures keep their own errors.
80
+ const deadline = now() + 120000;
81
+ for (let attempt = 1; attempt <= 3; attempt++) {
82
+ const remaining = Math.floor(deadline - now());
83
+ if (remaining <= 0) throw new Error(`npm update download failed for ${PACKAGE}@${version}: 120-second download budget exhausted`);
84
+ const result = runner('npm', ['install', '--prefix', staging, '--no-save', '--package-lock=false',
85
+ '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', '--prefer-online', '--prefer-offline=false', '--registry', REGISTRY, `${PACKAGE}@${version}`],
86
+ { encoding: 'utf8', timeout: remaining, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 });
87
+ if (!result.error && result.status === 0) break;
88
+ const detail = result.error?.message || result.stderr || result.status;
89
+ const visibilityDelay = !result.error && /^npm (?:ERR!|error) code ETARGET\s*$/m.test(result.stderr || '');
90
+ if (!visibilityDelay) throw new Error(`npm update download failed for ${PACKAGE}@${version}: ${detail}`);
91
+ if (attempt === 3 || deadline - now() <= 1000)
92
+ throw new Error(`npm release ${PACKAGE}@${version} is not yet downloadable; publication may still be processing. Retry the update later. ${detail}`);
93
+ sleep(1000);
94
+ }
79
95
  const pkg = read(join(staging, 'node_modules', PACKAGE, 'package.json'));
80
96
  if (pkg.name !== PACKAGE || pkg.version !== version || !existsSync(join(staging, 'node_modules', PACKAGE, 'bin/software-defence-factory.mjs')))
81
97
  throw new Error('Downloaded package identity does not match the requested release');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "software-defence-factory",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Factory: scoped software delivery and defence investigations with isolated jobs, evidence and review",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,7 +57,8 @@
57
57
  "test": "node --test tests/*.test.mjs",
58
58
  "check": "node scripts/check.mjs && node --test tests/*.test.mjs && npm run test:dashboard",
59
59
  "build:dashboard": "npm --prefix dashboard ci --ignore-scripts && npm --prefix dashboard run build",
60
- "test:dashboard": "npm --prefix dashboard test"
60
+ "test:dashboard": "npm --prefix dashboard test",
61
+ "qualify:pi": "FACTORY_REQUIRE_PI=1 node --test tests/pi-local-adapter.test.mjs tests/pi-context.test.mjs tests/pi-context-batch.test.mjs"
61
62
  },
62
63
  "dependencies": {
63
64
  "yaml": "2.9.1"