@principles/host-runtime 0.10.4 → 0.12.0

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.
@@ -0,0 +1,449 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PROMPT_BUDGET_POLICY_VERSION, RUNTIME_V2_PRINCIPLE_BUDGET, isValidContextWindowTokens, promptBudgetCharsForContextWindow, } from '@principles/core/runtime-v2';
4
+ import { isPromptInjectionTargetHost, } from './prompt-injection-projection.js';
5
+ /**
6
+ * PRI-1017: runtime budget OBSERVATION read model over the EXISTING
7
+ * injection-event JSONL (`<workspaceDir>/{.pd,.state}/logs/events_<date>.jsonl`,
8
+ * written by the OpenClaw EventLog and the Codex/WorkBuddy shared path via
9
+ * appendEventLogLine).
10
+ *
11
+ * Why this exists: SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1 forbids the
12
+ * actual injection from running a dynamic budget while the Console forecasts
13
+ * under a different fixed 2000-char budget. The console holds no live host
14
+ * facts, but the RUNTIME persists the resolution it actually used —
15
+ * `runtime_v2_prompt_activations_injected` carries budgetChars, capacity
16
+ * provenance and hostKind. This module reads those facts back so console
17
+ * surfaces forecast under the same budget the last real injection used.
18
+ *
19
+ * Honesty rules this model enforces (SPEC §6.1/§7):
20
+ * - An observation is a FACT ABOUT THE PAST, never a live claim: callers must
21
+ * surface `observedAt`; the console labels it "most recent runtime
22
+ * observation", not a current confirmation.
23
+ * - No logs / no budget facts ⇒ absent, never a fabricated window; the caller
24
+ * keeps the honest fixed-fallback policy.
25
+ * - A latest observation older than RUNTIME_BUDGET_OBSERVATION_MAX_AGE_MS is
26
+ * `stale` — the caller must NOT present it as the current budget.
27
+ * - Observations bind to ONE host: OpenClaw's window is never used for a
28
+ * Codex or WorkBuddy forecast.
29
+ * - Untrusted input discipline: every line is parsed as `unknown` and
30
+ * narrowed with typeof guards + closed-set checks; structurally invalid
31
+ * lines are counted in `parseFailures` and skipped loudly, never guessed.
32
+ * - Events predating the model-aware policy carry no provenance fields; they
33
+ * are counted as `legacyEventsSkipped` and produce NO observation — a
34
+ * missing provenance field is "policy unknown", never a capacity claim.
35
+ * - Bounded I/O: only the newest bytes of the most recent daily files are read
36
+ * (tail window), the file cap applying PER candidate log directory, so a
37
+ * runaway or crafted log cannot make a Console request read an unbounded
38
+ * amount of data. Only bounded budget metadata is retained and returned;
39
+ * other event payloads are not cached.
40
+ */
41
+ /**
42
+ * Candidate directories for the runtime-v2 event log, in priority order.
43
+ * Shared with the RuleCode telemetry collector (ERR-031: both readers derive
44
+ * from one list — the v1 EventLog writer emits `.state/logs`, no production
45
+ * code ever created `.pd/logs`).
46
+ */
47
+ export const RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS = ['.pd/logs', '.state/logs'];
48
+ /**
49
+ * Bounded I/O: only the most recent daily files are scanned — PER candidate
50
+ * log directory (`RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS`), not globally. The
51
+ * cap is deliberately per directory: both directories describe the same
52
+ * workspace's log convention and either can hold the newest event for a given
53
+ * host (the reader takes latest-wins ACROSS them), so a global cap would let
54
+ * one directory's files starve the other and drop a live host observation.
55
+ * Worst case per read is therefore `RUNTIME_BUDGET_OBSERVATION_MAX_FILES ×
56
+ * RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS.length` files (14 today) and the same
57
+ * count × `RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES` bytes (56 MiB today).
58
+ */
59
+ export const RUNTIME_BUDGET_OBSERVATION_MAX_FILES = 7;
60
+ /**
61
+ * Bounded I/O: per file, only the newest bytes are read. The writer appends,
62
+ * so the latest observation per host lives at the TAIL; a file larger than
63
+ * this window is tail-read and its older prefix skipped (counted in
64
+ * `truncatedFiles`) instead of materialising an unbounded log in the console
65
+ * process. 4 MiB is ~3.4x the largest real day observed on 2026-10-21
66
+ * (WorkBuddy 1.17 MiB, Codex 0.87 MiB, OpenClaw 0.98 MiB across 7 files).
67
+ */
68
+ export const RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES = 4 * 1024 * 1024;
69
+ /**
70
+ * Maximum age of an observation that may be presented as a CURRENT runtime
71
+ * fact. The runtime refreshes the observation on every prompt build, so an
72
+ * active workspace holds minute-old facts; beyond a day the model may have
73
+ * been switched and the window is no longer evidence about the present.
74
+ */
75
+ export const RUNTIME_BUDGET_OBSERVATION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
76
+ /**
77
+ * Clock-skew allowance for event timestamps. A writer clock ahead of ours is
78
+ * normal; an observation further in the future is not a fact about the
79
+ * present and must never be selected as the current budget.
80
+ */
81
+ export const RUNTIME_BUDGET_OBSERVATION_MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
82
+ /** Upper bound on the envelope `ts` string; anything longer is not a timestamp. */
83
+ const OBSERVED_AT_MAX_LENGTH = 64;
84
+ /** Console-side bounded reason: a runtime observation exists but is too old. */
85
+ export const RUNTIME_BUDGET_OBSERVATION_STALE_REASON = 'runtime_budget_observation_stale';
86
+ function isRecord(value) {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
88
+ }
89
+ function nonEmptyString(value) {
90
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
91
+ }
92
+ /** Windows paths are case-insensitive; compare them the way the OS does. */
93
+ function sameWorkspacePath(left, right) {
94
+ const resolvedLeft = path.resolve(left);
95
+ const resolvedRight = path.resolve(right);
96
+ return process.platform === 'win32'
97
+ ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
98
+ : resolvedLeft === resolvedRight;
99
+ }
100
+ function parseObservedAt(value) {
101
+ if (value.length > OBSERVED_AT_MAX_LENGTH)
102
+ return null;
103
+ const parsed = Date.parse(value);
104
+ return Number.isFinite(parsed) ? parsed : null;
105
+ }
106
+ const BUDGET_CAPACITY_SOURCES = new Set([
107
+ 'host_effective_window',
108
+ 'pi_catalog_exact',
109
+ 'legacy_fallback',
110
+ ]);
111
+ const BUDGET_CAPACITY_STATUSES = new Set(['confirmed', 'unknown']);
112
+ const BUDGET_FALLBACK_REASONS = new Set([
113
+ 'host_capacity_absent',
114
+ 'host_capacity_source_untrusted',
115
+ 'host_capacity_invalid_value',
116
+ 'catalog_unavailable',
117
+ 'model_identity_absent',
118
+ 'catalog_no_exact_match',
119
+ 'catalog_no_match_for_model_id',
120
+ 'catalog_ambiguous_model_id',
121
+ 'catalog_provider_absent',
122
+ 'catalog_metadata_invalid',
123
+ ]);
124
+ function isBudgetFallbackReason(value) {
125
+ return typeof value === 'string' && BUDGET_FALLBACK_REASONS.has(value);
126
+ }
127
+ /**
128
+ * Narrow one untrusted event line into an observation, or null when it does
129
+ * not carry a trustworthy model-aware budget fact. rc-1/rc-2: every field is
130
+ * guarded; a structurally valid event missing provenance is NOT an
131
+ * observation (see `legacyEventsSkipped` at the report level).
132
+ */
133
+ function narrowObservation(parsed, workspaceDir, nowMs) {
134
+ if (!isRecord(parsed))
135
+ return null;
136
+ if (nonEmptyString(parsed.type) !== 'runtime_v2_prompt_activations_injected')
137
+ return null;
138
+ const observedAt = nonEmptyString(parsed.ts);
139
+ const { data } = parsed;
140
+ if (observedAt === undefined || !isRecord(data))
141
+ return null;
142
+ const observedAtMs = parseObservedAt(observedAt);
143
+ if (observedAtMs === null)
144
+ return null;
145
+ // A timestamp beyond minor clock skew is not a fact about the present; it
146
+ // must not win the "latest observation" race or back a current forecast.
147
+ if (observedAtMs - nowMs > RUNTIME_BUDGET_OBSERVATION_MAX_FUTURE_SKEW_MS)
148
+ return null;
149
+ // Workspace binding: the console model is workspace-scoped, so an event
150
+ // recorded under a different workspaceDir is not this workspace's fact.
151
+ const eventWorkspaceDir = nonEmptyString(data.workspaceDir);
152
+ if (eventWorkspaceDir === undefined || !sameWorkspacePath(eventWorkspaceDir, workspaceDir))
153
+ return null;
154
+ const rawHostKind = data.hostKind;
155
+ if (!isPromptInjectionTargetHost(rawHostKind))
156
+ return null;
157
+ const { budget } = data;
158
+ if (typeof budget !== 'number' || !Number.isInteger(budget) || budget <= 0)
159
+ return null;
160
+ // A budget computed under another policy version is not evidence about the
161
+ // CURRENT formula. Absent provenance is pre-policy, not "current by
162
+ // default": both are skipped, never reinterpreted.
163
+ if (data.budgetPolicyVersion !== PROMPT_BUDGET_POLICY_VERSION)
164
+ return null;
165
+ const rawSource = data.budgetCapacitySource;
166
+ const rawStatus = data.budgetCapacityStatus;
167
+ if (typeof rawSource !== 'string' || !BUDGET_CAPACITY_SOURCES.has(rawSource))
168
+ return null;
169
+ if (typeof rawStatus !== 'string' || !BUDGET_CAPACITY_STATUSES.has(rawStatus))
170
+ return null;
171
+ const rawWindow = data.budgetContextWindowTokens;
172
+ const rawReason = data.budgetFallbackReason;
173
+ if (rawReason !== undefined && !isBudgetFallbackReason(rawReason))
174
+ return null;
175
+ // Source/status/window/budget must describe ONE coherent resolution: a
176
+ // confirmed source needs a real window and exactly the budget the shared
177
+ // formula derives from it; the legacy fallback needs the legacy budget, no
178
+ // window, and a bounded reason. Anything else is not a trustworthy fact.
179
+ if (rawStatus === 'confirmed') {
180
+ if (rawSource !== 'host_effective_window' && rawSource !== 'pi_catalog_exact')
181
+ return null;
182
+ if (!isValidContextWindowTokens(rawWindow))
183
+ return null;
184
+ if (budget !== promptBudgetCharsForContextWindow(rawWindow))
185
+ return null;
186
+ if (rawReason !== undefined)
187
+ return null;
188
+ }
189
+ else {
190
+ if (rawSource !== 'legacy_fallback')
191
+ return null;
192
+ if (rawWindow !== undefined)
193
+ return null;
194
+ if (!isBudgetFallbackReason(rawReason))
195
+ return null;
196
+ if (budget !== RUNTIME_V2_PRINCIPLE_BUDGET)
197
+ return null;
198
+ }
199
+ return {
200
+ workspaceDir,
201
+ hostKind: rawHostKind,
202
+ budgetChars: budget,
203
+ capacitySource: rawSource,
204
+ capacityStatus: rawStatus,
205
+ contextWindowTokens: rawWindow ?? null,
206
+ fallbackReason: rawReason ?? null,
207
+ policyVersion: PROMPT_BUDGET_POLICY_VERSION,
208
+ observedAt,
209
+ };
210
+ }
211
+ /**
212
+ * Read at most the newest `RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES` of a
213
+ * log file. When the prefix is skipped, the partial first line after the cut
214
+ * is dropped so a half-written JSON line can never be parsed. Returns null
215
+ * when the file cannot be opened/read (the caller skips it loudly elsewhere).
216
+ */
217
+ function readBoundedLogText(filePath) {
218
+ let fd;
219
+ try {
220
+ fd = fs.openSync(filePath, 'r');
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ try {
226
+ const { size } = fs.fstatSync(fd);
227
+ const start = Math.max(0, size - RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES);
228
+ const length = size - start;
229
+ if (length <= 0)
230
+ return { text: '', truncated: false };
231
+ const buffer = Buffer.allocUnsafe(length);
232
+ let offset = 0;
233
+ while (offset < length) {
234
+ const read = fs.readSync(fd, buffer, offset, length - offset, start + offset);
235
+ if (read <= 0)
236
+ break;
237
+ offset += read;
238
+ }
239
+ let text = buffer.subarray(0, offset).toString('utf8');
240
+ if (start > 0) {
241
+ const newline = text.indexOf('\n');
242
+ text = newline === -1 ? '' : text.slice(newline + 1);
243
+ }
244
+ return { text, truncated: start > 0 };
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ finally {
250
+ try {
251
+ fs.closeSync(fd);
252
+ }
253
+ catch { /* fd already closed */ }
254
+ }
255
+ }
256
+ /**
257
+ * Read the most recent budget observation per host from the workspace's
258
+ * runtime-v2 event logs. Never throws: an unreadable log directory degrades
259
+ * to an empty report with an explicit note (absence is UNKNOWN, not zero).
260
+ */
261
+ export function readRuntimePromptBudgetObservations(input) {
262
+ const { workspaceDir } = input;
263
+ const nowMs = (input.now ?? new Date()).getTime();
264
+ const filesRead = [];
265
+ const lines = { count: 0 };
266
+ const parseFailures = { count: 0 };
267
+ const legacyEventsSkipped = { count: 0 };
268
+ const foreignPolicyEventsSkipped = { count: 0 };
269
+ const truncatedFiles = { count: 0 };
270
+ const latest = new Map();
271
+ for (const candidate of RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS) {
272
+ const logsRoot = path.resolve(workspaceDir, ...candidate.split('/'));
273
+ if (!fs.existsSync(logsRoot))
274
+ continue;
275
+ let canonicalRoot;
276
+ try {
277
+ canonicalRoot = fs.realpathSync(logsRoot);
278
+ }
279
+ catch {
280
+ continue;
281
+ }
282
+ let names;
283
+ try {
284
+ names = fs.readdirSync(logsRoot)
285
+ .filter((name) => name.startsWith('events_') && name.endsWith('.jsonl'))
286
+ .sort()
287
+ .slice(-RUNTIME_BUDGET_OBSERVATION_MAX_FILES);
288
+ }
289
+ catch {
290
+ // Unreadable directory contributes nothing; the note stays explicit.
291
+ continue;
292
+ }
293
+ for (const name of names) {
294
+ // Containment guard: only bare event filenames inside the resolved
295
+ // logs root are ever read — a crafted name cannot escape the directory.
296
+ // Canonicalize BOTH sides so an in-logs symlink cannot redirect the
297
+ // read outside the workspace log directory (EP-08).
298
+ const filePath = path.resolve(logsRoot, name);
299
+ if (!filePath.startsWith(logsRoot + path.sep) || path.basename(name) !== name)
300
+ continue;
301
+ let canonicalFile;
302
+ try {
303
+ canonicalFile = fs.realpathSync(filePath);
304
+ }
305
+ catch {
306
+ continue;
307
+ }
308
+ if (!canonicalFile.startsWith(canonicalRoot + path.sep))
309
+ continue;
310
+ filesRead.push(canonicalFile);
311
+ const bounded = readBoundedLogText(canonicalFile);
312
+ if (bounded === null)
313
+ continue;
314
+ if (bounded.truncated)
315
+ truncatedFiles.count += 1;
316
+ for (const line of bounded.text.split('\n')) {
317
+ if (line.trim().length === 0)
318
+ continue;
319
+ lines.count += 1;
320
+ let parsed;
321
+ try {
322
+ parsed = JSON.parse(line);
323
+ }
324
+ catch {
325
+ parseFailures.count += 1;
326
+ continue;
327
+ }
328
+ if (isRecord(parsed) && parsed.type === 'runtime_v2_prompt_activations_injected' && isRecord(parsed.data)) {
329
+ const { data } = parsed;
330
+ const rawPolicyVersion = data.budgetPolicyVersion;
331
+ if (rawPolicyVersion !== undefined && rawPolicyVersion !== PROMPT_BUDGET_POLICY_VERSION) {
332
+ // A budget computed under a DIFFERENT policy version is not
333
+ // evidence about the current formula — counted, not reinterpreted.
334
+ foreignPolicyEventsSkipped.count += 1;
335
+ continue;
336
+ }
337
+ const observation = narrowObservation(parsed, workspaceDir, nowMs);
338
+ if (observation === null) {
339
+ // A pre-policy event, a future/invalid timestamp, incoherent
340
+ // provenance or an unusable binding — counted, never guessed.
341
+ legacyEventsSkipped.count += 1;
342
+ continue;
343
+ }
344
+ const previous = latest.get(observation.hostKind);
345
+ // Compare PARSED instants, not strings: a longer/invalid timestamp
346
+ // must not shadow a real observation lexicographically.
347
+ if (previous === undefined || Date.parse(observation.observedAt) > Date.parse(previous.observedAt)) {
348
+ latest.set(observation.hostKind, observation);
349
+ }
350
+ continue;
351
+ }
352
+ if (isRecord(parsed) && parsed.type === 'runtime_v2_prompt_activations_injected' && !isRecord(parsed.data)) {
353
+ parseFailures.count += 1;
354
+ }
355
+ }
356
+ }
357
+ }
358
+ return {
359
+ observations: [...latest.values()],
360
+ filesRead,
361
+ lines: lines.count,
362
+ parseFailures: parseFailures.count,
363
+ legacyEventsSkipped: legacyEventsSkipped.count,
364
+ foreignPolicyEventsSkipped: foreignPolicyEventsSkipped.count,
365
+ truncatedFiles: truncatedFiles.count,
366
+ note: 'observations are PAST runtime facts bound to one host; absence means UNKNOWN (never a fabricated window); events without model-aware budget provenance, or computed under a different policy version, are skipped, not reinterpreted; the bounded read takes the newest daily files per candidate log directory and files larger than the tail window are counted in truncatedFiles (older prefix skipped, newest event per host still inside the window)',
367
+ };
368
+ }
369
+ /** Latest-wins ordering that treats unparseable timestamps as older, not newer. */
370
+ function compareObservedAt(left, right) {
371
+ const leftMs = Date.parse(left);
372
+ const rightMs = Date.parse(right);
373
+ if (!Number.isFinite(leftMs))
374
+ return Number.isFinite(rightMs) ? -1 : 0;
375
+ if (!Number.isFinite(rightMs))
376
+ return 1;
377
+ return leftMs - rightMs;
378
+ }
379
+ /**
380
+ * Select the observation that may back a console forecast for `targetHost`.
381
+ * `absent` when the host has no observation at all; `stale` when the latest
382
+ * one is older than the freshness bound (the caller must fall back honestly).
383
+ */
384
+ export function selectRuntimeBudgetObservation(input) {
385
+ const now = input.now ?? new Date();
386
+ const candidates = input.targetHost === undefined
387
+ ? [...input.observations]
388
+ : input.observations.filter((observation) => observation.hostKind === input.targetHost);
389
+ let latest;
390
+ for (const candidate of candidates) {
391
+ if (latest === undefined || compareObservedAt(candidate.observedAt, latest.observedAt) > 0)
392
+ latest = candidate;
393
+ }
394
+ if (latest === undefined)
395
+ return { kind: 'absent' };
396
+ const observedAt = Date.parse(latest.observedAt);
397
+ if (!Number.isFinite(observedAt)
398
+ || observedAt - now.getTime() > RUNTIME_BUDGET_OBSERVATION_MAX_FUTURE_SKEW_MS
399
+ || now.getTime() - observedAt > RUNTIME_BUDGET_OBSERVATION_MAX_AGE_MS) {
400
+ return { kind: 'stale', observation: latest };
401
+ }
402
+ return { kind: 'usable', observation: latest };
403
+ }
404
+ export function selectConsoleRuntimeBudgetFacts(input) {
405
+ const report = readRuntimePromptBudgetObservations({ workspaceDir: input.workspaceDir, now: input.now });
406
+ const perHost = new Map();
407
+ const stalePerHost = new Map();
408
+ for (const observation of report.observations) {
409
+ const selection = selectRuntimeBudgetObservation({
410
+ observations: report.observations,
411
+ targetHost: observation.hostKind,
412
+ now: input.now,
413
+ });
414
+ if (selection.kind === 'usable')
415
+ perHost.set(observation.hostKind, selection.observation);
416
+ else if (selection.kind === 'stale')
417
+ stalePerHost.set(observation.hostKind, selection.observation);
418
+ }
419
+ if (input.targetHost === undefined) {
420
+ return { perHost, stalePerHost };
421
+ }
422
+ const selection = selectRuntimeBudgetObservation({
423
+ observations: report.observations,
424
+ targetHost: input.targetHost,
425
+ now: input.now,
426
+ });
427
+ return {
428
+ ...(selection.kind === 'usable' ? { selected: selection.observation } : {}),
429
+ ...(selection.kind === 'stale' ? { stale: selection.observation } : {}),
430
+ perHost,
431
+ stalePerHost,
432
+ };
433
+ }
434
+ /**
435
+ * Rebuild the budget resolution an observation stands for, so console
436
+ * projections run under the SAME resolution shape the real injection used.
437
+ * `fallbackDetail` is deliberately dropped: it is log-only context and never
438
+ * rides events or console payloads.
439
+ */
440
+ export function runtimeBudgetObservationToResolution(observation) {
441
+ return {
442
+ budgetChars: observation.budgetChars,
443
+ capacitySource: observation.capacitySource,
444
+ capacityStatus: observation.capacityStatus,
445
+ contextWindowTokens: observation.contextWindowTokens,
446
+ policyVersion: observation.policyVersion ?? PROMPT_BUDGET_POLICY_VERSION,
447
+ ...(observation.fallbackReason !== null ? { fallbackReason: observation.fallbackReason } : {}),
448
+ };
449
+ }
@@ -1,4 +1,4 @@
1
- import { type PromptSelectionPolicy } from '@principles/core/runtime-v2';
1
+ import { type PromptBudgetResolution, type PromptSelectionPolicy } from '@principles/core/runtime-v2';
2
2
  /** Hosts with a production prompt-injection path today (shared GovernanceHostKind boundary). */
3
3
  export type PromptInjectionTargetHost = 'openclaw' | 'codex' | 'workbuddy';
4
4
  /** Single host roster (review P2): every consumer derives from this list, so a
@@ -140,6 +140,20 @@ export interface PromptInjectionProjection {
140
140
  * route (where `usedChars` already IS the full render).
141
141
  */
142
142
  fullRenderChars?: number;
143
+ /**
144
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1/§7: provenance of `budget` —
145
+ * the same policy/resolution the real injection runs, so consumers can
146
+ * tell "runtime capacity confirmed" apart from "fixed-budget fallback"
147
+ * instead of assuming the forecast matches every model.
148
+ */
149
+ budgetCapacitySource?: PromptBudgetResolution['capacitySource'];
150
+ budgetCapacityStatus?: PromptBudgetResolution['capacityStatus'];
151
+ budgetContextWindowTokens?: number | null;
152
+ budgetPolicyVersion?: PromptBudgetResolution['policyVersion'];
153
+ /** SPEC §7 v0.2: bounded fallback reason code; present only on legacy_fallback. */
154
+ budgetFallbackReason?: PromptBudgetResolution['fallbackReason'];
155
+ /** Non-sensitive log context; never emitted to events. */
156
+ budgetFallbackDetail?: string;
143
157
  }
144
158
  /**
145
159
  * PR #1844 follow-up: forecast the prompt-injection budget from the SAME
@@ -186,6 +200,14 @@ export declare function buildLivePromptInjectionProjection(input: {
186
200
  roundKey?: number;
187
201
  /** AC-01: explicit route binding — 'codex'/'workbuddy' always shared, 'openclaw' by flag. */
188
202
  targetHost?: PromptInjectionTargetHost;
203
+ /**
204
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1: budget resolution this
205
+ * projection must run under. Absent → the fixed-fallback default (the
206
+ * honest "no runtime capacity confirmed" policy), NOT a silent second
207
+ * policy. Callers holding live model facts (the plugin hook) pass the
208
+ * SAME resolution the injection uses.
209
+ */
210
+ promptBudget?: PromptBudgetResolution;
189
211
  }): Promise<PromptInjectionProjection>;
