software-defence-factory 0.15.1 → 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.
- package/docs/definition.md +14 -3
- package/docs/npm.md +18 -4
- package/docs/setup.md +72 -0
- package/factory/execution-profile.mjs +5 -3
- package/factory/executor.mjs +2 -0
- package/factory/image/Dockerfile +1 -1
- package/factory/pi-context-budget.mjs +75 -0
- package/factory/pi-context-extension.mjs +64 -0
- package/factory/pi-local-launch.mjs +141 -46
- package/factory/pins.json +1 -1
- package/package.json +3 -2
package/docs/definition.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
@@ -116,8 +116,8 @@ installation or retained attempt needs them.
|
|
|
116
116
|
|
|
117
117
|
## Protected evidence compatibility
|
|
118
118
|
|
|
119
|
-
Factory 0.15.
|
|
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 and 0.15.
|
|
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
|
|
121
121
|
`factory/execution-profile.mjs`, independent of the installed package version;
|
|
122
122
|
it is not a semver range or an automatic promise for later releases. Unknown
|
|
123
123
|
runtime strings, unknown profile formats and incomplete legacy acceptance
|
|
@@ -184,7 +184,7 @@ Verify evidence and cannot authorize acceptance or publication.
|
|
|
184
184
|
|
|
185
185
|
Version 0.14.0 preserves the v1 writer only for unchanged inherited installations:
|
|
186
186
|
policy bytes, candidate/check/review/acceptance bindings, mounts and credential
|
|
187
|
-
rules stay compatible. Adopted role overrides use **v2 from 0.14.0, 0.15.0 and 0.15.
|
|
187
|
+
rules stay compatible. Adopted role overrides use **v2 from 0.14.0, 0.15.0, 0.15.1 and 0.15.2**, recording
|
|
188
188
|
role/provider/effort and an exact selection digest. The executor verifies the
|
|
189
189
|
frozen common configuration before phase selection; continuation and both delivery
|
|
190
190
|
validation paths require matching protected v2 evidence and private frozen config.
|
|
@@ -254,7 +254,7 @@ The 0.15.0 compatibility audit preserves unchanged v1/v2 policy construction,
|
|
|
254
254
|
protected checks, candidate reconstruction, read-only review and acceptance guards.
|
|
255
255
|
Historical 0.14.0 v2 validation compares its actual writer version rather than
|
|
256
256
|
relabeling it as the installed version. Local registry selection is a new execution
|
|
257
|
-
contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0 or 0.15.
|
|
257
|
+
contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0, 0.15.1 or 0.15.2** and its
|
|
258
258
|
exact protected frozen configuration. v1/v2 cannot attest local binding fields,
|
|
259
259
|
providers or a common policy containing a selected local role. A selected endpoint,
|
|
260
260
|
model, limit or compatibility change invalidates the current policy; explicit
|
|
@@ -268,3 +268,17 @@ writers, frozen configuration and policy bytes. The explicit allowlists include
|
|
|
268
268
|
both patch versions; supported v3 readback retains the original writer version
|
|
269
269
|
while still comparing every execution selection, phase and policy field. It does
|
|
270
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.
|
|
@@ -22,16 +22,18 @@ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
|
|
|
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
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.
|
|
25
27
|
// Older writers still require exact patch/tree reconstruction before publication.
|
|
26
28
|
// Deliberately independent of VERSION: a release bump is not
|
|
27
29
|
// evidence compatibility. Re-audit this list for every trust-relevant writer,
|
|
28
30
|
// isolation or validation change; remove versions whose guarantees no longer
|
|
29
31
|
// satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
|
|
30
|
-
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']);
|
|
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']);
|
|
31
33
|
|
|
32
34
|
export function isSupportedExecutionProfile(profile) {
|
|
33
|
-
if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
|
|
34
|
-
: ['0.14.0', '0.15.0', '0.15.1'].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')
|
|
35
37
|
&& profile.role === (Object.keys(ROLE_PHASES).find(role => ROLE_PHASES[role] === profile.phase) || null)
|
|
36
38
|
&& (profile.reasoningEffort === null || ['low', 'medium', 'high'].includes(profile.reasoningEffort))
|
|
37
39
|
&& /^[a-f0-9]{64}$/.test(profile.selectionHash || '');
|
package/factory/executor.mjs
CHANGED
|
@@ -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}`);
|
package/factory/image/Dockerfile
CHANGED
|
@@ -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 @
|
|
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.
|
|
2
|
-
//
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "software-defence-factory",
|
|
3
|
-
"version": "0.15.
|
|
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"
|