@aragon-agent/cli 0.6.14 → 0.6.15
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/CHANGELOG.md +14 -0
- package/README.md +250 -233
- package/dist/agent/controller.d.ts +27 -1
- package/dist/agent/controller.js +182 -45
- package/dist/agent/plan-prompt.d.ts +1 -1
- package/dist/agent/plan-prompt.js +4 -3
- package/dist/agent/project-guidance-prompt.d.ts +4 -0
- package/dist/agent/project-guidance-prompt.js +104 -0
- package/dist/agent/reducer.d.ts +2 -0
- package/dist/agent/reducer.js +4 -0
- package/dist/agent/system-prompt.js +4 -0
- package/dist/commands/builtins.js +2 -2
- package/dist/compaction/child.d.ts +9 -1
- package/dist/compaction/child.js +39 -13
- package/dist/compaction/command.js +20 -10
- package/dist/compaction/compactor.d.ts +33 -204
- package/dist/compaction/compactor.js +311 -726
- package/dist/compaction/digest.d.ts +15 -51
- package/dist/compaction/digest.js +113 -148
- package/dist/compaction/limits.d.ts +19 -142
- package/dist/compaction/limits.js +20 -147
- package/dist/compaction/memory-identity.d.ts +16 -0
- package/dist/compaction/memory-identity.js +63 -0
- package/dist/compaction/memory-input.d.ts +25 -0
- package/dist/compaction/memory-input.js +106 -0
- package/dist/compaction/memory-render.d.ts +4 -0
- package/dist/compaction/memory-render.js +47 -0
- package/dist/compaction/memory-validation.d.ts +21 -0
- package/dist/compaction/memory-validation.js +268 -0
- package/dist/compaction/memory.d.ts +79 -0
- package/dist/compaction/memory.js +118 -0
- package/dist/compaction/meter.d.ts +14 -10
- package/dist/compaction/meter.js +53 -19
- package/dist/compaction/operation.d.ts +18 -0
- package/dist/compaction/operation.js +68 -0
- package/dist/compaction/pressure.d.ts +5 -20
- package/dist/compaction/pressure.js +13 -23
- package/dist/compaction/summarize-call.d.ts +3 -16
- package/dist/compaction/summarize-call.js +20 -20
- package/dist/compaction/summary-prompt.d.ts +29 -66
- package/dist/compaction/summary-prompt.js +132 -178
- package/dist/compaction/types.d.ts +21 -3
- package/dist/compaction/wiring.d.ts +10 -25
- package/dist/compaction/wiring.js +56 -66
- package/dist/config/schema.d.ts +1 -1
- package/dist/config/schema.js +1 -1
- package/dist/exec/events.d.ts +8 -0
- package/dist/exec/index.js +13 -6
- package/dist/exec/runner.d.ts +8 -0
- package/dist/exec/runner.js +26 -2
- package/dist/session/persist.d.ts +3 -0
- package/dist/session/persist.js +9 -0
- package/dist/session/store.js +2 -0
- package/dist/session/validate-session.js +34 -0
- package/dist/team/subagent.js +8 -0
- package/dist/ui/App.js +218 -116
- package/dist/ui/BottomStatusRow.d.ts +5 -10
- package/dist/ui/BottomStatusRow.js +7 -11
- package/dist/ui/Composer.d.ts +4 -2
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/Header.d.ts +6 -2
- package/dist/ui/Header.js +7 -6
- package/dist/ui/PromptInput.d.ts +3 -1
- package/dist/ui/PromptInput.js +32 -41
- package/dist/ui/StatusBar.d.ts +6 -0
- package/dist/ui/StatusBar.js +8 -7
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/entries/CompactionCard.d.ts +3 -1
- package/dist/ui/entries/CompactionCard.js +7 -5
- package/dist/ui/frame-differ.js +27 -0
- package/dist/ui/frame-observer.js +18 -5
- package/dist/ui/interaction-copy.d.ts +73 -73
- package/dist/ui/interaction-copy.js +30 -38
- package/dist/ui/interaction-hints.d.ts +8 -1
- package/dist/ui/interaction-hints.js +56 -69
- package/dist/ui/layout/AppShell.d.ts +6 -2
- package/dist/ui/layout/AppShell.js +2 -2
- package/dist/ui/layout/OverlayFrame.d.ts +2 -0
- package/dist/ui/layout/OverlayFrame.js +23 -14
- package/dist/ui/layout/ScrollViewport.d.ts +3 -1
- package/dist/ui/layout/ScrollViewport.js +10 -2
- package/dist/ui/layout/budget.d.ts +25 -26
- package/dist/ui/layout/budget.js +29 -22
- package/dist/ui/layout/document-layout.d.ts +2 -2
- package/dist/ui/layout/document-layout.js +2 -3
- package/dist/ui/layout/queue-layout.js +4 -4
- package/dist/ui/layout/status-detail-layout.d.ts +9 -0
- package/dist/ui/layout/status-detail-layout.js +56 -0
- package/dist/ui/layout/status-layout.d.ts +19 -2
- package/dist/ui/layout/status-layout.js +95 -84
- package/dist/ui/layout/wrap-rows.d.ts +2 -0
- package/dist/ui/layout/wrap-rows.js +21 -7
- package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
- package/dist/ui/overlays/ConfirmDialog.js +22 -3
- package/dist/ui/overlays/HelpOverlay.js +9 -2
- package/dist/ui/overlays/ModelPicker.d.ts +5 -8
- package/dist/ui/overlays/ModelPicker.js +44 -10
- package/dist/ui/overlays/ModelProfileEditor.js +2 -0
- package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
- package/dist/ui/overlays/ModelProfilePicker.js +4 -2
- package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
- package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
- package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
- package/dist/ui/overlays/QuestionOverlay.js +45 -32
- package/dist/ui/overlays/QueueOverlay.js +2 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
- package/dist/ui/overlays/SettingsScreen.js +10 -3
- package/dist/ui/run-metrics.d.ts +23 -0
- package/dist/ui/run-metrics.js +27 -0
- package/dist/ui/status-feedback.d.ts +33 -0
- package/dist/ui/status-feedback.js +36 -0
- package/dist/ui/stdout-frame-writer.js +28 -0
- package/package.json +2 -2
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { COMPACTION_LIMITS as LIMITS } from './limits.js';
|
|
2
|
+
/** Stable failure reason and short field path; never includes protected content. */
|
|
3
|
+
export class MemoryError extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
path;
|
|
6
|
+
constructor(reason, path = '$') {
|
|
7
|
+
super(`${reason}: ${path}`);
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
this.path = path;
|
|
10
|
+
this.name = 'MemoryError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function invalid(path) {
|
|
14
|
+
throw new MemoryError('invalid_memory_delta', path);
|
|
15
|
+
}
|
|
16
|
+
/** Parse bounded JSON, rejecting duplicate decoded keys before JSON.parse. */
|
|
17
|
+
export function parseStrictJson(text) {
|
|
18
|
+
if (text.length > LIMITS.memoryJsonChars)
|
|
19
|
+
invalid('$.length');
|
|
20
|
+
const stack = [];
|
|
21
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
22
|
+
const char = text[index];
|
|
23
|
+
if (char === '"') {
|
|
24
|
+
const start = index++;
|
|
25
|
+
while (index < text.length && text[index] !== '"') {
|
|
26
|
+
if (text[index] === '\\')
|
|
27
|
+
index += 1;
|
|
28
|
+
index += 1;
|
|
29
|
+
}
|
|
30
|
+
if (index >= text.length)
|
|
31
|
+
invalid('$.string');
|
|
32
|
+
let after = index + 1;
|
|
33
|
+
while (/\s/.test(text[after] ?? '') && after < text.length)
|
|
34
|
+
after += 1;
|
|
35
|
+
if (text[after] === ':')
|
|
36
|
+
checkKey(text.slice(start, index + 1), stack.at(-1));
|
|
37
|
+
}
|
|
38
|
+
else if (char === '{' || char === '[') {
|
|
39
|
+
stack.push(char === '{' ? new Set() : null);
|
|
40
|
+
if (stack.length > LIMITS.jsonDepth)
|
|
41
|
+
invalid('$.depth');
|
|
42
|
+
}
|
|
43
|
+
else if (char === '}' || char === ']') {
|
|
44
|
+
if (!stack.length)
|
|
45
|
+
invalid('$.nesting');
|
|
46
|
+
stack.pop();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return invalid('$.json');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function checkKey(token, keys) {
|
|
57
|
+
let key;
|
|
58
|
+
try {
|
|
59
|
+
key = JSON.parse(token);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return invalid('$.key');
|
|
63
|
+
}
|
|
64
|
+
if (!keys || keys.has(key))
|
|
65
|
+
invalid('$.duplicate_key');
|
|
66
|
+
keys.add(key);
|
|
67
|
+
}
|
|
68
|
+
function object(value, required, optional, path) {
|
|
69
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
70
|
+
invalid(path);
|
|
71
|
+
const record = value;
|
|
72
|
+
if (required.some((key) => !Object.hasOwn(record, key)))
|
|
73
|
+
invalid(path);
|
|
74
|
+
if (Object.keys(record).some((key) => !required.includes(key) && !optional.includes(key))) {
|
|
75
|
+
invalid(path);
|
|
76
|
+
}
|
|
77
|
+
return record;
|
|
78
|
+
}
|
|
79
|
+
function array(value, max, path) {
|
|
80
|
+
if (!Array.isArray(value) || value.length > max)
|
|
81
|
+
invalid(path);
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
function string(value, max, path) {
|
|
85
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max)
|
|
86
|
+
invalid(path);
|
|
87
|
+
}
|
|
88
|
+
function integer(value, positive, path) {
|
|
89
|
+
if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0))
|
|
90
|
+
invalid(path);
|
|
91
|
+
}
|
|
92
|
+
function id(value, kind, path) {
|
|
93
|
+
string(value, 64, path);
|
|
94
|
+
const match = new RegExp(`^g([1-9][0-9]*):${kind}([0-9]+)$`).exec(value);
|
|
95
|
+
if (!match)
|
|
96
|
+
invalid(path);
|
|
97
|
+
integer(Number(match[1]), true, path);
|
|
98
|
+
integer(Number(match[2]), false, path);
|
|
99
|
+
}
|
|
100
|
+
function source(value, path) {
|
|
101
|
+
const record = object(value, ['messageId', 'role', 'excerpt'], [], path);
|
|
102
|
+
id(record.messageId, 'm', `${path}.messageId`);
|
|
103
|
+
if (!['user', 'assistant', 'tool_result'].includes(record.role)) {
|
|
104
|
+
invalid(`${path}.role`);
|
|
105
|
+
}
|
|
106
|
+
string(record.excerpt, LIMITS.sourceExcerptChars, `${path}.excerpt`);
|
|
107
|
+
}
|
|
108
|
+
const SECTIONS = [
|
|
109
|
+
'global', 'decisions', 'files', 'facts', 'tasks', 'verification', 'pitfalls', 'next',
|
|
110
|
+
];
|
|
111
|
+
const STATUSES = ['pending', 'in_progress', 'blocked', 'done', 'cancelled'];
|
|
112
|
+
function item(value, stored, path) {
|
|
113
|
+
const record = object(value, ['section', 'text', 'sources', ...(stored ? ['id'] : [])], ['status', 'supersedes'], path);
|
|
114
|
+
if (stored)
|
|
115
|
+
id(record.id, 'i', `${path}.id`);
|
|
116
|
+
if (!SECTIONS.includes(record.section))
|
|
117
|
+
invalid(`${path}.section`);
|
|
118
|
+
string(record.text, LIMITS.itemTextChars, `${path}.text`);
|
|
119
|
+
const sources = array(record.sources, LIMITS.itemSources, `${path}.sources`);
|
|
120
|
+
if (!sources.length)
|
|
121
|
+
invalid(`${path}.sources`);
|
|
122
|
+
sources.forEach((entry, index) => source(entry, `${path}.sources[${index}]`));
|
|
123
|
+
if (record.section === 'tasks') {
|
|
124
|
+
if (!STATUSES.includes(record.status))
|
|
125
|
+
invalid(`${path}.status`);
|
|
126
|
+
}
|
|
127
|
+
else if (Object.hasOwn(record, 'status'))
|
|
128
|
+
invalid(`${path}.status`);
|
|
129
|
+
if (Object.hasOwn(record, 'supersedes'))
|
|
130
|
+
id(record.supersedes, 'i', `${path}.supersedes`);
|
|
131
|
+
}
|
|
132
|
+
/** Validate the model-owned schema only; throws MemoryError on invalid fields. */
|
|
133
|
+
export function validateDeltaSchema(value) {
|
|
134
|
+
const record = object(value, ['schemaVersion', 'additions'], [], '$');
|
|
135
|
+
if (record.schemaVersion !== 2)
|
|
136
|
+
invalid('$.schemaVersion');
|
|
137
|
+
array(record.additions, LIMITS.memoryAdditions, '$.additions').forEach((entry, index) => item(entry, false, `$.additions[${index}]`));
|
|
138
|
+
}
|
|
139
|
+
function protectedUser(value, path) {
|
|
140
|
+
const entry = object(value, ['id', 'message'], [], path);
|
|
141
|
+
id(entry.id, 'm', `${path}.id`);
|
|
142
|
+
const message = object(entry.message, ['role', 'content'], ['timestamp'], `${path}.message`);
|
|
143
|
+
if (message.role !== 'user')
|
|
144
|
+
invalid(`${path}.message.role`);
|
|
145
|
+
if (typeof message.content !== 'string') {
|
|
146
|
+
// Images must remain in the top-level anchor/tail, never in this ledger.
|
|
147
|
+
array(message.content, LIMITS.memoryJsonChars, `${path}.content`).forEach((part) => {
|
|
148
|
+
const record = object(part, ['type', 'text'], [], `${path}.content`);
|
|
149
|
+
if (record.type !== 'text' || typeof record.text !== 'string')
|
|
150
|
+
invalid(`${path}.content`);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (Object.hasOwn(message, 'timestamp') && !Number.isFinite(message.timestamp)) {
|
|
154
|
+
invalid(`${path}.timestamp`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function validateCoverage(value) {
|
|
158
|
+
const coverage = object(value, ['summarizedMessages', 'clippedToolResults', 'legacyIncomplete'], [], '$.coverage');
|
|
159
|
+
integer(coverage.summarizedMessages, false, '$.coverage.summarizedMessages');
|
|
160
|
+
if (typeof coverage.legacyIncomplete !== 'boolean')
|
|
161
|
+
invalid('$.coverage.legacyIncomplete');
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const clips = array(coverage.clippedToolResults, LIMITS.coverageClips, '$.coverage.clippedToolResults');
|
|
164
|
+
clips.forEach((value) => {
|
|
165
|
+
const clip = object(value, ['messageId', 'omittedChars'], [], '$.coverage.clip');
|
|
166
|
+
id(clip.messageId, 'm', '$.coverage.clip.messageId');
|
|
167
|
+
integer(clip.omittedChars, true, '$.coverage.clip.omittedChars');
|
|
168
|
+
if (seen.has(clip.messageId))
|
|
169
|
+
invalid('$.coverage.clip.duplicate');
|
|
170
|
+
seen.add(clip.messageId);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function validateStoredChains(memory) {
|
|
174
|
+
const entries = new Map();
|
|
175
|
+
const replaced = new Set();
|
|
176
|
+
for (const entry of memory.items) {
|
|
177
|
+
if (entries.has(entry.id) || Number(entry.id.slice(1).split(':')[0]) > memory.generation) {
|
|
178
|
+
invalid('$.items.id');
|
|
179
|
+
}
|
|
180
|
+
if (entry.supersedes) {
|
|
181
|
+
const previous = entries.get(entry.supersedes);
|
|
182
|
+
if (!previous || previous.section !== entry.section || replaced.has(previous.id)) {
|
|
183
|
+
invalid('$.items.supersedes');
|
|
184
|
+
}
|
|
185
|
+
replaced.add(previous.id);
|
|
186
|
+
}
|
|
187
|
+
entries.set(entry.id, entry);
|
|
188
|
+
}
|
|
189
|
+
const users = memory.userMessages.map((entry) => entry.id);
|
|
190
|
+
if (new Set(users).size !== users.length || users.includes(memory.originalTask.sourceId)) {
|
|
191
|
+
invalid('$.userMessages.id');
|
|
192
|
+
}
|
|
193
|
+
const references = [
|
|
194
|
+
memory.originalTask.sourceId,
|
|
195
|
+
...users,
|
|
196
|
+
...memory.items.flatMap((entry) => entry.sources.map((source) => source.messageId)),
|
|
197
|
+
...memory.coverage.clippedToolResults.map((clip) => clip.messageId),
|
|
198
|
+
];
|
|
199
|
+
if (references.some((reference) => Number(reference.slice(1).split(':')[0]) > memory.generation)) {
|
|
200
|
+
invalid('$.sources.generation');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Validate a persisted v2 body, including unique IDs and acyclic state chains. */
|
|
204
|
+
export function validateMemorySchema(value) {
|
|
205
|
+
const record = object(value, ['schemaVersion', 'generation', 'originalTask', 'userMessages', 'items', 'coverage'], ['legacySummary'], '$');
|
|
206
|
+
if (record.schemaVersion !== 2)
|
|
207
|
+
invalid('$.schemaVersion');
|
|
208
|
+
integer(record.generation, true, '$.generation');
|
|
209
|
+
const original = object(record.originalTask, ['sourceId', 'location'], [], '$.originalTask');
|
|
210
|
+
id(original.sourceId, 'm', '$.originalTask.sourceId');
|
|
211
|
+
if (original.location !== 'anchor')
|
|
212
|
+
invalid('$.originalTask.location');
|
|
213
|
+
array(record.userMessages, LIMITS.protectedUserMessages, '$.userMessages')
|
|
214
|
+
.forEach((entry, index) => protectedUser(entry, `$.userMessages[${index}]`));
|
|
215
|
+
array(record.items, LIMITS.memoryItems, '$.items')
|
|
216
|
+
.forEach((entry, index) => item(entry, true, `$.items[${index}]`));
|
|
217
|
+
if (Object.hasOwn(record, 'legacySummary')) {
|
|
218
|
+
string(record.legacySummary, LIMITS.memoryJsonChars, '$.legacySummary');
|
|
219
|
+
}
|
|
220
|
+
validateCoverage(record.coverage);
|
|
221
|
+
validateStoredChains(value);
|
|
222
|
+
}
|
|
223
|
+
function validateSource(reference, sourceMap, path) {
|
|
224
|
+
const original = sourceMap.get(reference.messageId);
|
|
225
|
+
if (!original || original.role !== reference.role)
|
|
226
|
+
invalid(`${path}.role`);
|
|
227
|
+
if (!original.visibleText.some((text) => text.includes(reference.excerpt))) {
|
|
228
|
+
invalid(`${path}.excerpt`);
|
|
229
|
+
}
|
|
230
|
+
return original;
|
|
231
|
+
}
|
|
232
|
+
function validateEvidence(entry, originals, path) {
|
|
233
|
+
const hasUser = originals.some((source) => source.role === 'user' && !source.isLegacyReference);
|
|
234
|
+
const hasToolResult = originals.some((source) => source.role === 'tool_result' && !!source.toolName);
|
|
235
|
+
const hasTool = originals.some((source) => source.role === 'tool_result' && source.isError !== true && !!source.toolName);
|
|
236
|
+
if (entry.status === 'done' && !hasUser && !hasTool)
|
|
237
|
+
invalid(`${path}.done_evidence`);
|
|
238
|
+
if (entry.status === 'cancelled' && !hasUser)
|
|
239
|
+
invalid(`${path}.cancelled_evidence`);
|
|
240
|
+
if (entry.section === 'verification' && !hasToolResult)
|
|
241
|
+
invalid(`${path}.verification_evidence`);
|
|
242
|
+
if (entry.supersedes && entry.section === 'global' && !hasUser)
|
|
243
|
+
invalid(`${path}.user_evidence`);
|
|
244
|
+
if (entry.status === 'done' && originals.some((source) => source.isError === true)) {
|
|
245
|
+
invalid(`${path}.failed_evidence`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Check current-source excerpts, completion evidence and append-only supersession. */
|
|
249
|
+
export function validateMemoryDelta(delta, context) {
|
|
250
|
+
validateDeltaSchema(delta);
|
|
251
|
+
if (!delta.additions.length && context.hasSummarizableMessages) {
|
|
252
|
+
throw new MemoryError('empty_memory_delta', '$.additions');
|
|
253
|
+
}
|
|
254
|
+
const prior = new Map(context.prior?.items.map((entry) => [entry.id, entry]) ?? []);
|
|
255
|
+
const replaced = new Set(context.prior?.items.flatMap((entry) => entry.supersedes ? [entry.supersedes] : []) ?? []);
|
|
256
|
+
delta.additions.forEach((entry, index) => {
|
|
257
|
+
const path = `$.additions[${index}]`;
|
|
258
|
+
const originals = entry.sources.map((reference) => validateSource(reference, context.sourceMap, `${path}.sources`));
|
|
259
|
+
validateEvidence(entry, originals, path);
|
|
260
|
+
if (!entry.supersedes)
|
|
261
|
+
return;
|
|
262
|
+
const previous = prior.get(entry.supersedes);
|
|
263
|
+
if (!previous || previous.section !== entry.section || replaced.has(previous.id)) {
|
|
264
|
+
invalid(`${path}.supersedes`);
|
|
265
|
+
}
|
|
266
|
+
replaced.add(previous.id);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Host-owned append-only conversation memory; no I/O or model state. */
|
|
2
|
+
import type { Message, UserMessage } from '@aragon-agent/core';
|
|
3
|
+
import type { PreparedMemoryInput } from './memory-input.js';
|
|
4
|
+
export { MemoryError } from './memory-validation.js';
|
|
5
|
+
export interface SourceRef {
|
|
6
|
+
messageId: string;
|
|
7
|
+
role: 'user' | 'assistant' | 'tool_result';
|
|
8
|
+
excerpt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface MemoryItem {
|
|
11
|
+
id: string;
|
|
12
|
+
section: 'global' | 'decisions' | 'files' | 'facts' | 'tasks' | 'verification' | 'pitfalls' | 'next';
|
|
13
|
+
text: string;
|
|
14
|
+
sources: SourceRef[];
|
|
15
|
+
status?: 'pending' | 'in_progress' | 'blocked' | 'done' | 'cancelled';
|
|
16
|
+
supersedes?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ProtectedUserMessage {
|
|
19
|
+
id: string;
|
|
20
|
+
message: UserMessage;
|
|
21
|
+
}
|
|
22
|
+
export interface CompactionMemory {
|
|
23
|
+
schemaVersion: 2;
|
|
24
|
+
generation: number;
|
|
25
|
+
originalTask: {
|
|
26
|
+
sourceId: string;
|
|
27
|
+
location: 'anchor';
|
|
28
|
+
};
|
|
29
|
+
userMessages: ProtectedUserMessage[];
|
|
30
|
+
items: MemoryItem[];
|
|
31
|
+
legacySummary?: string;
|
|
32
|
+
coverage: {
|
|
33
|
+
summarizedMessages: number;
|
|
34
|
+
clippedToolResults: Array<{
|
|
35
|
+
messageId: string;
|
|
36
|
+
omittedChars: number;
|
|
37
|
+
}>;
|
|
38
|
+
legacyIncomplete: boolean;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export interface MemoryDelta {
|
|
42
|
+
schemaVersion: 2;
|
|
43
|
+
additions: Array<Omit<MemoryItem, 'id'>>;
|
|
44
|
+
}
|
|
45
|
+
export interface MemorySource {
|
|
46
|
+
role: Message['role'];
|
|
47
|
+
visibleText: string[];
|
|
48
|
+
toolCallId?: string;
|
|
49
|
+
toolName?: string;
|
|
50
|
+
isError?: boolean;
|
|
51
|
+
isLegacyReference?: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface MemoryCoverage {
|
|
54
|
+
summarizedMessages: number;
|
|
55
|
+
clippedToolResults: Array<{
|
|
56
|
+
messageId: string;
|
|
57
|
+
omittedChars: number;
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
/** Recognize a legacy reference without excluding it from protected user content. */
|
|
61
|
+
export declare function isLegacyMemoryText(message: Message): boolean;
|
|
62
|
+
/** Parse the sole permitted model response; throws MemoryError on invalid input. */
|
|
63
|
+
export declare function parseMemoryDelta(text: string): MemoryDelta;
|
|
64
|
+
/** Parse a complete persisted body without inferring host identity from its shape. */
|
|
65
|
+
export declare function parseMemory(text: string): CompactionMemory;
|
|
66
|
+
/** Canonical UserMessage field order for protected budgets and prefix hashes. */
|
|
67
|
+
export declare function canonicalUserMessage(message: UserMessage): UserMessage;
|
|
68
|
+
/** Serialize fixed schema order and escaped delimiters; throws on final text budget. */
|
|
69
|
+
export declare function serializeMemory(memory: CompactionMemory): string;
|
|
70
|
+
/** Enforce the combined protected budget using complete serialized content. */
|
|
71
|
+
export declare function assertProtectedMemoryBudget(anchor: UserMessage, memory: CompactionMemory): void;
|
|
72
|
+
export interface MergeMemoryInput {
|
|
73
|
+
input: PreparedMemoryInput;
|
|
74
|
+
delta: MemoryDelta;
|
|
75
|
+
sourceMap: ReadonlyMap<string, MemorySource>;
|
|
76
|
+
coverage: MemoryCoverage;
|
|
77
|
+
}
|
|
78
|
+
/** Create a staged ledger; prior items and users are retained without rewriting. */
|
|
79
|
+
export declare function mergeMemory(options: MergeMemoryInput): CompactionMemory;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { COMPACTION_LIMITS as LIMITS } from './limits.js';
|
|
2
|
+
import { MemoryError, parseStrictJson, validateDeltaSchema, validateMemoryDelta, validateMemorySchema, } from './memory-validation.js';
|
|
3
|
+
export { MemoryError } from './memory-validation.js';
|
|
4
|
+
/** Recognize a legacy reference without excluding it from protected user content. */
|
|
5
|
+
export function isLegacyMemoryText(message) {
|
|
6
|
+
const legacyPattern = new RegExp('^<compacted_context version="v1-2026-08"(?: [a-z]+="[^"]*")*>'
|
|
7
|
+
+ '\\n[\\s\\S]*\\n</compacted_context>$');
|
|
8
|
+
return message.role === 'user' && typeof message.content === 'string'
|
|
9
|
+
&& legacyPattern.test(message.content);
|
|
10
|
+
}
|
|
11
|
+
/** Parse the sole permitted model response; throws MemoryError on invalid input. */
|
|
12
|
+
export function parseMemoryDelta(text) {
|
|
13
|
+
const value = parseStrictJson(text);
|
|
14
|
+
validateDeltaSchema(value);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/** Parse a complete persisted body without inferring host identity from its shape. */
|
|
18
|
+
export function parseMemory(text) {
|
|
19
|
+
const value = parseStrictJson(text);
|
|
20
|
+
validateMemorySchema(value);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/** Canonical UserMessage field order for protected budgets and prefix hashes. */
|
|
24
|
+
export function canonicalUserMessage(message) {
|
|
25
|
+
return {
|
|
26
|
+
role: 'user',
|
|
27
|
+
content: typeof message.content === 'string'
|
|
28
|
+
? message.content
|
|
29
|
+
: message.content.map((part) => part.type === 'text'
|
|
30
|
+
? { type: 'text', text: part.text }
|
|
31
|
+
: { type: 'image', mediaType: part.mediaType, data: part.data }),
|
|
32
|
+
...(message.timestamp !== undefined ? { timestamp: message.timestamp } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Serialize fixed schema order and escaped delimiters; throws on final text budget. */
|
|
36
|
+
export function serializeMemory(memory) {
|
|
37
|
+
validateMemorySchema(memory);
|
|
38
|
+
const ordered = {
|
|
39
|
+
schemaVersion: 2,
|
|
40
|
+
generation: memory.generation,
|
|
41
|
+
originalTask: { sourceId: memory.originalTask.sourceId, location: 'anchor' },
|
|
42
|
+
userMessages: memory.userMessages.map((entry) => ({
|
|
43
|
+
id: entry.id,
|
|
44
|
+
message: canonicalUserMessage(entry.message),
|
|
45
|
+
})),
|
|
46
|
+
items: memory.items.map((entry) => ({
|
|
47
|
+
id: entry.id,
|
|
48
|
+
section: entry.section,
|
|
49
|
+
text: entry.text,
|
|
50
|
+
sources: entry.sources.map((source) => ({
|
|
51
|
+
messageId: source.messageId,
|
|
52
|
+
role: source.role,
|
|
53
|
+
excerpt: source.excerpt,
|
|
54
|
+
})),
|
|
55
|
+
...(entry.status !== undefined ? { status: entry.status } : {}),
|
|
56
|
+
...(entry.supersedes !== undefined ? { supersedes: entry.supersedes } : {}),
|
|
57
|
+
})),
|
|
58
|
+
...(memory.legacySummary !== undefined ? { legacySummary: memory.legacySummary } : {}),
|
|
59
|
+
coverage: {
|
|
60
|
+
summarizedMessages: memory.coverage.summarizedMessages,
|
|
61
|
+
clippedToolResults: memory.coverage.clippedToolResults.map((clip) => ({
|
|
62
|
+
messageId: clip.messageId,
|
|
63
|
+
omittedChars: clip.omittedChars,
|
|
64
|
+
})),
|
|
65
|
+
legacyIncomplete: memory.coverage.legacyIncomplete,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const text = JSON.stringify(ordered, null, 2).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
69
|
+
if (text.length > LIMITS.memoryJsonChars) {
|
|
70
|
+
throw new MemoryError('protected_memory_too_large', '$.serialized');
|
|
71
|
+
}
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
/** Enforce the combined protected budget using complete serialized content. */
|
|
75
|
+
export function assertProtectedMemoryBudget(anchor, memory) {
|
|
76
|
+
const length = JSON.stringify(canonicalUserMessage(anchor)).length
|
|
77
|
+
+ serializeMemory(memory).length;
|
|
78
|
+
if (length > LIMITS.protectedMemoryChars) {
|
|
79
|
+
throw new MemoryError('protected_memory_too_large', '$.anchor');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Create a staged ledger; prior items and users are retained without rewriting. */
|
|
83
|
+
export function mergeMemory(options) {
|
|
84
|
+
const { input, delta, sourceMap, coverage } = options;
|
|
85
|
+
validateMemoryDelta(delta, {
|
|
86
|
+
sourceMap,
|
|
87
|
+
prior: input.prior,
|
|
88
|
+
hasSummarizableMessages: coverage.summarizedMessages > 0,
|
|
89
|
+
});
|
|
90
|
+
const items = [
|
|
91
|
+
...(input.prior?.items ?? []),
|
|
92
|
+
...delta.additions.map((entry, index) => ({ id: `g${input.generation}:i${index}`, ...entry })),
|
|
93
|
+
];
|
|
94
|
+
const clips = [
|
|
95
|
+
...(input.prior?.coverage.clippedToolResults ?? []),
|
|
96
|
+
...coverage.clippedToolResults,
|
|
97
|
+
];
|
|
98
|
+
if (items.length > LIMITS.memoryItems ||
|
|
99
|
+
input.userMessages.length > LIMITS.protectedUserMessages ||
|
|
100
|
+
clips.length > LIMITS.coverageClips) {
|
|
101
|
+
throw new MemoryError('protected_memory_too_large', '$.counts');
|
|
102
|
+
}
|
|
103
|
+
const memory = {
|
|
104
|
+
schemaVersion: 2,
|
|
105
|
+
generation: input.generation,
|
|
106
|
+
originalTask: { sourceId: input.originalTaskSourceId, location: 'anchor' },
|
|
107
|
+
userMessages: [...input.userMessages],
|
|
108
|
+
items,
|
|
109
|
+
...(input.legacySummary !== undefined ? { legacySummary: input.legacySummary } : {}),
|
|
110
|
+
coverage: {
|
|
111
|
+
summarizedMessages: (input.prior?.coverage.summarizedMessages ?? 0) + coverage.summarizedMessages,
|
|
112
|
+
clippedToolResults: clips,
|
|
113
|
+
legacyIncomplete: !!input.prior?.coverage.legacyIncomplete || input.legacySummary !== undefined,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
assertProtectedMemoryBudget(input.anchor, memory);
|
|
117
|
+
return memory;
|
|
118
|
+
}
|
|
@@ -62,6 +62,7 @@ export interface ContextMeterDeps {
|
|
|
62
62
|
* measurement, so a live settings edit moves the denominator without a relaunch.
|
|
63
63
|
*/
|
|
64
64
|
getWindowOverride(): number | null;
|
|
65
|
+
getRequestVersion?(): number;
|
|
65
66
|
}
|
|
66
67
|
export type ContextUsageListener = (usage: ContextUsageSnapshot) => void;
|
|
67
68
|
/**
|
|
@@ -102,6 +103,13 @@ export declare class ContextMeter {
|
|
|
102
103
|
private last;
|
|
103
104
|
/** Whether the history has changed since the last measurement. */
|
|
104
105
|
private dirty;
|
|
106
|
+
private prefixLast;
|
|
107
|
+
private sampledPrompt;
|
|
108
|
+
private sampledVersion;
|
|
109
|
+
private publishedLength;
|
|
110
|
+
private publishedLast;
|
|
111
|
+
private publishedPrompt;
|
|
112
|
+
private publishedVersion;
|
|
105
113
|
constructor(deps: ContextMeterDeps);
|
|
106
114
|
/**
|
|
107
115
|
* Occupancy right now. FOR THE SCREEN.
|
|
@@ -130,13 +138,8 @@ export declare class ContextMeter {
|
|
|
130
138
|
lastPublished(): Pressure | null;
|
|
131
139
|
/** The published projection, or a zeroed one before the first measurement. */
|
|
132
140
|
currentUsage(): ContextUsageSnapshot;
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
*
|
|
136
|
-
* `Compactor.shouldCompact` has a `CompactionProbe.lastUsage` the meter may
|
|
137
|
-
* not have seen yet, so it supplies it rather than trusting the field here.
|
|
138
|
-
*/
|
|
139
|
-
measureWith(lastUsage: TokenUsage | undefined): Pressure;
|
|
141
|
+
/** Compatibility reads cannot establish or resurrect a provider sample. */
|
|
142
|
+
measureWith(_lastUsage: TokenUsage | undefined): Pressure;
|
|
140
143
|
/**
|
|
141
144
|
* `turn_end` - the one moment in the whole flow that is FULLY measured.
|
|
142
145
|
*
|
|
@@ -175,13 +178,13 @@ export declare class ContextMeter {
|
|
|
175
178
|
*/
|
|
176
179
|
onHistoryReplaced(): void;
|
|
177
180
|
/**
|
|
178
|
-
* Only the
|
|
181
|
+
* Only the denominator moved (window override or model metadata refresh).
|
|
179
182
|
*
|
|
180
183
|
* DELIBERATELY NOT A RESET. The measured base is still a true statement about
|
|
181
184
|
* the history, so throwing it away would trade a correct numerator for a whole
|
|
182
185
|
* history estimate and produce a visible jump on a common operation. The
|
|
183
|
-
*
|
|
184
|
-
*
|
|
186
|
+
* Changing the model connection or request environment requires the deep
|
|
187
|
+
* invalidation path instead; its measurement is no longer transferable.
|
|
185
188
|
*/
|
|
186
189
|
onWindowChanged(): void;
|
|
187
190
|
/**
|
|
@@ -234,6 +237,7 @@ export declare class ContextMeter {
|
|
|
234
237
|
* is cached" cannot drift, which is what makes `lastPublished()` a meaningful
|
|
235
238
|
* answer rather than a stale one.
|
|
236
239
|
*/
|
|
240
|
+
private refreshValidity;
|
|
237
241
|
private measureAndPublish;
|
|
238
242
|
private compute;
|
|
239
243
|
/**
|
package/dist/compaction/meter.js
CHANGED
|
@@ -103,6 +103,13 @@ export class ContextMeter {
|
|
|
103
103
|
last = null;
|
|
104
104
|
/** Whether the history has changed since the last measurement. */
|
|
105
105
|
dirty = true;
|
|
106
|
+
prefixLast;
|
|
107
|
+
sampledPrompt;
|
|
108
|
+
sampledVersion;
|
|
109
|
+
publishedLength = -1;
|
|
110
|
+
publishedLast;
|
|
111
|
+
publishedPrompt;
|
|
112
|
+
publishedVersion;
|
|
106
113
|
constructor(deps) {
|
|
107
114
|
this.deps = deps;
|
|
108
115
|
}
|
|
@@ -128,8 +135,7 @@ export class ContextMeter {
|
|
|
128
135
|
// precisely the symptom. `estimateAppendedTokens` bounds-checks too, but it
|
|
129
136
|
// only zeroes the DELTA and keeps the wrong measured base, so that belt does
|
|
130
137
|
// not cover this.
|
|
131
|
-
|
|
132
|
-
this.dirty = true;
|
|
138
|
+
this.refreshValidity();
|
|
133
139
|
if (!this.dirty && this.last)
|
|
134
140
|
return this.last;
|
|
135
141
|
return this.measureAndPublish(this.lastUsage);
|
|
@@ -154,16 +160,9 @@ export class ContextMeter {
|
|
|
154
160
|
// =========================================================================
|
|
155
161
|
// Writing
|
|
156
162
|
// =========================================================================
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
* `Compactor.shouldCompact` has a `CompactionProbe.lastUsage` the meter may
|
|
161
|
-
* not have seen yet, so it supplies it rather than trusting the field here.
|
|
162
|
-
*/
|
|
163
|
-
measureWith(lastUsage) {
|
|
164
|
-
if (this.discardStalePrefix())
|
|
165
|
-
this.dirty = true;
|
|
166
|
-
return this.measureAndPublish(lastUsage);
|
|
163
|
+
/** Compatibility reads cannot establish or resurrect a provider sample. */
|
|
164
|
+
measureWith(_lastUsage) {
|
|
165
|
+
return this.current();
|
|
167
166
|
}
|
|
168
167
|
/**
|
|
169
168
|
* `turn_end` - the one moment in the whole flow that is FULLY measured.
|
|
@@ -180,6 +179,9 @@ export class ContextMeter {
|
|
|
180
179
|
this.lastUsage = usage;
|
|
181
180
|
this.estimateOffset = computeEstimateOffset(usage, messages, systemPrompt);
|
|
182
181
|
this.measuredPrefixLength = messages.length;
|
|
182
|
+
this.prefixLast = messages[messages.length - 1];
|
|
183
|
+
this.sampledPrompt = systemPrompt;
|
|
184
|
+
this.sampledVersion = this.deps.getRequestVersion?.() ?? 0;
|
|
183
185
|
this.measureAndPublish(usage);
|
|
184
186
|
}
|
|
185
187
|
/**
|
|
@@ -199,6 +201,7 @@ export class ContextMeter {
|
|
|
199
201
|
onHistorySpliced() {
|
|
200
202
|
this.lastUsage = undefined;
|
|
201
203
|
this.measuredPrefixLength = undefined;
|
|
204
|
+
this.prefixLast = undefined;
|
|
202
205
|
if (this.dirty)
|
|
203
206
|
return;
|
|
204
207
|
this.dirty = true;
|
|
@@ -218,22 +221,25 @@ export class ContextMeter {
|
|
|
218
221
|
onHistoryReplaced() {
|
|
219
222
|
this.lastUsage = undefined;
|
|
220
223
|
this.measuredPrefixLength = undefined;
|
|
224
|
+
this.prefixLast = undefined;
|
|
221
225
|
this.estimateOffset = undefined;
|
|
226
|
+
this.sampledPrompt = undefined;
|
|
227
|
+
this.sampledVersion = undefined;
|
|
222
228
|
this.dirty = true;
|
|
223
229
|
this.scheduleTick();
|
|
224
230
|
}
|
|
225
231
|
/**
|
|
226
|
-
* Only the
|
|
232
|
+
* Only the denominator moved (window override or model metadata refresh).
|
|
227
233
|
*
|
|
228
234
|
* DELIBERATELY NOT A RESET. The measured base is still a true statement about
|
|
229
235
|
* the history, so throwing it away would trade a correct numerator for a whole
|
|
230
236
|
* history estimate and produce a visible jump on a common operation. The
|
|
231
|
-
*
|
|
232
|
-
*
|
|
237
|
+
* Changing the model connection or request environment requires the deep
|
|
238
|
+
* invalidation path instead; its measurement is no longer transferable.
|
|
233
239
|
*/
|
|
234
240
|
onWindowChanged() {
|
|
235
241
|
this.dirty = true;
|
|
236
|
-
this.
|
|
242
|
+
this.current();
|
|
237
243
|
}
|
|
238
244
|
/**
|
|
239
245
|
* Something was appended. Mark dirty, arrange a throttled publish, MEASURE
|
|
@@ -255,7 +261,7 @@ export class ContextMeter {
|
|
|
255
261
|
this.timer = null;
|
|
256
262
|
if (!this.dirty || this.disposed)
|
|
257
263
|
return;
|
|
258
|
-
this.
|
|
264
|
+
this.current();
|
|
259
265
|
}, CONTEXT_METER_TICK_MS);
|
|
260
266
|
// The other half of I-11: an armed tick must not keep the event loop alive.
|
|
261
267
|
const unrefable = handle;
|
|
@@ -353,11 +359,13 @@ export class ContextMeter {
|
|
|
353
359
|
discardStalePrefix() {
|
|
354
360
|
if (this.measuredPrefixLength === undefined)
|
|
355
361
|
return false;
|
|
356
|
-
const
|
|
357
|
-
if (length >= this.measuredPrefixLength
|
|
362
|
+
const messages = this.deps.getMessages();
|
|
363
|
+
if (messages.length >= this.measuredPrefixLength &&
|
|
364
|
+
messages[this.measuredPrefixLength - 1] === this.prefixLast)
|
|
358
365
|
return false;
|
|
359
366
|
this.lastUsage = undefined;
|
|
360
367
|
this.measuredPrefixLength = undefined;
|
|
368
|
+
this.prefixLast = undefined;
|
|
361
369
|
return true;
|
|
362
370
|
}
|
|
363
371
|
/**
|
|
@@ -367,8 +375,34 @@ export class ContextMeter {
|
|
|
367
375
|
* is cached" cannot drift, which is what makes `lastPublished()` a meaningful
|
|
368
376
|
* answer rather than a stale one.
|
|
369
377
|
*/
|
|
378
|
+
refreshValidity() {
|
|
379
|
+
const messages = this.deps.getMessages();
|
|
380
|
+
const prompt = this.readSystemPrompt();
|
|
381
|
+
const version = this.deps.getRequestVersion?.() ?? 0;
|
|
382
|
+
if (this.sampledPrompt !== undefined &&
|
|
383
|
+
(prompt !== this.sampledPrompt || version !== this.sampledVersion)) {
|
|
384
|
+
this.onHistoryReplaced();
|
|
385
|
+
}
|
|
386
|
+
if (this.discardStalePrefix())
|
|
387
|
+
this.dirty = true;
|
|
388
|
+
const window = this.resolveWindow();
|
|
389
|
+
const last = this.last;
|
|
390
|
+
if (messages.length !== this.publishedLength ||
|
|
391
|
+
messages[messages.length - 1] !== this.publishedLast ||
|
|
392
|
+
prompt !== this.publishedPrompt || version !== this.publishedVersion ||
|
|
393
|
+
window.contextWindow !== last?.contextWindow ||
|
|
394
|
+
window.windowKnown !== last?.windowKnown ||
|
|
395
|
+
window.windowOverridden !== last?.windowOverridden ||
|
|
396
|
+
this.deps.getModelInfo().contextWindowSource !== last?.windowSource)
|
|
397
|
+
this.dirty = true;
|
|
398
|
+
}
|
|
370
399
|
measureAndPublish(lastUsage) {
|
|
371
400
|
const pressure = this.compute(lastUsage);
|
|
401
|
+
const messages = this.deps.getMessages();
|
|
402
|
+
this.publishedLength = messages.length;
|
|
403
|
+
this.publishedLast = messages[messages.length - 1];
|
|
404
|
+
this.publishedPrompt = this.readSystemPrompt();
|
|
405
|
+
this.publishedVersion = this.deps.getRequestVersion?.() ?? 0;
|
|
372
406
|
this.last = pressure;
|
|
373
407
|
this.dirty = false;
|
|
374
408
|
this.publish(pressure);
|