190
212
  /**
191
213
  * AC-01/R-A1: the host-aware entry every console/CLI consumer should use.
@@ -210,4 +232,20 @@ export declare function resolveLivePromptInjectionProjection(input: {
210
232
  targetHost?: PromptInjectionTargetHost;
211
233
  excludePrincipleIds?: ReadonlySet<string>;
212
234
  roundKey?: number;
235
+ /**
236
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1 / PRI-1017: budget resolution
237
+ * the confirmed projection must run under. Console callers pass the
238
+ * resolution rebuilt from the workspace's most recent runtime budget
239
+ * OBSERVATION (`runtimeBudgetObservationToResolution`) so the forecast uses
240
+ * the same budget the last real injection used. Absent → the fixed-fallback
241
+ * default (the honest "no runtime capacity confirmed" policy).
242
+ */
243
+ promptBudget?: PromptBudgetResolution;
244
+ /**
245
+ * PRI-1017: per-host budget resolutions for the unconfirmed per-host
246
+ * forecasts. Each host's projection runs under THAT host's observation —
247
+ * never another host's window. Absent per host → that host's honest
248
+ * fixed-fallback default.
249
+ */
250
+ perHostPromptBudget?: Partial<Record<PromptInjectionTargetHost, PromptBudgetResolution>>;
213
251
  }): Promise<LivePromptInjectionProjectionResolution>;
