bare-agent 0.16.2 → 0.19.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/README.md +37 -31
- package/bareagent.context.md +61 -2
- package/index.d.ts +7 -1
- package/index.js +12 -1
- package/package.json +9 -6
- package/src/bareguard-adapter.js +22 -4
- package/src/complexity.d.ts +12 -0
- package/src/complexity.js +33 -4
- package/src/evaluator.d.ts +147 -0
- package/src/evaluator.js +270 -0
- package/src/loop.d.ts +75 -4
- package/src/loop.js +233 -44
- package/src/memory.d.ts +10 -3
- package/src/memory.js +15 -4
- package/src/provider-anthropic.d.ts +13 -0
- package/src/provider-anthropic.js +36 -1
- package/src/provider-gemini.d.ts +70 -0
- package/src/provider-gemini.js +197 -0
- package/src/provider-openai.d.ts +10 -0
- package/src/provider-openai.js +20 -4
- package/src/providers.d.ts +2 -1
- package/src/providers.js +3 -0
- package/src/refine.d.ts +86 -0
- package/src/refine.js +65 -0
- package/src/remember.d.ts +118 -0
- package/src/remember.js +163 -0
- package/src/skills.d.ts +71 -0
- package/src/skills.js +200 -0
- package/src/stash.d.ts +44 -0
- package/src/stash.js +342 -0
- package/types/index.d.ts +57 -1
package/src/stash.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Stash — the eval-assist F2 reference skill (PRD §2.8–2.14). Compaction-first context hygiene: fold a
|
|
4
|
+
// FINISHED sub-task out of the live transcript to stay under a budget within one run, restorable verbatim.
|
|
5
|
+
//
|
|
6
|
+
// Shape: `createStashSkill(options)` returns `{ skill, trim }`. Register `skill` into a SkillRegistry
|
|
7
|
+
// (progressive disclosure, src/skills.js) and wire `trim` into `new Loop({ trim })`. The three tools
|
|
8
|
+
// (stash_checkpoint / stash_compact / stash_restore) are auto-prefixed by the registry.
|
|
9
|
+
//
|
|
10
|
+
// THE LOAD-BEARING DESIGN (validated by poc/f2-stash-fold.mjs against a real Loop + real litectx 0.16.0):
|
|
11
|
+
// • Tool execute() receives ARGS ONLY — no `msgs`, no `ctx` (loop.js:676) — and the Loop runs on a COPY
|
|
12
|
+
// of the caller's messages (loop.js:305). So a tool CANNOT touch the live transcript directly. The
|
|
13
|
+
// tools QUEUE INTENT; the work happens in `trim(msgs, ctx)` (loop.js:487) — the one seam that receives
|
|
14
|
+
// the live canonical transcript + ctx and may mutate it in place.
|
|
15
|
+
// • Deferring the fold to trim is not just convenient, it is CORRECT: a synchronous fold inside execute()
|
|
16
|
+
// would capture the triggering stash_compact's own assistant-tool_calls message but NOT yet its
|
|
17
|
+
// tool-result (the Loop appends that after execute returns) — orphaning its own pair. trim runs at a
|
|
18
|
+
// clean ROUND BOUNDARY, so every fold spans whole rounds.
|
|
19
|
+
// • Anchor = an identity REFERENCE to the existing boundary message at checkpoint time — NOT an injected
|
|
20
|
+
// marker (a `system` marker is hoisted out of position + clobbers the system prompt on Anthropic,
|
|
21
|
+
// provider-anthropic.js:59).
|
|
22
|
+
// • TWO transcript invariants must survive a fold or real providers reject the next call: (1) every
|
|
23
|
+
// assistant tool_call has its tool_result and vice-versa; (2) strict user/assistant alternation after
|
|
24
|
+
// Anthropic normalization (provider-anthropic.js:127 does NO consecutive-role merging). The fold EVICTS
|
|
25
|
+
// whole rounds (preserves both) and, where it leaves an inline note, injects it as a self-contained
|
|
26
|
+
// `assistant(tool_call) + tool(result)` PAIR — pairing-safe and alternation-safe by construction (a
|
|
27
|
+
// bare user/assistant note would break alternation).
|
|
28
|
+
//
|
|
29
|
+
// Two strategies (D10): 'stash' (lossless — verbatim parked to litectx's stash table or an in-process Map,
|
|
30
|
+
// restorable byte-exact) and 'summarize' (lossy — `ctx.summarize` folds the span into an inline gist; the
|
|
31
|
+
// detail is NOT retained, the smallest footprint). The note pair carries the gist ('summarize') or a
|
|
32
|
+
// "restorable" breadcrumb ('stash') so the model is never left blind about what it just folded. The stance
|
|
33
|
+
// side (D13) writes a litectx `episode` via `remember` when a `reason` is given. Governance is unchanged:
|
|
34
|
+
// stash tools flow through the same `Loop({ policy })` chokepoint as any tool.
|
|
35
|
+
|
|
36
|
+
const { ToolError } = require('./errors');
|
|
37
|
+
|
|
38
|
+
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
39
|
+
|
|
40
|
+
const DEFAULT_KEY_PREFIX = 'stash:';
|
|
41
|
+
const DEFAULT_MAX_LABELS = 128;
|
|
42
|
+
const STRATEGIES = new Set(['stash', 'summarize']);
|
|
43
|
+
|
|
44
|
+
const ARG_SCHEMA = {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
label: { type: 'string', description: 'The sub-task label that brackets the span (set at checkpoint).' },
|
|
48
|
+
strategy: { type: 'string', description: "Compaction strategy: 'summarize' (lossy gist) or 'stash' (lossless, restorable verbatim)." },
|
|
49
|
+
reason: { type: 'string', description: 'Short note on what the sub-task accomplished (recorded as the durable stance).' },
|
|
50
|
+
},
|
|
51
|
+
required: ['label'],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const INSTRUCTIONS =
|
|
55
|
+
'Keep the working context lean by folding FINISHED sub-tasks out of it:\n'
|
|
56
|
+
+ '• stash_checkpoint({ label }) at the START of a self-contained sub-task — plants a labeled anchor.\n'
|
|
57
|
+
+ '• stash_compact({ label, reason, strategy }) once that sub-task is DONE and you will not need its '
|
|
58
|
+
+ 'step-by-step detail inline again — folds everything since the checkpoint out of context, leaving a '
|
|
59
|
+
+ "short note. strategy 'summarize' (default) keeps a gist and drops the detail; 'stash' keeps the detail "
|
|
60
|
+
+ 'restorable verbatim. Pass a short `reason` describing the outcome.\n'
|
|
61
|
+
+ "• stash_restore({ label }) to bring a 'stash'-compacted sub-task back verbatim (a 'summarize'd one "
|
|
62
|
+
+ 'cannot be restored — its detail was not retained).\n'
|
|
63
|
+
+ 'Never compact work that is still in progress.';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the stash reference skill + the trim function that executes its folds.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} [options]
|
|
69
|
+
* @param {'summarize'|'stash'} [options.defaultStrategy='summarize'] - Strategy when the model omits one.
|
|
70
|
+
* 'summarize' (OQ5 lean / §2.11) is lossy; 'stash' is lossless. 'summarize' degrades to a lossless park
|
|
71
|
+
* when no `ctx.summarize` is wired (loud, never a silent detail-loss).
|
|
72
|
+
* @param {string} [options.keyPrefix='stash:'] - Namespace for stash/episode ids.
|
|
73
|
+
* @param {number} [options.maxLabels=128] - LRU backstop on DISTINCT live labels (§2.13) — visible, not silent.
|
|
74
|
+
* @param {Object} [options.compaction] - AUTOMATIC token-pressure trigger (§2.11/D12, all bareagent — a
|
|
75
|
+
* housekeeping threshold, NEVER a bareguard halt bound). Opt-in: omit, or omit `ceilingTokens`, → OFF
|
|
76
|
+
* (no guessed model→window table). Fires on the NEXT round's trim when the Loop's measured
|
|
77
|
+
* `ctx.usage.inputTokens / ceilingTokens > triggerAt`.
|
|
78
|
+
* @param {number} [options.compaction.ceilingTokens] - OPERATOR-SET context ceiling (enables auto-trigger).
|
|
79
|
+
* @param {number} [options.compaction.triggerAt=0.7] - Fraction of the ceiling that fires a fold.
|
|
80
|
+
* @param {'summarize'|'stash'} [options.compaction.strategy] - Strategy for auto-folds (default: defaultStrategy).
|
|
81
|
+
* @param {number} [options.compaction.keepHeadTurns=1] - Recent turns to keep at the START (initial context).
|
|
82
|
+
* @param {number} [options.compaction.keepRecentTurns=3] - Recent turns to keep at the END (live working set).
|
|
83
|
+
* @param {(msg: string) => void} [options.onNote=console.warn] - Sink for the loud one-time/backstop notes.
|
|
84
|
+
* @returns {{ skill: { name: string, description: string, instructions: string, tools: ToolDef[] }, trim: (msgs: any[], ctx: any) => Promise<any[]>, restoreHandles: () => string[] }}
|
|
85
|
+
*/
|
|
86
|
+
function createStashSkill(options = {}) {
|
|
87
|
+
const {
|
|
88
|
+
defaultStrategy = 'summarize',
|
|
89
|
+
keyPrefix = DEFAULT_KEY_PREFIX,
|
|
90
|
+
maxLabels = DEFAULT_MAX_LABELS,
|
|
91
|
+
compaction = null,
|
|
92
|
+
onNote = (/** @type {string} */ msg) => console.warn(msg),
|
|
93
|
+
} = options;
|
|
94
|
+
const auto = compaction && compaction.ceilingTokens
|
|
95
|
+
? { ceilingTokens: compaction.ceilingTokens, triggerAt: compaction.triggerAt ?? 0.7, strategy: compaction.strategy || defaultStrategy, keepHeadTurns: compaction.keepHeadTurns ?? 1, keepRecentTurns: compaction.keepRecentTurns ?? 3 }
|
|
96
|
+
: null;
|
|
97
|
+
let autoSeq = 0;
|
|
98
|
+
|
|
99
|
+
/** @type {Map<string, any>} label → identity-ref to the boundary message (the anchor). */
|
|
100
|
+
const anchors = new Map();
|
|
101
|
+
/** @type {Map<string, { backend: 'ctx'|'local'|'summary', id: string }>} label → restore handle. */
|
|
102
|
+
const parked = new Map();
|
|
103
|
+
/** @type {Map<string, string>} id → parked JSON, the in-process lossless backend (no litectx). */
|
|
104
|
+
const local = new Map();
|
|
105
|
+
/** @type {Array<{ action: string, label: string, strategy?: string, reason?: string }>} intent queue. */
|
|
106
|
+
const pending = [];
|
|
107
|
+
let warnedNoStash = false;
|
|
108
|
+
let noteSeq = 0;
|
|
109
|
+
|
|
110
|
+
const idFor = (/** @type {string} */ label) => `${keyPrefix}${label}`;
|
|
111
|
+
const hasCheckpoint = (/** @type {string} */ label) =>
|
|
112
|
+
anchors.has(label) || pending.some(p => p.action === 'checkpoint' && p.label === label);
|
|
113
|
+
|
|
114
|
+
const requireLabel = (/** @type {string} */ label, /** @type {string} */ tool) => {
|
|
115
|
+
if (typeof label !== 'string' || !label) throw new ToolError(`[${tool}] requires a non-empty string "label".`);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// A provider-safe inline note: a self-contained assistant(tool_call)+tool(result) PAIR. Bare user/
|
|
119
|
+
// assistant notes break Anthropic's strict alternation; this pair satisfies both pairing and alternation
|
|
120
|
+
// by construction when it follows a tool-result (user) boundary — which the checkpoint anchor always is.
|
|
121
|
+
const noteMessages = (/** @type {string} */ text) => {
|
|
122
|
+
// The id goes ON THE WIRE as a tool_use.id — Anthropic enforces ^[a-zA-Z0-9_-]+$ on it (live-POC
|
|
123
|
+
// caught a colon'd id being rejected). Keep it in-charset; do NOT reuse keyPrefix (it has a colon).
|
|
124
|
+
const id = `stash_note_${noteSeq++}`;
|
|
125
|
+
return [
|
|
126
|
+
{ role: 'assistant', content: null, tool_calls: [{ id, type: 'function', function: { name: 'context_compacted', arguments: '{}' } }] },
|
|
127
|
+
{ role: 'tool', tool_call_id: id, content: text },
|
|
128
|
+
];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Tools — they only QUEUE intent (args-only signature); trim does the transcript work next round.
|
|
132
|
+
/** @type {ToolDef} */
|
|
133
|
+
const checkpointTool = {
|
|
134
|
+
name: 'checkpoint',
|
|
135
|
+
description: 'Plant a labeled anchor at the start of a sub-task, so it can later be compacted as a unit.',
|
|
136
|
+
parameters: ARG_SCHEMA,
|
|
137
|
+
execute: async ({ label } = {}) => {
|
|
138
|
+
requireLabel(label, 'stash_checkpoint');
|
|
139
|
+
pending.push({ action: 'checkpoint', label });
|
|
140
|
+
return { label, status: 'checkpoint scheduled' };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
/** @type {ToolDef} */
|
|
144
|
+
const compactTool = {
|
|
145
|
+
name: 'compact',
|
|
146
|
+
description: 'Fold everything since the matching checkpoint out of the live context, leaving a short note.',
|
|
147
|
+
parameters: ARG_SCHEMA,
|
|
148
|
+
execute: async ({ label, strategy, reason } = {}) => {
|
|
149
|
+
requireLabel(label, 'stash_compact');
|
|
150
|
+
if (!hasCheckpoint(label)) {
|
|
151
|
+
throw new ToolError(`[stash_compact] no checkpoint for "${label}" — call stash_checkpoint({ label: "${label}" }) first.`);
|
|
152
|
+
}
|
|
153
|
+
const used = strategy || defaultStrategy;
|
|
154
|
+
if (!STRATEGIES.has(used)) {
|
|
155
|
+
throw new ToolError(`[stash_compact] strategy must be 'summarize' (lossy) or 'stash' (lossless); got "${used}".`);
|
|
156
|
+
}
|
|
157
|
+
pending.push({ action: 'compact', label, strategy: used, reason });
|
|
158
|
+
return { label, strategy: used, status: 'compaction scheduled' };
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
/** @type {ToolDef} */
|
|
162
|
+
const restoreTool = {
|
|
163
|
+
name: 'restore',
|
|
164
|
+
description: "Rehydrate a 'stash'-compacted sub-task verbatim into the live context.",
|
|
165
|
+
parameters: ARG_SCHEMA,
|
|
166
|
+
execute: async ({ label } = {}) => {
|
|
167
|
+
requireLabel(label, 'stash_restore');
|
|
168
|
+
if (!parked.has(label) && !pending.some(p => p.action === 'compact' && p.label === label)) {
|
|
169
|
+
throw new ToolError(`[stash_restore] nothing compacted under "${label}" to restore.`);
|
|
170
|
+
}
|
|
171
|
+
pending.push({ action: 'restore', label });
|
|
172
|
+
return { label, status: 'restore scheduled' };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Park / rehydrate over litectx when wired, else the in-process Map. Same lossless guarantee.
|
|
177
|
+
const park = (/** @type {any} */ ctx, /** @type {string} */ label, /** @type {string} */ json) => {
|
|
178
|
+
const id = idFor(label);
|
|
179
|
+
if (ctx && typeof ctx.stash === 'function') {
|
|
180
|
+
ctx.stash(id, json);
|
|
181
|
+
parked.set(label, { backend: 'ctx', id });
|
|
182
|
+
} else {
|
|
183
|
+
if (!warnedNoStash) {
|
|
184
|
+
warnedNoStash = true;
|
|
185
|
+
onNote('[stash] no litectx ctx.stash wired — parking verbatim in-process (lossless, but run-scoped, not durable across runs).');
|
|
186
|
+
}
|
|
187
|
+
local.set(id, json);
|
|
188
|
+
parked.set(label, { backend: 'local', id });
|
|
189
|
+
}
|
|
190
|
+
// §3.6 memory footprint: a lossless park is a `stashed` op bareagent initiated. Announce it to the
|
|
191
|
+
// loop-lent meter (channel A); absent (no ctx / not wired to a Loop) it's a harmless no-op.
|
|
192
|
+
if (ctx && typeof ctx.recordMemoryOp === 'function') ctx.recordMemoryOp('stashed');
|
|
193
|
+
};
|
|
194
|
+
const rehydrate = (/** @type {any} */ ctx, /** @type {string} */ label) => {
|
|
195
|
+
const handle = parked.get(label);
|
|
196
|
+
if (!handle) return null;
|
|
197
|
+
if (handle.backend === 'ctx') {
|
|
198
|
+
const item = ctx && typeof ctx.get === 'function' ? ctx.get(handle.id) : null;
|
|
199
|
+
return item && typeof item.text === 'string' ? item.text : null;
|
|
200
|
+
}
|
|
201
|
+
if (handle.backend === 'local') return local.get(handle.id) ?? null;
|
|
202
|
+
return null; // 'summary' — lossy, no verbatim retained
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// LRU backstop on distinct live labels (§2.13) — visible, never silent.
|
|
206
|
+
const enforceBackstop = (/** @type {any} */ ctx) => {
|
|
207
|
+
while (parked.size > maxLabels) {
|
|
208
|
+
const oldest = parked.keys().next().value;
|
|
209
|
+
const handle = parked.get(oldest);
|
|
210
|
+
if (handle) {
|
|
211
|
+
if (handle.backend === 'ctx') { if (ctx && typeof ctx.evict === 'function') { try { ctx.evict(handle.id); } catch { /* best-effort */ } } }
|
|
212
|
+
else if (handle.backend === 'local') local.delete(handle.id);
|
|
213
|
+
}
|
|
214
|
+
parked.delete(oldest);
|
|
215
|
+
onNote(`[stash] label backstop (${maxLabels}) exceeded — evicted oldest stash "${oldest}".`);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Fold msgs[from, to) into a parked stash (or a lossy summary) + a provider-safe note pair, IN PLACE.
|
|
220
|
+
// Shared by on-demand compact (to = end) and the auto-trigger (a middle range). Callers must pass a
|
|
221
|
+
// range whose left edge follows a user-normalized message (so the assistant-led note pair alternates)
|
|
222
|
+
// and whose right edge is either an assistant turn start or the end of the transcript — so the spliced
|
|
223
|
+
// note pair keeps both tool-pairing and alternation valid.
|
|
224
|
+
const foldRange = async (/** @type {any[]} */ msgs, /** @type {any} */ ctx, /** @type {string} */ label, /** @type {number} */ from, /** @type {number} */ to, /** @type {string} */ strategy, /** @type {string|undefined} */ reason) => {
|
|
225
|
+
const span = msgs.slice(from, to);
|
|
226
|
+
if (!span.length) return false;
|
|
227
|
+
let note;
|
|
228
|
+
if (strategy === 'summarize') {
|
|
229
|
+
let summary = '';
|
|
230
|
+
if (ctx && typeof ctx.summarize === 'function') {
|
|
231
|
+
try { summary = await ctx.summarize(span); }
|
|
232
|
+
catch (err) { if (err && err.name === 'HaltError') throw err; onNote(`[stash] summarize failed for "${label}": ${err && err.message}`); }
|
|
233
|
+
}
|
|
234
|
+
if (summary) {
|
|
235
|
+
parked.set(label, { backend: 'summary', id: idFor(label) }); // lossy: nothing verbatim retained
|
|
236
|
+
note = `Compacted "${label}" (${span.length} turns, summarized; not restorable): ${summary}`;
|
|
237
|
+
} else {
|
|
238
|
+
onNote(`[stash] 'summarize' for "${label}" has no ctx.summarize — parked verbatim (lossless) instead.`);
|
|
239
|
+
park(ctx, label, JSON.stringify(span));
|
|
240
|
+
note = `Compacted "${label}" (${span.length} turns) — restorable verbatim via stash_restore.`;
|
|
241
|
+
}
|
|
242
|
+
} else { // 'stash' — lossless
|
|
243
|
+
park(ctx, label, JSON.stringify(span));
|
|
244
|
+
note = `Compacted "${label}" (${span.length} turns) — restorable verbatim via stash_restore.`;
|
|
245
|
+
}
|
|
246
|
+
msgs.splice(from, to - from, ...noteMessages(note)); // replace the span with the note pair, in place
|
|
247
|
+
if (reason && ctx && typeof ctx.remember === 'function') {
|
|
248
|
+
try {
|
|
249
|
+
await ctx.remember(`${keyPrefix}episode:${label}`, reason, { kind: 'episode' });
|
|
250
|
+
// §3.6 memory footprint: an episode stance write bareagent initiated (channel A; no-op if unwired).
|
|
251
|
+
if (typeof ctx.recordMemoryOp === 'function') ctx.recordMemoryOp('episodes');
|
|
252
|
+
} catch (err) { if (err && err.name === 'HaltError') throw err; onNote(`[stash] episode stance write failed for "${label}": ${err && err.message}`); }
|
|
253
|
+
}
|
|
254
|
+
enforceBackstop(ctx);
|
|
255
|
+
return true;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Assistant turn starts: indices where an assistant turn begins after a user-normalized message (tool
|
|
259
|
+
// results normalize to user). Folding between two such indices removes WHOLE turns and lands the note
|
|
260
|
+
// pair on a user→assistant boundary — both transcript invariants hold by construction.
|
|
261
|
+
const turnStarts = (/** @type {any[]} */ msgs) => {
|
|
262
|
+
const out = [];
|
|
263
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
264
|
+
const norm = msgs[i].role === 'tool' ? 'user' : msgs[i].role;
|
|
265
|
+
const prev = i === 0 ? null : (msgs[i - 1].role === 'tool' ? 'user' : msgs[i - 1].role);
|
|
266
|
+
if (norm === 'assistant' && prev !== 'assistant') out.push(i);
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Automatic token-pressure compaction (§2.11): fold the MIDDLE — keep the first `keepHeadTurns` turns
|
|
272
|
+
// (initial context) and the last `keepRecentTurns` turns (the live working set) — when measured context
|
|
273
|
+
// pressure crosses the ceiling. Reuses foldRange's validated machinery.
|
|
274
|
+
const autoCompact = async (/** @type {any[]} */ msgs, /** @type {any} */ ctx) => {
|
|
275
|
+
if (!auto || !ctx || !ctx.usage) return false;
|
|
276
|
+
const used = ctx.usage.inputTokens || 0;
|
|
277
|
+
if (!(used / auto.ceilingTokens > auto.triggerAt)) return false;
|
|
278
|
+
const starts = turnStarts(msgs);
|
|
279
|
+
if (starts.length < auto.keepHeadTurns + auto.keepRecentTurns + 1) return false; // too little to fold
|
|
280
|
+
const from = starts[auto.keepHeadTurns];
|
|
281
|
+
const to = starts[starts.length - auto.keepRecentTurns];
|
|
282
|
+
if (to == null || to <= from) return false;
|
|
283
|
+
const label = `auto:${autoSeq++}`;
|
|
284
|
+
const folded = await foldRange(msgs, ctx, label, from, to, auto.strategy, undefined);
|
|
285
|
+
if (folded) onNote(`[stash] auto-compaction fired at ${used}/${auto.ceilingTokens} tokens (>${auto.triggerAt}) — folded ${to - from} turns as "${label}".`);
|
|
286
|
+
return folded;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The trim seam (loop.js:487): drains queued intents against the LIVE canonical transcript each round,
|
|
291
|
+
* at a clean round boundary. Mutates `msgs` in place and returns it. Fail-open per the trim contract:
|
|
292
|
+
* a HaltError (e.g. a litectx write-gate deny) propagates; anything else is contained so a hygiene bug
|
|
293
|
+
* never halts the agent.
|
|
294
|
+
* @param {any[]} msgs
|
|
295
|
+
* @param {any} ctx
|
|
296
|
+
* @returns {Promise<any[]>}
|
|
297
|
+
*/
|
|
298
|
+
const trim = async (msgs, ctx) => {
|
|
299
|
+
while (pending.length) {
|
|
300
|
+
const op = pending.shift();
|
|
301
|
+
if (!op) break;
|
|
302
|
+
if (op.action === 'checkpoint') {
|
|
303
|
+
anchors.set(op.label, msgs.length ? msgs[msgs.length - 1] : null);
|
|
304
|
+
} else if (op.action === 'compact') {
|
|
305
|
+
const anchorMsg = anchors.get(op.label);
|
|
306
|
+
anchors.delete(op.label);
|
|
307
|
+
// No-op unless there's a real anchor message still in the transcript. A null anchor (checkpoint
|
|
308
|
+
// planted on an empty transcript) or one already folded away (indexOf < 0) has no coherent span —
|
|
309
|
+
// and folding from 0 would splice the assistant-led note pair at index 0, breaking the
|
|
310
|
+
// first-message-must-be-user rule. Fold strictly AFTER the anchor.
|
|
311
|
+
const at = anchorMsg == null ? -1 : msgs.indexOf(anchorMsg);
|
|
312
|
+
if (at < 0) continue;
|
|
313
|
+
await foldRange(msgs, ctx, op.label, at + 1, msgs.length, op.strategy || defaultStrategy, op.reason); // on-demand: anchor→now
|
|
314
|
+
} else if (op.action === 'restore') {
|
|
315
|
+
const handle = parked.get(op.label);
|
|
316
|
+
if (handle && handle.backend === 'summary') {
|
|
317
|
+
msgs.push(...noteMessages(`Cannot restore "${op.label}" verbatim — it was compacted with the lossy 'summarize' strategy.`));
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const json = rehydrate(ctx, op.label);
|
|
321
|
+
if (json == null) { onNote(`[stash] restore "${op.label}": nothing parked (or backend lost it).`); continue; }
|
|
322
|
+
let span;
|
|
323
|
+
try { span = JSON.parse(json); } catch { onNote(`[stash] restore "${op.label}": parked payload was not valid JSON.`); continue; }
|
|
324
|
+
if (Array.isArray(span)) msgs.push(...span); // verbatim re-append (whole rounds → still valid)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// After on-demand intents, fire the automatic token-pressure fold if the ceiling is crossed (§2.11).
|
|
328
|
+
if (auto) await autoCompact(msgs, ctx);
|
|
329
|
+
return msgs;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const skill = {
|
|
333
|
+
name: 'stash',
|
|
334
|
+
description: 'Compact finished sub-tasks to keep the live context window lean.',
|
|
335
|
+
instructions: INSTRUCTIONS,
|
|
336
|
+
tools: [checkpointTool, compactTool, restoreTool],
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
return { skill, trim, restoreHandles: () => [...parked.keys()] };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = { createStashSkill };
|
package/types/index.d.ts
CHANGED
|
@@ -4,10 +4,66 @@
|
|
|
4
4
|
// <-> loop <-> tools). Per-file option bags live as local @typedef blocks in
|
|
5
5
|
// each module; only the genuinely shared shapes belong here.
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Token accounting returned by a provider's generate(), normalized to one neutral shape across
|
|
9
|
+
* providers. `inputTokens` is always the UNCACHED prompt remainder — total prompt =
|
|
10
|
+
* inputTokens + cacheReadTokens + cacheCreationTokens. Providers whose API folds cached tokens
|
|
11
|
+
* into the prompt count (OpenAI `prompt_tokens`, Gemini `promptTokenCount`) subtract them out here;
|
|
12
|
+
* Anthropic's `input_tokens` is already the remainder. The cache tiers price differently from
|
|
13
|
+
* uncached input (read is cheaper, Anthropic's creation is a premium) — see `estimateCost`. Absent
|
|
14
|
+
* cache fields mean the provider/model didn't cache (local models, short prompts); treat as 0.
|
|
15
|
+
*/
|
|
8
16
|
export interface Usage {
|
|
17
|
+
/** Uncached prompt tokens, billed at the model's full input rate. */
|
|
9
18
|
inputTokens: number;
|
|
19
|
+
/** Completion tokens (includes provider "thinking"/reasoning tokens where billed as output). */
|
|
10
20
|
outputTokens: number;
|
|
21
|
+
/** Prompt tokens served from cache — a cheaper tier (OpenAI ~0.5×, Gemini ~0.25×, Anthropic ~0.1×). */
|
|
22
|
+
cacheReadTokens?: number;
|
|
23
|
+
/** Prompt tokens written to cache — a premium tier (Anthropic ~1.25×; OpenAI/Gemini have no write surcharge → 0). */
|
|
24
|
+
cacheCreationTokens?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Canonical per-run counters returned on a Loop result as `result.metrics` (Feature 3 — the meter).
|
|
29
|
+
* Present on every run, gate-wired or not. `tokens` is CUMULATIVE across all rounds and all four tiers
|
|
30
|
+
* (the run total — unlike `result.usage`, which is the last round only and kept for back-compat).
|
|
31
|
+
*/
|
|
32
|
+
export interface RunMetrics {
|
|
33
|
+
/** Rounds (LLM turns) executed. */
|
|
34
|
+
turns: number;
|
|
35
|
+
/** Total tool calls the model made (every invocation, including denied/unknown). */
|
|
36
|
+
toolCalls: number;
|
|
37
|
+
/** Per-tool invocation counts, keyed by tool name. */
|
|
38
|
+
byTool: Record<string, number>;
|
|
39
|
+
/** Cumulative token spend across all rounds (incl. summarize calls), by tier. */
|
|
40
|
+
tokens: { input: number; output: number; cacheCreation: number; cacheRead: number };
|
|
41
|
+
/** Cumulative USD over priced rounds; null ONLY if nothing could be priced (explicit-unknown, not free). */
|
|
42
|
+
costUsd: number | null;
|
|
43
|
+
/** Count of rounds whose cost could not be computed (no model / no rate) — the loud-unpriced signal. */
|
|
44
|
+
unpricedRounds: number;
|
|
45
|
+
/** Spawn-tool invocations this run (the `spawn` tool count — counts every call, incl. denied/failed). */
|
|
46
|
+
spawned: number;
|
|
47
|
+
/**
|
|
48
|
+
* CE-activity rollup, derived in-place from Stream events (loop:trim, loop:summarize) — a
|
|
49
|
+
* convenience view, not a second source. `compactions`: destructive trim evictions. `summaries`:
|
|
50
|
+
* ctx.summarize calls. `tokensTrimmed`: APPROXIMATE (~4 chars/token) tokens evicted from the
|
|
51
|
+
* canonical transcript — an estimate, since evicted spans have no exact provider count (§3.10).
|
|
52
|
+
* (The memory.* footprint stays deferred — its source crosses component independence — §3.10.)
|
|
53
|
+
*/
|
|
54
|
+
context: { compactions: number; summaries: number; tokensTrimmed: number };
|
|
55
|
+
/**
|
|
56
|
+
* §3.6 memory footprint — the memory ops bareagent INITIATES this run, via the loop-lent
|
|
57
|
+
* `ctx.recordMemoryOp` hook (bounded per run; result.metrics is a copy taken at run end). `stashed`:
|
|
58
|
+
* lossless parks to the stash table/in-process. `episodes`: stance writes on compact. The Memory
|
|
59
|
+
* wrapper is metered symmetrically and opt-in (0 unless the caller threads the run's ctx): `recalls`
|
|
60
|
+
* = Memory.search reads, `stored` = Memory.store writes. `facts` = durable facts written by `remember`
|
|
61
|
+
* (the consolidation pass) — its honest producer; DISJOINT from `stored` (a distilled fact counts once,
|
|
62
|
+
* as a fact). litectx's own episode→fact promotion stays litectx-internal to surface, not this counter.
|
|
63
|
+
*/
|
|
64
|
+
memory: { stashed: number; episodes: number; recalls: number; stored: number; facts: number };
|
|
65
|
+
/** Wall-clock duration of the run in ms. */
|
|
66
|
+
durationMs: number;
|
|
11
67
|
}
|
|
12
68
|
|
|
13
69
|
/** A single tool invocation requested by the model. `arguments` is parsed JSON. */
|