@openprd/cli 0.1.18 → 0.1.21
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/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
- package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
- package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
- package/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-generated-change/design.md +50 -0
- package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
- package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
- package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
- package/.openprd/design/active/asset-spec.md +19 -14
- package/.openprd/design/active/direction-plan.md +19 -3
- package/.openprd/design/active/facts-sheet.md +16 -7
- package/.openprd/design/active/image-preflight.md +6 -5
- package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
- package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
- package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
- package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
- package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
- package/.openprd/design/active/selected-direction.md +23 -9
- package/.openprd/engagements/active/control-plane-architecture.json +203 -0
- package/.openprd/engagements/active/control-plane-intake.json +418 -0
- package/.openprd/engagements/active/prd.md +186 -100
- package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
- package/.openprd/i18n-config.json +12 -0
- package/.openprd/ledger/events.jsonl +6 -0
- package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
- package/.openprd/manifest.json +21 -0
- package/AGENTS.md +1 -0
- package/README.md +22 -0
- package/README_EN.md +27 -0
- package/package.json +1 -1
- package/skills/openprd-frontend-design/SKILL.md +16 -0
- package/skills/openprd-harness/SKILL.md +1 -1
- package/skills/openprd-quality/SKILL.md +1 -1
- package/skills/openprd-requirement-intake/SKILL.md +1 -1
- package/skills/openprd-shared/SKILL.md +1 -1
- package/src/adapters/adapter-spi.js +193 -0
- package/src/adapters/capability-envelope.js +98 -0
- package/src/adapters/event-normalizer.js +55 -0
- package/src/adapters/index.js +4 -0
- package/src/adapters/install-safety.js +249 -0
- package/src/agent-canonical-content.js +6 -2
- package/src/agent-integration.js +147 -42
- package/src/canvas-app-client-app.js +838 -0
- package/src/canvas-app-client-helpers.js +1138 -0
- package/src/canvas-app-styles.js +898 -0
- package/src/canvas-app.html.js +6 -2781
- package/src/canvas-binding.js +414 -0
- package/src/canvas-codex-relay.js +430 -0
- package/src/canvas-i18n.js +8 -0
- package/src/canvas-scene.js +377 -0
- package/src/canvas-session-store.js +148 -0
- package/src/canvas-workspace.js +59 -1066
- package/src/cli/args.js +63 -4
- package/src/cli/gate-print.js +17 -0
- package/src/cli/quality-commands.js +18 -0
- package/src/cli/quality-print.js +10 -0
- package/src/cli/runtime-print.js +24 -0
- package/src/codex-hook-runner-template.mjs +87 -77
- package/src/codex-runtime.js +48 -5
- package/src/context/cache.js +245 -0
- package/src/context/compiler.js +438 -0
- package/src/context/constants.js +30 -0
- package/src/context/index.js +39 -0
- package/src/context/redaction.js +84 -0
- package/src/context/stable.js +69 -0
- package/src/context/telemetry.js +42 -0
- package/src/dev-standards.js +57 -0
- package/src/fleet.js +112 -95
- package/src/gates/index.js +2 -0
- package/src/gates/scoped-gates.js +256 -0
- package/src/gates/store.js +126 -0
- package/src/gates/workspace.js +41 -0
- package/src/html-artifacts.js +725 -28
- package/src/kernel/atomic-store.js +299 -0
- package/src/kernel/event-envelope.js +166 -0
- package/src/kernel/index.js +4 -0
- package/src/kernel/project-ledger.js +467 -0
- package/src/kernel/project-manifest.js +205 -0
- package/src/knowledge-v3/index.js +1 -0
- package/src/knowledge-v3/lifecycle.js +290 -0
- package/src/knowledge.js +14 -7
- package/src/openprd.js +71 -2
- package/src/review-model.js +413 -0
- package/src/review-presentation.js +1 -1
- package/src/run-harness.js +431 -37
- package/src/runtime/cli_runtime_README.md +28 -0
- package/src/runtime/errors.js +66 -0
- package/src/runtime/index.js +44 -0
- package/src/runtime/lane-schema.js +141 -0
- package/src/runtime/lane-store.js +279 -0
- package/src/runtime/task-runtime.js +449 -0
- package/src/runtime/workspace.js +179 -0
- package/src/runtime/write-set.js +206 -0
- package/src/session-binding.js +16 -3
- package/src/session-registry.js +59 -1
- package/src/upgrade/fleet-mutation.js +166 -0
- package/src/upgrade/fleet-transaction.js +398 -0
- package/src/upgrade/transaction-store.js +416 -0
- package/src/visual-compare-core.js +66 -27
- package/src/visual-compare.js +18 -12
- package/src/workspace-core.js +109 -7
- package/src/workspace-registry.js +39 -1
- package/src/workspace-workflow.js +18 -15
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildContextRequestFingerprint,
|
|
7
|
+
compileContextCapsule,
|
|
8
|
+
verifyContextCapsule,
|
|
9
|
+
} from './compiler.js';
|
|
10
|
+
import {
|
|
11
|
+
CONTEXT_CACHE_SCHEMA_VERSION,
|
|
12
|
+
CONTEXT_COMPILER_VERSION,
|
|
13
|
+
} from './constants.js';
|
|
14
|
+
import { cloneStable, compareStableText, digestStable } from './stable.js';
|
|
15
|
+
|
|
16
|
+
function assertCacheKey(key) {
|
|
17
|
+
const normalized = String(key ?? '');
|
|
18
|
+
if (!/^context-v1-[a-f0-9]{64}$/.test(normalized)) {
|
|
19
|
+
throw new TypeError(`Invalid context cache key: ${normalized}`);
|
|
20
|
+
}
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildContextCacheKey(input, { previousCapsule = null } = {}) {
|
|
25
|
+
const fingerprint = buildContextRequestFingerprint(input, { previousCapsule });
|
|
26
|
+
const hash = digestStable({
|
|
27
|
+
compilerVersion: CONTEXT_COMPILER_VERSION,
|
|
28
|
+
fingerprint,
|
|
29
|
+
}).slice('sha256:'.length);
|
|
30
|
+
return `context-v1-${hash}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function contextCachePath(cacheDir, key) {
|
|
34
|
+
return path.join(cacheDir, `${assertCacheKey(key)}.json`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cacheEnvelope(key, capsule) {
|
|
38
|
+
if (!verifyContextCapsule(capsule)) {
|
|
39
|
+
throw new TypeError('Refusing to cache an invalid ContextCapsule.');
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
|
|
43
|
+
key: assertCacheKey(key),
|
|
44
|
+
revision: capsule.revision,
|
|
45
|
+
capsuleDigest: capsule.digest,
|
|
46
|
+
capsule,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validEnvelope(value, key) {
|
|
51
|
+
return Boolean(
|
|
52
|
+
value
|
|
53
|
+
&& value.schemaVersion === CONTEXT_CACHE_SCHEMA_VERSION
|
|
54
|
+
&& value.key === key
|
|
55
|
+
&& value.capsuleDigest === value.capsule?.digest
|
|
56
|
+
&& verifyContextCapsule(value.capsule),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readContextCache(cacheDir, key) {
|
|
61
|
+
const normalizedKey = assertCacheKey(key);
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(await fs.readFile(contextCachePath(cacheDir, normalizedKey), 'utf8'));
|
|
64
|
+
return validEnvelope(parsed, normalizedKey) ? parsed.capsule : null;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code === 'ENOENT' || error instanceof SyntaxError) return null;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function writeContextCache(cacheDir, key, capsule) {
|
|
72
|
+
const normalizedKey = assertCacheKey(key);
|
|
73
|
+
const target = contextCachePath(cacheDir, normalizedKey);
|
|
74
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
75
|
+
const envelope = cacheEnvelope(normalizedKey, capsule);
|
|
76
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
77
|
+
try {
|
|
78
|
+
await fs.writeFile(temporary, `${JSON.stringify(envelope)}\n`, 'utf8');
|
|
79
|
+
await fs.rename(temporary, target);
|
|
80
|
+
} finally {
|
|
81
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeInvalidationSelector(selector = {}) {
|
|
87
|
+
const keys = new Set();
|
|
88
|
+
if (selector.key) keys.add(assertCacheKey(selector.key));
|
|
89
|
+
for (const key of selector.keys ?? []) keys.add(assertCacheKey(key));
|
|
90
|
+
return {
|
|
91
|
+
keys,
|
|
92
|
+
revision: selector.revision == null ? null : String(selector.revision),
|
|
93
|
+
all: selector.all === true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function invalidateContextCache(cacheDir, selector = {}) {
|
|
98
|
+
const normalized = normalizeInvalidationSelector(selector);
|
|
99
|
+
let names;
|
|
100
|
+
try {
|
|
101
|
+
names = await fs.readdir(cacheDir);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error?.code === 'ENOENT') return { removed: [], kept: [], errors: [] };
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const removed = [];
|
|
107
|
+
const kept = [];
|
|
108
|
+
const errors = [];
|
|
109
|
+
for (const name of names.filter((item) => /^context-v1-[a-f0-9]{64}\.json$/.test(item)).sort()) {
|
|
110
|
+
const key = name.slice(0, -'.json'.length);
|
|
111
|
+
let shouldRemove = normalized.all || normalized.keys.has(key);
|
|
112
|
+
if (!shouldRemove && normalized.revision != null) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(await fs.readFile(path.join(cacheDir, name), 'utf8'));
|
|
115
|
+
shouldRemove = parsed.revision === normalized.revision;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
errors.push({ key, error: error?.code ?? error?.name ?? 'read-error' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!shouldRemove) {
|
|
121
|
+
kept.push(key);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await fs.rm(path.join(cacheDir, name), { force: true });
|
|
126
|
+
removed.push(key);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
errors.push({ key, error: error?.code ?? error?.name ?? 'remove-error' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { removed, kept, errors };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createMemoryContextCache() {
|
|
135
|
+
const records = new Map();
|
|
136
|
+
const counters = { reads: 0, writes: 0, hits: 0, misses: 0, invalidations: 0 };
|
|
137
|
+
return {
|
|
138
|
+
async read(key) {
|
|
139
|
+
const normalizedKey = assertCacheKey(key);
|
|
140
|
+
counters.reads += 1;
|
|
141
|
+
if (!records.has(normalizedKey)) {
|
|
142
|
+
counters.misses += 1;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
counters.hits += 1;
|
|
146
|
+
return cloneStable(records.get(normalizedKey).capsule);
|
|
147
|
+
},
|
|
148
|
+
async write(key, capsule) {
|
|
149
|
+
const normalizedKey = assertCacheKey(key);
|
|
150
|
+
records.set(normalizedKey, cacheEnvelope(normalizedKey, cloneStable(capsule)));
|
|
151
|
+
counters.writes += 1;
|
|
152
|
+
},
|
|
153
|
+
async invalidate(selector = {}) {
|
|
154
|
+
const normalized = normalizeInvalidationSelector(selector);
|
|
155
|
+
const removed = [];
|
|
156
|
+
const kept = [];
|
|
157
|
+
for (const [key, envelope] of [...records.entries()].sort(([left], [right]) => compareStableText(left, right))) {
|
|
158
|
+
const shouldRemove = normalized.all
|
|
159
|
+
|| normalized.keys.has(key)
|
|
160
|
+
|| (normalized.revision != null && envelope.revision === normalized.revision);
|
|
161
|
+
if (shouldRemove) {
|
|
162
|
+
records.delete(key);
|
|
163
|
+
removed.push(key);
|
|
164
|
+
} else {
|
|
165
|
+
kept.push(key);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
counters.invalidations += removed.length;
|
|
169
|
+
return { removed, kept, errors: [] };
|
|
170
|
+
},
|
|
171
|
+
stats() {
|
|
172
|
+
return { ...counters, size: records.size };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createFileContextCache(cacheDir) {
|
|
178
|
+
const counters = { reads: 0, writes: 0, hits: 0, misses: 0, invalidations: 0 };
|
|
179
|
+
return {
|
|
180
|
+
async read(key) {
|
|
181
|
+
counters.reads += 1;
|
|
182
|
+
const capsule = await readContextCache(cacheDir, key);
|
|
183
|
+
if (capsule) counters.hits += 1;
|
|
184
|
+
else counters.misses += 1;
|
|
185
|
+
return capsule;
|
|
186
|
+
},
|
|
187
|
+
async write(key, capsule) {
|
|
188
|
+
counters.writes += 1;
|
|
189
|
+
return writeContextCache(cacheDir, key, capsule);
|
|
190
|
+
},
|
|
191
|
+
async invalidate(selector = {}) {
|
|
192
|
+
const result = await invalidateContextCache(cacheDir, selector);
|
|
193
|
+
counters.invalidations += result.removed.length;
|
|
194
|
+
return result;
|
|
195
|
+
},
|
|
196
|
+
stats() {
|
|
197
|
+
return { ...counters };
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function compileContextWithCache(input, {
|
|
203
|
+
cache,
|
|
204
|
+
previousCapsule = null,
|
|
205
|
+
} = {}) {
|
|
206
|
+
if (!cache || typeof cache.read !== 'function' || typeof cache.write !== 'function') {
|
|
207
|
+
throw new TypeError('compileContextWithCache requires a cache with read() and write().');
|
|
208
|
+
}
|
|
209
|
+
const cacheKey = buildContextCacheKey(input, { previousCapsule });
|
|
210
|
+
const cached = await cache.read(cacheKey);
|
|
211
|
+
if (cached && verifyContextCapsule(cached)) {
|
|
212
|
+
return { capsule: cached, cacheKey, cacheStatus: 'hit', compiled: false };
|
|
213
|
+
}
|
|
214
|
+
const capsule = compileContextCapsule(input, { previousCapsule });
|
|
215
|
+
await cache.write(cacheKey, capsule);
|
|
216
|
+
return { capsule, cacheKey, cacheStatus: 'miss', compiled: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A structural cold/warm benchmark. It deliberately reports operation counts
|
|
221
|
+
* rather than wall-clock thresholds, which keeps CI stable across machines.
|
|
222
|
+
*/
|
|
223
|
+
export async function runContextCacheBenchmark(input, {
|
|
224
|
+
cache = createMemoryContextCache(),
|
|
225
|
+
previousCapsule = null,
|
|
226
|
+
} = {}) {
|
|
227
|
+
const cold = await compileContextWithCache(input, { cache, previousCapsule });
|
|
228
|
+
const warm = await compileContextWithCache(input, { cache, previousCapsule });
|
|
229
|
+
return {
|
|
230
|
+
cold: {
|
|
231
|
+
cacheStatus: cold.cacheStatus,
|
|
232
|
+
compiled: cold.compiled,
|
|
233
|
+
digest: cold.capsule.digest,
|
|
234
|
+
includedCount: cold.capsule.entries.length,
|
|
235
|
+
},
|
|
236
|
+
warm: {
|
|
237
|
+
cacheStatus: warm.cacheStatus,
|
|
238
|
+
compiled: warm.compiled,
|
|
239
|
+
digest: warm.capsule.digest,
|
|
240
|
+
includedCount: warm.capsule.entries.length,
|
|
241
|
+
},
|
|
242
|
+
sameDigest: cold.capsule.digest === warm.capsule.digest,
|
|
243
|
+
cacheStats: typeof cache.stats === 'function' ? cache.stats() : null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTEXT_AUTHORITY_SCORES,
|
|
3
|
+
CONTEXT_CAPSULE_SCHEMA_VERSION,
|
|
4
|
+
CONTEXT_COMPILER_VERSION,
|
|
5
|
+
CONTEXT_FRESHNESS_SCORES,
|
|
6
|
+
CONTEXT_REQUEST_SCHEMA_VERSION,
|
|
7
|
+
DEFAULT_CONTEXT_DELTA_TOKEN_BUDGET,
|
|
8
|
+
DEFAULT_CONTEXT_TOKEN_BUDGET,
|
|
9
|
+
} from './constants.js';
|
|
10
|
+
import { redactContextSecrets } from './redaction.js';
|
|
11
|
+
import {
|
|
12
|
+
cloneStable,
|
|
13
|
+
compareStableText,
|
|
14
|
+
digestStable,
|
|
15
|
+
digestText,
|
|
16
|
+
stableStringify,
|
|
17
|
+
} from './stable.js';
|
|
18
|
+
|
|
19
|
+
function positiveInteger(value, fallback) {
|
|
20
|
+
const parsed = Number(value);
|
|
21
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
22
|
+
return Math.max(1, Math.floor(parsed));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function finiteScore(value, fallback = 0) {
|
|
26
|
+
const parsed = Number(value);
|
|
27
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeDelivery(value) {
|
|
31
|
+
return ['auto', 'full', 'delta'].includes(value) ? value : 'auto';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeFileContentPolicy(value) {
|
|
35
|
+
return value === 'inline' ? 'inline' : 'reference';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function canonicalCandidateOrder(left, right) {
|
|
39
|
+
return compareStableText(stableStringify(left), stableStringify(right));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a ContextRequest v1 without mutating the caller's input.
|
|
44
|
+
* A revision is mandatory so cache and delta behavior never depend on wall time.
|
|
45
|
+
*/
|
|
46
|
+
export function createContextRequest(input = {}) {
|
|
47
|
+
if (input.schemaVersion && input.schemaVersion !== CONTEXT_REQUEST_SCHEMA_VERSION) {
|
|
48
|
+
throw new TypeError(`Unsupported context request schema: ${input.schemaVersion}`);
|
|
49
|
+
}
|
|
50
|
+
const revision = String(input.revision ?? '').trim();
|
|
51
|
+
if (!revision) {
|
|
52
|
+
throw new TypeError('ContextRequest v1 requires a non-empty revision.');
|
|
53
|
+
}
|
|
54
|
+
const rawCandidates = input.candidates ?? input.sources ?? [];
|
|
55
|
+
if (!Array.isArray(rawCandidates)) {
|
|
56
|
+
throw new TypeError('ContextRequest v1 candidates must be an array.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const candidates = rawCandidates
|
|
60
|
+
.map((candidate) => cloneStable(candidate && typeof candidate === 'object' ? candidate : { value: candidate }))
|
|
61
|
+
.sort(canonicalCandidateOrder);
|
|
62
|
+
return {
|
|
63
|
+
schemaVersion: CONTEXT_REQUEST_SCHEMA_VERSION,
|
|
64
|
+
compilerVersion: CONTEXT_COMPILER_VERSION,
|
|
65
|
+
workspaceId: String(input.workspaceId ?? 'workspace'),
|
|
66
|
+
revision,
|
|
67
|
+
taskId: input.taskId == null ? null : String(input.taskId),
|
|
68
|
+
intent: input.intent == null ? null : String(input.intent),
|
|
69
|
+
delivery: normalizeDelivery(input.delivery),
|
|
70
|
+
tokenBudget: positiveInteger(input.tokenBudget, DEFAULT_CONTEXT_TOKEN_BUDGET),
|
|
71
|
+
deltaTokenBudget: positiveInteger(input.deltaTokenBudget, DEFAULT_CONTEXT_DELTA_TOKEN_BUDGET),
|
|
72
|
+
fileContentPolicy: normalizeFileContentPolicy(input.fileContentPolicy),
|
|
73
|
+
candidates,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeAuthority(value) {
|
|
78
|
+
if (typeof value === 'number') {
|
|
79
|
+
return { label: 'numeric', score: finiteScore(value) };
|
|
80
|
+
}
|
|
81
|
+
if (value && typeof value === 'object') {
|
|
82
|
+
const label = String(value.label ?? value.kind ?? value.name ?? 'custom');
|
|
83
|
+
const fallback = CONTEXT_AUTHORITY_SCORES[label] ?? 0;
|
|
84
|
+
return { label, score: finiteScore(value.score, fallback) };
|
|
85
|
+
}
|
|
86
|
+
const label = String(value ?? 'unknown').trim().toLowerCase() || 'unknown';
|
|
87
|
+
return { label, score: CONTEXT_AUTHORITY_SCORES[label] ?? CONTEXT_AUTHORITY_SCORES.unknown };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeFreshness(value) {
|
|
91
|
+
if (typeof value === 'number') {
|
|
92
|
+
return { value, score: finiteScore(value) };
|
|
93
|
+
}
|
|
94
|
+
if (value instanceof Date) {
|
|
95
|
+
const iso = value.toISOString();
|
|
96
|
+
return { value: iso, score: value.getTime() };
|
|
97
|
+
}
|
|
98
|
+
if (value && typeof value === 'object') {
|
|
99
|
+
const label = String(value.value ?? value.label ?? value.kind ?? 'unknown');
|
|
100
|
+
const parsedTimestamp = Date.parse(label);
|
|
101
|
+
const fallback = CONTEXT_FRESHNESS_SCORES[label]
|
|
102
|
+
?? (Number.isFinite(parsedTimestamp) ? parsedTimestamp : 0);
|
|
103
|
+
return { value: label, score: finiteScore(value.score, fallback) };
|
|
104
|
+
}
|
|
105
|
+
const label = String(value ?? 'unknown').trim().toLowerCase() || 'unknown';
|
|
106
|
+
if (Object.hasOwn(CONTEXT_FRESHNESS_SCORES, label)) {
|
|
107
|
+
return { value: label, score: CONTEXT_FRESHNESS_SCORES[label] };
|
|
108
|
+
}
|
|
109
|
+
const timestamp = Date.parse(label);
|
|
110
|
+
return { value: label, score: Number.isFinite(timestamp) ? timestamp : 0 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeRelevance(value) {
|
|
114
|
+
return finiteScore(value, 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function estimateTokens(value) {
|
|
118
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(stableStringify(value), 'utf8') / 4));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizedFileCandidate(candidate, request) {
|
|
122
|
+
const file = candidate.file ?? candidate.fileRef ?? candidate;
|
|
123
|
+
const pathResult = redactContextSecrets(String(file.path ?? candidate.path ?? ''));
|
|
124
|
+
const rawContent = file.content ?? candidate.content;
|
|
125
|
+
const contentString = rawContent == null
|
|
126
|
+
? null
|
|
127
|
+
: typeof rawContent === 'string'
|
|
128
|
+
? rawContent
|
|
129
|
+
: stableStringify(rawContent);
|
|
130
|
+
const fileDigest = file.digest
|
|
131
|
+
? String(file.digest)
|
|
132
|
+
: contentString != null
|
|
133
|
+
? digestText(contentString)
|
|
134
|
+
: digestText(`path:${pathResult.value}`);
|
|
135
|
+
const value = {
|
|
136
|
+
type: 'file-ref',
|
|
137
|
+
path: pathResult.value,
|
|
138
|
+
digest: fileDigest,
|
|
139
|
+
sizeBytes: file.sizeBytes == null
|
|
140
|
+
? contentString == null ? null : Buffer.byteLength(contentString, 'utf8')
|
|
141
|
+
: Math.max(0, Math.floor(finiteScore(file.sizeBytes))),
|
|
142
|
+
};
|
|
143
|
+
const redactions = [...pathResult.redactions];
|
|
144
|
+
const reasonCodes = ['file-reference'];
|
|
145
|
+
if (request.fileContentPolicy === 'inline' && contentString != null) {
|
|
146
|
+
const contentResult = redactContextSecrets(contentString);
|
|
147
|
+
value.content = contentResult.value;
|
|
148
|
+
redactions.push(...contentResult.redactions);
|
|
149
|
+
reasonCodes.push('file-content-explicitly-inlined');
|
|
150
|
+
} else if (contentString != null) {
|
|
151
|
+
reasonCodes.push('file-content-omitted');
|
|
152
|
+
}
|
|
153
|
+
return { value, redactions, reasonCodes };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function candidateValue(candidate, request) {
|
|
157
|
+
const isFile = candidate.kind === 'file'
|
|
158
|
+
|| candidate.file != null
|
|
159
|
+
|| candidate.fileRef != null
|
|
160
|
+
|| (candidate.path != null && candidate.content != null);
|
|
161
|
+
if (isFile) {
|
|
162
|
+
return normalizedFileCandidate(candidate, request);
|
|
163
|
+
}
|
|
164
|
+
const rawValue = candidate.data
|
|
165
|
+
?? candidate.value
|
|
166
|
+
?? candidate.content
|
|
167
|
+
?? candidate.summary
|
|
168
|
+
?? candidate.text
|
|
169
|
+
?? null;
|
|
170
|
+
const result = redactContextSecrets(rawValue);
|
|
171
|
+
return {
|
|
172
|
+
value: result.value,
|
|
173
|
+
redactions: result.redactions,
|
|
174
|
+
reasonCodes: [],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeCandidate(candidate, request) {
|
|
179
|
+
const authority = normalizeAuthority(candidate.authority);
|
|
180
|
+
const freshness = normalizeFreshness(candidate.freshness);
|
|
181
|
+
const relevance = normalizeRelevance(candidate.relevance);
|
|
182
|
+
const kind = String(candidate.kind ?? (candidate.file || candidate.fileRef ? 'file' : 'fact'));
|
|
183
|
+
const normalizedValue = candidateValue(candidate, request);
|
|
184
|
+
const derivedIdentity = digestStable({ kind, value: normalizedValue.value });
|
|
185
|
+
const rawId = candidate.id ?? candidate.key ?? `${kind}:${derivedIdentity.slice('sha256:'.length, 'sha256:'.length + 20)}`;
|
|
186
|
+
const idResult = redactContextSecrets(String(rawId));
|
|
187
|
+
const redactions = [...idResult.redactions, ...normalizedValue.redactions];
|
|
188
|
+
const value = normalizedValue.value;
|
|
189
|
+
const digest = digestStable({ authority, freshness, id: idResult.value, kind, relevance, value });
|
|
190
|
+
const estimatedTokens = estimateTokens({ id: idResult.value, kind, value });
|
|
191
|
+
return {
|
|
192
|
+
id: idResult.value,
|
|
193
|
+
kind,
|
|
194
|
+
authority,
|
|
195
|
+
freshness,
|
|
196
|
+
relevance,
|
|
197
|
+
value,
|
|
198
|
+
digest,
|
|
199
|
+
estimatedTokens,
|
|
200
|
+
redactionCount: redactions.length,
|
|
201
|
+
redactionReasons: [...new Set(redactions.map((item) => item.reason))].sort(compareStableText),
|
|
202
|
+
reasonCodes: normalizedValue.reasonCodes,
|
|
203
|
+
empty: value == null || value === '',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function compareCandidates(left, right) {
|
|
208
|
+
if (left.authority.score !== right.authority.score) {
|
|
209
|
+
return right.authority.score - left.authority.score;
|
|
210
|
+
}
|
|
211
|
+
if (left.freshness.score !== right.freshness.score) {
|
|
212
|
+
return right.freshness.score - left.freshness.score;
|
|
213
|
+
}
|
|
214
|
+
if (left.relevance !== right.relevance) {
|
|
215
|
+
return right.relevance - left.relevance;
|
|
216
|
+
}
|
|
217
|
+
const idOrder = compareStableText(left.id, right.id);
|
|
218
|
+
if (idOrder !== 0) return idOrder;
|
|
219
|
+
return compareStableText(left.digest, right.digest);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function uniqueRankedCandidates(candidates) {
|
|
223
|
+
const ranked = [...candidates].sort(compareCandidates);
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const unique = [];
|
|
226
|
+
const duplicates = [];
|
|
227
|
+
for (const candidate of ranked) {
|
|
228
|
+
if (seen.has(candidate.id)) {
|
|
229
|
+
duplicates.push(candidate);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
seen.add(candidate.id);
|
|
233
|
+
unique.push(candidate);
|
|
234
|
+
}
|
|
235
|
+
return { unique, duplicates };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function previousEntryMap(previousCapsule) {
|
|
239
|
+
const previous = new Map();
|
|
240
|
+
for (const entry of previousCapsule?.excluded ?? []) {
|
|
241
|
+
if (
|
|
242
|
+
entry?.id
|
|
243
|
+
&& entry?.digest
|
|
244
|
+
&& entry.excludedReason !== 'duplicate-lower-ranked-candidate'
|
|
245
|
+
&& entry.excludedReason !== 'empty-candidate'
|
|
246
|
+
) {
|
|
247
|
+
previous.set(String(entry.id), String(entry.digest));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Delivered entries are authoritative when an older capsule contains both a
|
|
251
|
+
// selected candidate and a lower-ranked duplicate exclusion with the same id.
|
|
252
|
+
for (const entry of previousCapsule?.entries ?? []) {
|
|
253
|
+
if (entry?.id && entry?.digest) previous.set(String(entry.id), String(entry.digest));
|
|
254
|
+
}
|
|
255
|
+
return previous;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resolveDelivery(request, previousCapsule) {
|
|
259
|
+
if (request.delivery === 'full') return 'full';
|
|
260
|
+
if (request.delivery === 'delta' && previousCapsule) return 'delta';
|
|
261
|
+
if (request.delivery === 'auto' && previousCapsule) return 'delta';
|
|
262
|
+
return 'full';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function contextRequestIdentity(request, normalizedCandidates, previousCapsule, delivery) {
|
|
266
|
+
return {
|
|
267
|
+
schemaVersion: request.schemaVersion,
|
|
268
|
+
compilerVersion: request.compilerVersion,
|
|
269
|
+
workspaceId: request.workspaceId,
|
|
270
|
+
revision: request.revision,
|
|
271
|
+
taskId: request.taskId,
|
|
272
|
+
intent: request.intent,
|
|
273
|
+
delivery,
|
|
274
|
+
tokenBudget: request.tokenBudget,
|
|
275
|
+
deltaTokenBudget: request.deltaTokenBudget,
|
|
276
|
+
fileContentPolicy: request.fileContentPolicy,
|
|
277
|
+
previousCapsuleDigest: delivery === 'delta' ? previousCapsule?.digest ?? null : null,
|
|
278
|
+
candidates: normalizedCandidates
|
|
279
|
+
.map((candidate) => ({ id: candidate.id, digest: candidate.digest }))
|
|
280
|
+
.sort((left, right) => compareStableText(left.id, right.id) || compareStableText(left.digest, right.digest)),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function buildContextRequestFingerprint(input, { previousCapsule = null } = {}) {
|
|
285
|
+
const request = createContextRequest(input);
|
|
286
|
+
const normalized = request.candidates.map((candidate) => normalizeCandidate(candidate, request));
|
|
287
|
+
const { unique } = uniqueRankedCandidates(normalized);
|
|
288
|
+
const delivery = resolveDelivery(request, previousCapsule);
|
|
289
|
+
return digestStable(contextRequestIdentity(request, unique, previousCapsule, delivery));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function asIncludedEntry(candidate, rank) {
|
|
293
|
+
const reasonCodes = [
|
|
294
|
+
'authority-freshness-relevance-ranked',
|
|
295
|
+
'within-token-budget',
|
|
296
|
+
...candidate.reasonCodes,
|
|
297
|
+
];
|
|
298
|
+
if (candidate.redactionCount > 0) reasonCodes.push('secrets-redacted');
|
|
299
|
+
return {
|
|
300
|
+
id: candidate.id,
|
|
301
|
+
kind: candidate.kind,
|
|
302
|
+
authority: candidate.authority,
|
|
303
|
+
freshness: candidate.freshness,
|
|
304
|
+
relevance: candidate.relevance,
|
|
305
|
+
value: candidate.value,
|
|
306
|
+
digest: candidate.digest,
|
|
307
|
+
rank,
|
|
308
|
+
estimatedTokens: candidate.estimatedTokens,
|
|
309
|
+
includedReason: [...new Set(reasonCodes)],
|
|
310
|
+
redactionCount: candidate.redactionCount,
|
|
311
|
+
redactionReasons: candidate.redactionReasons,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function excludedEntry(candidate, rank, reason, extra = {}) {
|
|
316
|
+
return {
|
|
317
|
+
id: candidate.id,
|
|
318
|
+
digest: candidate.digest,
|
|
319
|
+
rank,
|
|
320
|
+
estimatedTokens: candidate.estimatedTokens,
|
|
321
|
+
excludedReason: reason,
|
|
322
|
+
...extra,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function compareExcluded(left, right) {
|
|
327
|
+
if (left.rank !== right.rank) return left.rank - right.rank;
|
|
328
|
+
return compareStableText(left.id, right.id) || compareStableText(left.excludedReason, right.excludedReason);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function computeContextCapsuleDigest(capsule) {
|
|
332
|
+
const { digest: _digest, ...core } = capsule;
|
|
333
|
+
return digestStable(core);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function verifyContextCapsule(capsule) {
|
|
337
|
+
return Boolean(
|
|
338
|
+
capsule
|
|
339
|
+
&& capsule.schemaVersion === CONTEXT_CAPSULE_SCHEMA_VERSION
|
|
340
|
+
&& typeof capsule.digest === 'string'
|
|
341
|
+
&& capsule.digest === computeContextCapsuleDigest(capsule),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Compile a deterministic ContextCapsule v1. This function performs no I/O and
|
|
347
|
+
* does not emit telemetry; cache and telemetry side effects live in separate APIs.
|
|
348
|
+
*/
|
|
349
|
+
export function compileContextCapsule(input, { previousCapsule = null } = {}) {
|
|
350
|
+
const request = createContextRequest(input);
|
|
351
|
+
const normalized = request.candidates.map((candidate) => normalizeCandidate(candidate, request));
|
|
352
|
+
const { unique, duplicates } = uniqueRankedCandidates(normalized);
|
|
353
|
+
const delivery = resolveDelivery(request, previousCapsule);
|
|
354
|
+
const requestDigest = digestStable(contextRequestIdentity(request, unique, previousCapsule, delivery));
|
|
355
|
+
const previous = previousEntryMap(previousCapsule);
|
|
356
|
+
const currentIds = new Set(unique.map((candidate) => candidate.id));
|
|
357
|
+
const delta = {
|
|
358
|
+
previousCapsuleDigest: delivery === 'delta' ? previousCapsule?.digest ?? null : null,
|
|
359
|
+
added: [],
|
|
360
|
+
changed: [],
|
|
361
|
+
removed: delivery === 'delta'
|
|
362
|
+
? [...previous.keys()].filter((id) => !currentIds.has(id)).sort(compareStableText)
|
|
363
|
+
: [],
|
|
364
|
+
unchanged: [],
|
|
365
|
+
};
|
|
366
|
+
const excluded = [];
|
|
367
|
+
duplicates.forEach((candidate) => {
|
|
368
|
+
const rank = unique.findIndex((item) => item.id === candidate.id) + 1;
|
|
369
|
+
excluded.push(excludedEntry(candidate, rank, 'duplicate-lower-ranked-candidate'));
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const eligible = [];
|
|
373
|
+
unique.forEach((candidate, index) => {
|
|
374
|
+
const rank = index + 1;
|
|
375
|
+
if (candidate.empty) {
|
|
376
|
+
excluded.push(excludedEntry(candidate, rank, 'empty-candidate'));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (delivery !== 'delta') {
|
|
380
|
+
eligible.push({ candidate, rank });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const previousDigest = previous.get(candidate.id);
|
|
384
|
+
if (previousDigest === candidate.digest) {
|
|
385
|
+
delta.unchanged.push(candidate.id);
|
|
386
|
+
excluded.push(excludedEntry(candidate, rank, 'unchanged-since-previous-capsule'));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (previousDigest) delta.changed.push(candidate.id);
|
|
390
|
+
else delta.added.push(candidate.id);
|
|
391
|
+
eligible.push({ candidate, rank });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const limitTokens = delivery === 'delta' ? request.deltaTokenBudget : request.tokenBudget;
|
|
395
|
+
let usedTokens = 0;
|
|
396
|
+
const entries = [];
|
|
397
|
+
for (const { candidate, rank } of eligible) {
|
|
398
|
+
if (usedTokens + candidate.estimatedTokens > limitTokens) {
|
|
399
|
+
excluded.push(excludedEntry(candidate, rank, 'token-budget-exceeded', {
|
|
400
|
+
remainingTokens: Math.max(0, limitTokens - usedTokens),
|
|
401
|
+
}));
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
entries.push(asIncludedEntry(candidate, rank));
|
|
405
|
+
usedTokens += candidate.estimatedTokens;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
delta.added.sort(compareStableText);
|
|
409
|
+
delta.changed.sort(compareStableText);
|
|
410
|
+
delta.unchanged.sort(compareStableText);
|
|
411
|
+
excluded.sort(compareExcluded);
|
|
412
|
+
|
|
413
|
+
const capsule = {
|
|
414
|
+
schemaVersion: CONTEXT_CAPSULE_SCHEMA_VERSION,
|
|
415
|
+
compilerVersion: CONTEXT_COMPILER_VERSION,
|
|
416
|
+
requestDigest,
|
|
417
|
+
workspaceId: request.workspaceId,
|
|
418
|
+
revision: request.revision,
|
|
419
|
+
taskId: request.taskId,
|
|
420
|
+
delivery,
|
|
421
|
+
budget: {
|
|
422
|
+
limitTokens,
|
|
423
|
+
estimatedTokens: usedTokens,
|
|
424
|
+
remainingTokens: Math.max(0, limitTokens - usedTokens),
|
|
425
|
+
},
|
|
426
|
+
entries,
|
|
427
|
+
excluded,
|
|
428
|
+
delta,
|
|
429
|
+
summary: {
|
|
430
|
+
candidateCount: normalized.length,
|
|
431
|
+
includedCount: entries.length,
|
|
432
|
+
excludedCount: excluded.length,
|
|
433
|
+
redactionCount: entries.reduce((sum, entry) => sum + entry.redactionCount, 0),
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
capsule.digest = computeContextCapsuleDigest(capsule);
|
|
437
|
+
return capsule;
|
|
438
|
+
}
|