@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.
- package/dist/active-principle-prompt.d.ts +23 -1
- package/dist/active-principle-prompt.js +21 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +37 -2
- package/dist/prompt-activation-deliverability.d.ts +36 -3
- package/dist/prompt-activation-deliverability.js +44 -26
- package/dist/prompt-injection-budget-observation.d.ts +176 -0
- package/dist/prompt-injection-budget-observation.js +449 -0
- package/dist/prompt-injection-projection.d.ts +39 -1
- package/dist/prompt-injection-projection.js +43 -9
- package/dist/prompt-injection-status.d.ts +47 -5
- package/dist/prompt-injection-status.js +45 -8
- package/package.json +2 -2
|
@@ -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 {
|
|
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:
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
...
|
|
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({
|
|
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 };
|