@@ -1,4 +1,4 @@
1
- import { RUNTIME_V2_PRINCIPLE_BUDGET, computeFeatureFlagsFromConfig, renderPrinciplesToDirectives, trimToBudget, } from '@principles/core/runtime-v2';
1
+ import { computeFeatureFlagsFromConfig, renderPrinciplesToDirectives, resolvePromptBudget, trimToBudget, } from '@principles/core/runtime-v2';
2
2
  import { escapeXml } from '@principles/core/prompt-builder';
3
3
  import { loadPdConfigForPlugin } from './pd-config.js';
4
4
  import { buildActivePrinciplePromptContext, readPromptActivationCandidates, MAX_SHARED_DIAGNOSTIC_IDS } from './active-principle-prompt.js';
@@ -223,6 +223,7 @@ function isDiagnosticCapHit(ids) {
223
223
  * `selectionPolicy` on the result.
224
224
  */
225
225
  export async function buildLivePromptInjectionProjection(input) {
226
+ const promptBudget = input.promptBudget ?? resolvePromptBudget();
226
227
  const flagEnabled = computeFeatureFlagsFromConfig(loadPdConfigForPlugin(input.workspaceDir).effective).flags.abstraction_layer_v1?.enabled === true;
227
228
  // Route face follows THE one route formula (review P2-2: a re-typed
228
229
  // codex-only ternary forecast WorkBuddy as legacy_trim while its adapter
@@ -232,7 +233,7 @@ export async function buildLivePromptInjectionProjection(input) {
232
233
  ? flagEnabled
233
234
  : promptRouteForHost(input.targetHost, flagEnabled) === 'shared_render';
234
235
  if (sharedRoute) {
235
- const context = await buildActivePrinciplePromptContext(input);
236
+ const context = await buildActivePrinciplePromptContext({ ...input, promptBudget });
236
237
  const oversized = context.oversizedActivationIds ?? [];
237
238
  const dropped = context.droppedActivationIds ?? [];
238
239
  return {
@@ -256,6 +257,12 @@ export async function buildLivePromptInjectionProjection(input) {
256
257
  droppedActivationIds: dropped,
257
258
  oversizedDiagnosticTruncated: isDiagnosticCapHit(oversized),
258
259
  droppedDiagnosticTruncated: isDiagnosticCapHit(dropped),
260
+ budgetCapacitySource: context.budgetCapacitySource,
261
+ budgetCapacityStatus: context.budgetCapacityStatus,
262
+ budgetContextWindowTokens: context.budgetContextWindowTokens,
263
+ budgetPolicyVersion: context.budgetPolicyVersion,
264
+ ...(context.budgetFallbackReason !== undefined ? { budgetFallbackReason: context.budgetFallbackReason } : {}),
265
+ ...(context.budgetFallbackDetail !== undefined ? { budgetFallbackDetail: context.budgetFallbackDetail } : {}),
259
266
  };
260
267
  }
261
268
  const candidates = await readPromptActivationCandidates(input);
@@ -263,7 +270,7 @@ export async function buildLivePromptInjectionProjection(input) {
263
270
  if (candidates.aborted || principles.length === 0) {
264
271
  return {
265
272
  route: 'legacy_trim',
266
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
273
+ budget: promptBudget.budgetChars,
267
274
  usedChars: 0,
268
275
  truncated: false,
269
276
  injectedPrincipleIds: [],
@@ -279,11 +286,17 @@ export async function buildLivePromptInjectionProjection(input) {
279
286
  droppedActivationIds: [],
280
287
  oversizedDiagnosticTruncated: false,
281
288
  droppedDiagnosticTruncated: false,
289
+ budgetCapacitySource: promptBudget.capacitySource,
290
+ budgetCapacityStatus: promptBudget.capacityStatus,
291
+ budgetContextWindowTokens: promptBudget.contextWindowTokens,
292
+ budgetPolicyVersion: promptBudget.policyVersion,
293
+ ...(promptBudget.fallbackReason !== undefined ? { budgetFallbackReason: promptBudget.fallbackReason } : {}),
294
+ ...(promptBudget.fallbackDetail !== undefined ? { budgetFallbackDetail: promptBudget.fallbackDetail } : {}),
282
295
  };
283
296
  }
284
297
  // PR-1894: pass the caller's round key so the forecast runs the SAME
285
298
  // packing policy production runs this turn.
286
- const trimmed = trimToBudget(principles, RUNTIME_V2_PRINCIPLE_BUDGET, escapeXml, input.roundKey);
299
+ const trimmed = trimToBudget(principles, promptBudget.budgetChars, escapeXml, input.roundKey);
287
300
  const injected = principles.filter((p) => trimmed.injectedIds.has(p.principleId));
288
301
  // PR-1894: the legacy_trim route IS the OpenClaw plugin-local route, which
289
302
  // rotates on every recorded user turn (PRI-904). Reachability is therefore
@@ -291,11 +304,11 @@ export async function buildLivePromptInjectionProjection(input) {
291
304
  // round key — a console without a session key must still be able to tell
292
305
  // "queued behind rotation" apart from "structurally starved", otherwise it
293
306
  // keeps telling the Owner to deactivate healthy principles.
294
- const eventuallyInjectedActivationIds = collectReachableActivationIds(principles, RUNTIME_V2_PRINCIPLE_BUDGET);
307
+ const eventuallyInjectedActivationIds = collectReachableActivationIds(principles, promptBudget.budgetChars);
295
308
  const fullRenderChars = renderPrinciplesToDirectives(injected, trimmed.injectedIds, { escapeFn: escapeXml, selfReportInstruction: candidates.selfReportEnabled }).length;
296
309
  return {
297
310
  route: 'legacy_trim',
298
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
311
+ budget: promptBudget.budgetChars,
299
312
  usedChars: trimmed.lines.join('\n').length,
300
313
  truncated: trimmed.truncated,
301
314
  injectedPrincipleIds: [...trimmed.injectedIds],
@@ -312,23 +325,44 @@ export async function buildLivePromptInjectionProjection(input) {
312
325
  oversizedDiagnosticTruncated: isDiagnosticCapHit(trimmed.oversizedActivationIds),
313
326
  droppedDiagnosticTruncated: isDiagnosticCapHit(trimmed.droppedActivationIds),
314
327
  ...(injected.length > 0 ? { fullRenderChars } : {}),
328
+ budgetCapacitySource: promptBudget.capacitySource,
329
+ budgetCapacityStatus: promptBudget.capacityStatus,
330
+ budgetContextWindowTokens: promptBudget.contextWindowTokens,
331
+ budgetPolicyVersion: promptBudget.policyVersion,
332
+ ...(promptBudget.fallbackReason !== undefined ? { budgetFallbackReason: promptBudget.fallbackReason } : {}),
333
+ ...(promptBudget.fallbackDetail !== undefined ? { budgetFallbackDetail: promptBudget.fallbackDetail } : {}),
315
334
  };
316
335
  }
317
336
  export async function resolveLivePromptInjectionProjection(input) {
318
- const decision = resolvePromptInjectionRouteDecision(input);
337
+ const { promptBudget, perHostPromptBudget, ...projectionInput } = input;
338
+ const decision = resolvePromptInjectionRouteDecision(projectionInput);
319
339
  if (decision.status === 'confirmed') {
340
+ // PRI-1017: the route can be confirmed by WORKSPACE FACTS without any
341
+ // request-level targetHost (a single-host workspace). The caller then has
342
+ // no `promptBudget`, but it does have that host's own observation in the
343
+ // per-host map — use it. A flag-shared route with no hostKind stays on
344
+ // the honest fallback: no host was identified, so no host's window may
345
+ // be borrowed.
346
+ const decidedBudget = promptBudget
347
+ ?? (decision.hostKind !== undefined ? perHostPromptBudget?.[decision.hostKind] : undefined);
320
348
  const projection = await buildLivePromptInjectionProjection({
321
- ...input,
349
+ ...projectionInput,
322
350
  targetHost: decision.hostKind,
351
+ ...(decidedBudget !== undefined ? { promptBudget: decidedBudget } : {}),
323
352
  });
324
353
  return { status: 'confirmed', decision, projection };
325
354
  }
326
355
  const hosts = decision.perHost?.map((entry) => entry.hostKind) ?? [...PROMPT_INJECTION_TARGET_HOSTS];
327
356
  const perHost = [];
328
357
  for (const hostKind of hosts) {
358
+ const hostBudget = perHostPromptBudget?.[hostKind];
329
359
  perHost.push({
330
360
  hostKind,
331
- projection: await buildLivePromptInjectionProjection({ ...input, targetHost: hostKind }),
361
+ projection: await buildLivePromptInjectionProjection({
362
+ ...projectionInput,
363
+ targetHost: hostKind,
364
+ ...(hostBudget !== undefined ? { promptBudget: hostBudget } : {}),
365
+ }),
332
366
  });
333
367
  }
334
368
  return { status: 'unconfirmed', decision, perHost };