bare-agent 0.16.2 → 0.18.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 +36 -30
- package/bareagent.context.md +60 -1
- package/index.d.ts +7 -1
- package/index.js +12 -1
- package/package.json +5 -4
- 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/remember.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* remember — the consolidation pass (eval-assist F5). The "future glue" the PRD parked: it turns the spans
|
|
5
|
+
* `stash` harvested out of the live transcript into durable facts, written back through the GENERIC four-verb
|
|
6
|
+
* `Store` socket (`store`/`search`/`get`/`delete`). Backend-agnostic by construction — works with the
|
|
7
|
+
* JsonFileStore, SQLite, litectx, or a custom store — so it carries NO litectx coupling (the reason the
|
|
8
|
+
* read-litectx's-promotion-count alternative was rejected: it reaches past the socket into one backend).
|
|
9
|
+
*
|
|
10
|
+
* One cheap LLM pass per span distills durable signal (decisions, config, identifiers, stable preferences)
|
|
11
|
+
* and DROPS ephemeral chatter, superseded values, and unanswered questions. The distiller prompt below is the
|
|
12
|
+
* one validated live on the real Anthropic wire in `poc/f5-remember-distill.mjs` (faithfulness + correction +
|
|
13
|
+
* discrimination, all falsifiable). A wrong distiller poisons Memory silently, so that POC must stay green.
|
|
14
|
+
*
|
|
15
|
+
* SECURITY — memory-poisoning surface: facts are model output distilled over UNTRUSTED transcript content and
|
|
16
|
+
* written to durable memory. The grounding prompt refuses a direct "record this fact" injection (validated in the
|
|
17
|
+
* F5 POC + integration test), but treat recalled facts as untrusted CONTEXT, never as authority — and never feed
|
|
18
|
+
* a recalled fact into a privileged action without the same gate you'd apply to any model output.
|
|
19
|
+
*
|
|
20
|
+
* Composes AROUND a Loop (like Evaluator/refine), never inside `loop.js`. Optional, flagged-and-deletable.
|
|
21
|
+
* Budget visibility: each distill pass forwards `usage` to `opts.onLlmResult` (mirror of Evaluator) so a wired
|
|
22
|
+
* bareguard gate keeps counting. A `HaltError` from the provider propagates clean. Metering: each fact write
|
|
23
|
+
* counts against `result.metrics.memory.facts` via the loop-lent `ctx.recordMemoryOp('facts')` hook — the
|
|
24
|
+
* honest producer that field waited for. `facts` is DISJOINT from `stored`: remember writes via `store.store`
|
|
25
|
+
* WITHOUT threading ctx, so it never also trips the generic-write counter.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @typedef {import('../types').Provider} Provider */
|
|
29
|
+
/** @typedef {import('../types').Store} Store */
|
|
30
|
+
|
|
31
|
+
// The distiller prompt under test in poc/f5-remember-distill.mjs — proven faithful on the live wire.
|
|
32
|
+
// This IS the artifact; do not edit without re-running that POC (it must stay able to FAIL).
|
|
33
|
+
const DISTILL_PROMPT = [
|
|
34
|
+
'You distill durable, reusable memory from a finished work transcript.',
|
|
35
|
+
'Output ONLY a JSON array of short fact strings. No prose, no code fences.',
|
|
36
|
+
'A durable fact is one a future session would want: decisions, architecture, config values, stable preferences, identifiers.',
|
|
37
|
+
'Rules:',
|
|
38
|
+
'- Ground every fact in the transcript. NEVER infer or invent a value that is not stated.',
|
|
39
|
+
'- If a value was later corrected, record ONLY the final corrected value — drop the superseded one entirely.',
|
|
40
|
+
'- Ignore greetings, acknowledgements, transient status ("checking", "one sec"), and questions that went unanswered.',
|
|
41
|
+
'- If nothing durable is present, return exactly [].',
|
|
42
|
+
].join('\n');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} RememberOptions
|
|
46
|
+
* @property {Provider} provider - LLM provider implementing `generate(messages, tools, opts)`.
|
|
47
|
+
* @property {Pick<Store, 'store'>} store - The Store socket (or a `Memory` wrapper). remember writes through
|
|
48
|
+
* `.store(content, metadata)` ONLY — no other verb, no backend assumptions.
|
|
49
|
+
* @property {string} [contract] - Optional definition of what counts as durable for this task; appended to the
|
|
50
|
+
* distiller prompt to steer it (the A3 contract idea, reused on the write side).
|
|
51
|
+
* @property {Record<string, any>} [metadata] - Merged into every stored fact's metadata. `kind` is always `'fact'`
|
|
52
|
+
* (NOT overridable — `remember` writes facts; `fact` is litectx's canonical durable kind and harmless to other
|
|
53
|
+
* stores, so the stored label always matches the `facts` counter). Add ANY OTHER metadata here (tags, or litectx
|
|
54
|
+
* `format`/`scope` to differentiate facts). NEVER carries ctx — ctx rides in its own option, never persisted.
|
|
55
|
+
* @property {{ recordMemoryOp?: (kind: string) => void } & Record<string, any>} [ctx] - The run ctx. If it carries
|
|
56
|
+
* the loop-lent `recordMemoryOp`, each fact write counts against `result.metrics.memory.facts`.
|
|
57
|
+
* @property {(payload: { usage: any, model: string|null, kind: 'remember' }) => any} [onLlmResult] - Budget hook;
|
|
58
|
+
* each distill pass forwards usage so a bareguard gate keeps counting (mirror of Evaluator).
|
|
59
|
+
* @property {string} [prompt] - Override the distiller system prompt (defaults to the F5-validated one).
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} RememberOutcome
|
|
64
|
+
* @property {string[]} facts - The facts written, in order — deduplicated within this call by exact string
|
|
65
|
+
* (so `facts.length` is the count stored). Cross-run / semantic dedup is the store's/consumer's job.
|
|
66
|
+
* @property {number} spans - How many non-empty spans were processed.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Distill durable facts from harvested spans and persist them through the Store socket.
|
|
71
|
+
*
|
|
72
|
+
* @param {Array<string | { content?: string, text?: string }>} spans - The harvested spans (stash's feedstock).
|
|
73
|
+
* Each is a transcript chunk — a raw string, or an object with `content`/`text`. Empty/blank spans are skipped.
|
|
74
|
+
* @param {RememberOptions} options
|
|
75
|
+
* @returns {Promise<RememberOutcome>}
|
|
76
|
+
*/
|
|
77
|
+
async function remember(spans, options = /** @type {RememberOptions} */ ({})) {
|
|
78
|
+
if (!Array.isArray(spans)) throw new Error('[remember] spans must be an array');
|
|
79
|
+
const { provider, store } = options;
|
|
80
|
+
if (!provider || typeof provider.generate !== 'function') throw new Error('[remember] requires options.provider with a generate() method');
|
|
81
|
+
if (!store || typeof store.store !== 'function') throw new Error('[remember] requires options.store with a store() method');
|
|
82
|
+
|
|
83
|
+
const contract = typeof options.contract === 'string' && options.contract.trim() ? options.contract.trim() : null;
|
|
84
|
+
const baseMeta = options.metadata && typeof options.metadata === 'object' ? options.metadata : {};
|
|
85
|
+
const ctx = options.ctx;
|
|
86
|
+
/** @type {((kind: string) => void) | null} */
|
|
87
|
+
const recordFact = ctx && typeof ctx.recordMemoryOp === 'function' ? ctx.recordMemoryOp : null;
|
|
88
|
+
const onLlmResult = typeof options.onLlmResult === 'function' ? options.onLlmResult : null;
|
|
89
|
+
const sys = typeof options.prompt === 'string' && options.prompt.trim()
|
|
90
|
+
? options.prompt
|
|
91
|
+
: (contract ? `${DISTILL_PROMPT}\n\nFor this task, "durable" specifically means:\n${contract}` : DISTILL_PROMPT);
|
|
92
|
+
|
|
93
|
+
/** @type {string[]} */
|
|
94
|
+
const all = [];
|
|
95
|
+
const seen = new Set(); // in-call exact-string dedup; cross-run / semantic dedup is the store's/consumer's job
|
|
96
|
+
let spanCount = 0;
|
|
97
|
+
|
|
98
|
+
for (const span of spans) {
|
|
99
|
+
const text = spanText(span);
|
|
100
|
+
if (!text) continue; // skip empty/blank — nothing to distill, and a blank prompt wastes a round
|
|
101
|
+
spanCount++;
|
|
102
|
+
|
|
103
|
+
const out = await provider.generate(
|
|
104
|
+
[{ role: 'system', content: sys }, { role: 'user', content: text }],
|
|
105
|
+
[],
|
|
106
|
+
{ temperature: 0 },
|
|
107
|
+
);
|
|
108
|
+
if (onLlmResult) {
|
|
109
|
+
await onLlmResult({ usage: (out && out.usage) || null, model: (out && out.model) || provider.model || null, kind: 'remember' });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const fact of parseFacts(out && out.text)) {
|
|
113
|
+
if (seen.has(fact)) continue; // already written this call — don't double-store or double-count
|
|
114
|
+
seen.add(fact);
|
|
115
|
+
// Write via the generic socket WITHOUT ctx → counts as `facts`, not the generic `stored`.
|
|
116
|
+
// `kind:'fact'` is authoritative (AFTER the spread) so the stored label always matches the counter.
|
|
117
|
+
await store.store(fact, { ...baseMeta, kind: 'fact' });
|
|
118
|
+
if (recordFact) recordFact('facts');
|
|
119
|
+
all.push(fact);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { facts: all, spans: spanCount };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalize a span to its text, or '' if there's nothing to distill.
|
|
128
|
+
* @param {any} span
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
function spanText(span) {
|
|
132
|
+
let s = '';
|
|
133
|
+
if (typeof span === 'string') s = span;
|
|
134
|
+
else if (span && typeof span === 'object') s = typeof span.content === 'string' ? span.content : (typeof span.text === 'string' ? span.text : '');
|
|
135
|
+
return s.trim() ? s : '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Lenient extraction of a JSON array of fact strings from raw model output (mirrors planner's parse).
|
|
140
|
+
* A model that returns prose / no array yields [] — NOT an error (the "nothing durable" case). A provider
|
|
141
|
+
* or network error is a different thing and propagates from generate(), never swallowed here.
|
|
142
|
+
* @param {any} text
|
|
143
|
+
* @returns {string[]}
|
|
144
|
+
*/
|
|
145
|
+
function parseFacts(text) {
|
|
146
|
+
if (typeof text !== 'string') return [];
|
|
147
|
+
const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
|
|
148
|
+
let arr;
|
|
149
|
+
try {
|
|
150
|
+
arr = JSON.parse(cleaned);
|
|
151
|
+
} catch {
|
|
152
|
+
const match = cleaned.match(/\[[\s\S]*\]/);
|
|
153
|
+
if (!match) return [];
|
|
154
|
+
try { arr = JSON.parse(match[0]); } catch { return []; }
|
|
155
|
+
}
|
|
156
|
+
if (!Array.isArray(arr)) return [];
|
|
157
|
+
return arr
|
|
158
|
+
.map((f) => (typeof f === 'string' ? f : (f && typeof f === 'object' && typeof f.fact === 'string' ? f.fact : '')))
|
|
159
|
+
.map((s) => s.trim())
|
|
160
|
+
.filter(Boolean);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { remember, DISTILL_PROMPT };
|
package/src/skills.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type ToolDef = import("../types").ToolDef;
|
|
2
|
+
export type Skill = {
|
|
3
|
+
/**
|
|
4
|
+
* - Unique skill name (also the tool-name prefix).
|
|
5
|
+
*/
|
|
6
|
+
name: string;
|
|
7
|
+
/**
|
|
8
|
+
* - One-line catalog entry shown in `skill_use`'s description.
|
|
9
|
+
*/
|
|
10
|
+
description: string;
|
|
11
|
+
/**
|
|
12
|
+
* - Returned as the `skill_use` tool result when the skill is activated.
|
|
13
|
+
*/
|
|
14
|
+
instructions: string;
|
|
15
|
+
/**
|
|
16
|
+
* - Tools unlocked on use; their names are auto-prefixed `${name}_${tool}`.
|
|
17
|
+
*/
|
|
18
|
+
tools?: import("../types").ToolDef[] | undefined;
|
|
19
|
+
};
|
|
20
|
+
export class SkillRegistry {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} [options]
|
|
23
|
+
* @param {Iterable<string>} [options.reserved] - Tool names already in use (native/MCP), so a prefixed
|
|
24
|
+
* skill tool that would collide with one is rejected at `register` time. Tool names are globally unique
|
|
25
|
+
* for DISPATCH (PRD §2.6, D6) — this is the collision check across native + MCP + skills, not security.
|
|
26
|
+
* @param {string} [options.metaToolName='skill_use'] - Override the meta-tool name if `skill_use` is taken.
|
|
27
|
+
*/
|
|
28
|
+
constructor(options?: {
|
|
29
|
+
reserved?: Iterable<string> | undefined;
|
|
30
|
+
metaToolName?: string | undefined;
|
|
31
|
+
});
|
|
32
|
+
/** @type {Map<string, {name: string, description: string, instructions: string, tools: ToolDef[]}>} */
|
|
33
|
+
_skills: Map<string, {
|
|
34
|
+
name: string;
|
|
35
|
+
description: string;
|
|
36
|
+
instructions: string;
|
|
37
|
+
tools: ToolDef[];
|
|
38
|
+
}>;
|
|
39
|
+
/** @type {Set<string>} skill names the agent has activated this run */
|
|
40
|
+
_unlocked: Set<string>;
|
|
41
|
+
/** @type {Set<string>} every tool name owned by the registry (meta + all prefixed skill tools) */
|
|
42
|
+
_toolNames: Set<string>;
|
|
43
|
+
/** @type {Set<string>} externally-reserved names (native/MCP) for collision detection */
|
|
44
|
+
_reserved: Set<string>;
|
|
45
|
+
metaToolName: string;
|
|
46
|
+
/**
|
|
47
|
+
* The current active tool set: `[metaTool, ...tools of every unlocked skill]`. Pass this method (bound)
|
|
48
|
+
* as the Loop `tools` thunk — it is re-evaluated each round, so tools unlocked this round are offered next.
|
|
49
|
+
* @returns {ToolDef[]}
|
|
50
|
+
*/
|
|
51
|
+
activeTools(): ToolDef[];
|
|
52
|
+
/**
|
|
53
|
+
* Register a skill. Throws on a duplicate skill name, or if a prefixed tool name collides with an
|
|
54
|
+
* existing native/MCP/skill tool (or the meta-tool). Returns `this` for chaining.
|
|
55
|
+
* @param {Skill} skill
|
|
56
|
+
* @returns {SkillRegistry}
|
|
57
|
+
*/
|
|
58
|
+
register(skill: Skill): SkillRegistry;
|
|
59
|
+
/** Build the catalog block (one line per registered skill) for the meta-tool description. */
|
|
60
|
+
_catalog(): string;
|
|
61
|
+
/**
|
|
62
|
+
* The `skill_use` meta-tool ToolDef, with the current catalog in its description. Always included by
|
|
63
|
+
* `activeTools()`. Activating a skill injects its instructions (the tool result) and unlocks its tools.
|
|
64
|
+
* @returns {ToolDef}
|
|
65
|
+
*/
|
|
66
|
+
get metaTool(): ToolDef;
|
|
67
|
+
/** Names of skills activated so far this run. @returns {string[]} */
|
|
68
|
+
unlocked(): string[];
|
|
69
|
+
/** Reset the unlocked set (e.g. between runs). The registered skills are kept. */
|
|
70
|
+
reset(): void;
|
|
71
|
+
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// SkillRegistry — the eval-assist F2 skill mechanism (PRD §2.3–2.7).
|
|
4
|
+
//
|
|
5
|
+
// A skill is an operator-registered bundle `{ name, description, instructions, tools }` surfaced to the
|
|
6
|
+
// agent by PROGRESSIVE DISCLOSURE: a single meta-tool `skill_use` carries a catalog of one-liners in its
|
|
7
|
+
// description; until a skill is used, only its one-liner is in context — never its instructions or tool
|
|
8
|
+
// schemas. On `skill_use({ name })`, two effects: (1) the skill's `instructions` are returned AS the tool
|
|
9
|
+
// result (on-demand injection, lands naturally in the transcript), and (2) the skill's tools are unlocked
|
|
10
|
+
// into the active tool set for subsequent rounds, called NATIVELY by their prefixed names.
|
|
11
|
+
//
|
|
12
|
+
// The genuine gap vs. MCP (which already does tools-on-demand): instructions injected per-invocation inside
|
|
13
|
+
// the same agent's context budget — the context-engineering lever (PRD §2.2).
|
|
14
|
+
//
|
|
15
|
+
// Governance is unchanged (PRD §2.6, D5): the gate judges `(tool, args)` and ignores origin. Skills affect
|
|
16
|
+
// DISCOVERY, never AUTHORIZATION — unlocking a tool authorizes nothing; bareguard decides each call blind to
|
|
17
|
+
// how the tool was reached. `skill_use` itself, and every unlocked tool, flow through the SAME
|
|
18
|
+
// `Loop({ policy })` chokepoint.
|
|
19
|
+
//
|
|
20
|
+
// The only Loop coupling is the general tools-as-thunk primitive (D4, src/loop.js): pass `activeTools` as
|
|
21
|
+
// the `tools` thunk and the freshly-unlocked set is offered on the next round. Loop never imports this file.
|
|
22
|
+
//
|
|
23
|
+
// Separator is `_`, not `.` (POC-corrected, PRD §2.6): a dot is rejected by both OpenAI's
|
|
24
|
+
// `^[a-zA-Z0-9_-]+$` and Anthropic's tool-name validators. Underscore is what MCP already uses
|
|
25
|
+
// (`${server}_${tool}`); skills inherit it. Validated by poc/f2-skill-thunk.mjs on OpenAI + Anthropic.
|
|
26
|
+
|
|
27
|
+
const { ValidationError, ToolError } = require('./errors');
|
|
28
|
+
|
|
29
|
+
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} Skill
|
|
33
|
+
* @property {string} name - Unique skill name (also the tool-name prefix).
|
|
34
|
+
* @property {string} description - One-line catalog entry shown in `skill_use`'s description.
|
|
35
|
+
* @property {string} instructions - Returned as the `skill_use` tool result when the skill is activated.
|
|
36
|
+
* @property {ToolDef[]} [tools] - Tools unlocked on use; their names are auto-prefixed `${name}_${tool}`.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const META_NAME = 'skill_use';
|
|
40
|
+
|
|
41
|
+
// Provider-safe identifier charset. OpenAI enforces `^[a-zA-Z0-9_-]+$` on tool names and Anthropic the
|
|
42
|
+
// same shape (the POC, poc/f2-skill-thunk.mjs, proved a dot is rejected at generate time). Skill names and
|
|
43
|
+
// bare tool names must satisfy this so the prefixed result — joined by `_`, itself in the set — is accepted
|
|
44
|
+
// by every provider. Validating at register() fails fast, instead of surfacing as a mid-run provider error.
|
|
45
|
+
const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
|
|
46
|
+
|
|
47
|
+
class SkillRegistry {
|
|
48
|
+
/**
|
|
49
|
+
* @param {Object} [options]
|
|
50
|
+
* @param {Iterable<string>} [options.reserved] - Tool names already in use (native/MCP), so a prefixed
|
|
51
|
+
* skill tool that would collide with one is rejected at `register` time. Tool names are globally unique
|
|
52
|
+
* for DISPATCH (PRD §2.6, D6) — this is the collision check across native + MCP + skills, not security.
|
|
53
|
+
* @param {string} [options.metaToolName='skill_use'] - Override the meta-tool name if `skill_use` is taken.
|
|
54
|
+
*/
|
|
55
|
+
constructor(options = {}) {
|
|
56
|
+
/** @type {Map<string, {name: string, description: string, instructions: string, tools: ToolDef[]}>} */
|
|
57
|
+
this._skills = new Map();
|
|
58
|
+
/** @type {Set<string>} skill names the agent has activated this run */
|
|
59
|
+
this._unlocked = new Set();
|
|
60
|
+
/** @type {Set<string>} every tool name owned by the registry (meta + all prefixed skill tools) */
|
|
61
|
+
this._toolNames = new Set();
|
|
62
|
+
/** @type {Set<string>} externally-reserved names (native/MCP) for collision detection */
|
|
63
|
+
this._reserved = new Set(options.reserved || []);
|
|
64
|
+
this.metaToolName = options.metaToolName || META_NAME;
|
|
65
|
+
if (!SAFE_NAME.test(this.metaToolName)) {
|
|
66
|
+
throw new ValidationError(`[SkillRegistry] meta-tool name "${this.metaToolName}" must match ${SAFE_NAME} (provider tool-name charset).`);
|
|
67
|
+
}
|
|
68
|
+
if (this._reserved.has(this.metaToolName)) {
|
|
69
|
+
throw new ValidationError(`[SkillRegistry] meta-tool name "${this.metaToolName}" collides with a reserved tool name.`);
|
|
70
|
+
}
|
|
71
|
+
this._toolNames.add(this.metaToolName);
|
|
72
|
+
// Bind so callers can pass `skills.activeTools` directly as the Loop `tools` thunk (PRD §2.7) without
|
|
73
|
+
// losing `this`.
|
|
74
|
+
this.activeTools = this.activeTools.bind(this);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register a skill. Throws on a duplicate skill name, or if a prefixed tool name collides with an
|
|
79
|
+
* existing native/MCP/skill tool (or the meta-tool). Returns `this` for chaining.
|
|
80
|
+
* @param {Skill} skill
|
|
81
|
+
* @returns {SkillRegistry}
|
|
82
|
+
*/
|
|
83
|
+
register(skill) {
|
|
84
|
+
if (!skill || typeof skill !== 'object') {
|
|
85
|
+
throw new ValidationError('[SkillRegistry] register(skill) requires a { name, description, instructions, tools } object.');
|
|
86
|
+
}
|
|
87
|
+
const { name, description, instructions, tools = [] } = skill;
|
|
88
|
+
if (typeof name !== 'string' || !name) {
|
|
89
|
+
throw new ValidationError(`[SkillRegistry] skill name must be a non-empty string (got ${JSON.stringify(name)}).`);
|
|
90
|
+
}
|
|
91
|
+
if (!SAFE_NAME.test(name)) {
|
|
92
|
+
throw new ValidationError(`[SkillRegistry] skill name "${name}" must match ${SAFE_NAME} — it prefixes tool names, which providers reject outside that charset (e.g. a dot breaks OpenAI/Anthropic).`);
|
|
93
|
+
}
|
|
94
|
+
if (this._skills.has(name)) {
|
|
95
|
+
throw new ValidationError(`[SkillRegistry] duplicate skill name "${name}".`);
|
|
96
|
+
}
|
|
97
|
+
if (typeof description !== 'string' || !description) {
|
|
98
|
+
throw new ValidationError(`[SkillRegistry] skill "${name}" needs a non-empty string description (the catalog line).`);
|
|
99
|
+
}
|
|
100
|
+
if (typeof instructions !== 'string' || !instructions) {
|
|
101
|
+
throw new ValidationError(`[SkillRegistry] skill "${name}" needs non-empty string instructions (injected on use).`);
|
|
102
|
+
}
|
|
103
|
+
if (!Array.isArray(tools)) {
|
|
104
|
+
throw new ValidationError(`[SkillRegistry] skill "${name}" tools must be an array (got ${typeof tools}).`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Prefix each tool name for global dispatch uniqueness, validating collisions BEFORE committing any
|
|
108
|
+
// (a half-registered skill would be worse than a clean throw).
|
|
109
|
+
/** @type {ToolDef[]} */
|
|
110
|
+
const prefixed = [];
|
|
111
|
+
const seenThisSkill = new Set();
|
|
112
|
+
for (const tool of tools) {
|
|
113
|
+
if (!tool || typeof tool.name !== 'string' || !tool.name) {
|
|
114
|
+
throw new ValidationError(`[SkillRegistry] skill "${name}" has a tool with no name.`);
|
|
115
|
+
}
|
|
116
|
+
if (typeof tool.execute !== 'function') {
|
|
117
|
+
throw new ValidationError(`[SkillRegistry] skill "${name}" tool "${tool.name}" is missing an execute() function.`);
|
|
118
|
+
}
|
|
119
|
+
if (!SAFE_NAME.test(tool.name)) {
|
|
120
|
+
throw new ValidationError(`[SkillRegistry] skill "${name}" tool name "${tool.name}" must match ${SAFE_NAME} (provider tool-name charset).`);
|
|
121
|
+
}
|
|
122
|
+
const fullName = `${name}_${tool.name}`;
|
|
123
|
+
if (seenThisSkill.has(fullName)) {
|
|
124
|
+
throw new ValidationError(`[SkillRegistry] skill "${name}" defines "${tool.name}" twice.`);
|
|
125
|
+
}
|
|
126
|
+
if (this._toolNames.has(fullName) || this._reserved.has(fullName)) {
|
|
127
|
+
throw new ValidationError(`[SkillRegistry] skill "${name}" tool "${fullName}" collides with an existing native/MCP/skill tool name.`);
|
|
128
|
+
}
|
|
129
|
+
seenThisSkill.add(fullName);
|
|
130
|
+
prefixed.push({ ...tool, name: fullName });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const t of prefixed) this._toolNames.add(t.name);
|
|
134
|
+
this._skills.set(name, { name, description, instructions, tools: prefixed });
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build the catalog block (one line per registered skill) for the meta-tool description. */
|
|
139
|
+
_catalog() {
|
|
140
|
+
if (this._skills.size === 0) return '(no skills registered)';
|
|
141
|
+
return [...this._skills.values()].map(s => ` ${s.name}: ${s.description}`).join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The `skill_use` meta-tool ToolDef, with the current catalog in its description. Always included by
|
|
146
|
+
* `activeTools()`. Activating a skill injects its instructions (the tool result) and unlocks its tools.
|
|
147
|
+
* @returns {ToolDef}
|
|
148
|
+
*/
|
|
149
|
+
get metaTool() {
|
|
150
|
+
const registry = this;
|
|
151
|
+
return {
|
|
152
|
+
name: this.metaToolName,
|
|
153
|
+
description:
|
|
154
|
+
'Reveal and activate a registered skill by name. Returns the skill\'s instructions and unlocks its '
|
|
155
|
+
+ 'tools (callable by their namespaced names on subsequent turns). Available skills:\n'
|
|
156
|
+
+ this._catalog(),
|
|
157
|
+
parameters: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: { name: { type: 'string', description: 'The name of the skill to activate.' } },
|
|
160
|
+
required: ['name'],
|
|
161
|
+
},
|
|
162
|
+
execute: async ({ name } = {}) => {
|
|
163
|
+
const skill = registry._skills.get(name);
|
|
164
|
+
if (!skill) {
|
|
165
|
+
const known = [...registry._skills.keys()].join(', ') || '(none)';
|
|
166
|
+
throw new ToolError(`[skill_use] unknown skill "${name}". Registered skills: ${known}.`);
|
|
167
|
+
}
|
|
168
|
+
registry._unlocked.add(name);
|
|
169
|
+
return skill.instructions;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The current active tool set: `[metaTool, ...tools of every unlocked skill]`. Pass this method (bound)
|
|
176
|
+
* as the Loop `tools` thunk — it is re-evaluated each round, so tools unlocked this round are offered next.
|
|
177
|
+
* @returns {ToolDef[]}
|
|
178
|
+
*/
|
|
179
|
+
activeTools() {
|
|
180
|
+
/** @type {ToolDef[]} */
|
|
181
|
+
const out = [this.metaTool];
|
|
182
|
+
for (const name of this._unlocked) {
|
|
183
|
+
const skill = this._skills.get(name);
|
|
184
|
+
if (skill) out.push(...skill.tools);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Names of skills activated so far this run. @returns {string[]} */
|
|
190
|
+
unlocked() {
|
|
191
|
+
return [...this._unlocked];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Reset the unlocked set (e.g. between runs). The registered skills are kept. */
|
|
195
|
+
reset() {
|
|
196
|
+
this._unlocked.clear();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { SkillRegistry };
|
package/src/stash.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type ToolDef = import("../types").ToolDef;
|
|
2
|
+
/**
|
|
3
|
+
* Build the stash reference skill + the trim function that executes its folds.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} [options]
|
|
6
|
+
* @param {'summarize'|'stash'} [options.defaultStrategy='summarize'] - Strategy when the model omits one.
|
|
7
|
+
* 'summarize' (OQ5 lean / §2.11) is lossy; 'stash' is lossless. 'summarize' degrades to a lossless park
|
|
8
|
+
* when no `ctx.summarize` is wired (loud, never a silent detail-loss).
|
|
9
|
+
* @param {string} [options.keyPrefix='stash:'] - Namespace for stash/episode ids.
|
|
10
|
+
* @param {number} [options.maxLabels=128] - LRU backstop on DISTINCT live labels (§2.13) — visible, not silent.
|
|
11
|
+
* @param {Object} [options.compaction] - AUTOMATIC token-pressure trigger (§2.11/D12, all bareagent — a
|
|
12
|
+
* housekeeping threshold, NEVER a bareguard halt bound). Opt-in: omit, or omit `ceilingTokens`, → OFF
|
|
13
|
+
* (no guessed model→window table). Fires on the NEXT round's trim when the Loop's measured
|
|
14
|
+
* `ctx.usage.inputTokens / ceilingTokens > triggerAt`.
|
|
15
|
+
* @param {number} [options.compaction.ceilingTokens] - OPERATOR-SET context ceiling (enables auto-trigger).
|
|
16
|
+
* @param {number} [options.compaction.triggerAt=0.7] - Fraction of the ceiling that fires a fold.
|
|
17
|
+
* @param {'summarize'|'stash'} [options.compaction.strategy] - Strategy for auto-folds (default: defaultStrategy).
|
|
18
|
+
* @param {number} [options.compaction.keepHeadTurns=1] - Recent turns to keep at the START (initial context).
|
|
19
|
+
* @param {number} [options.compaction.keepRecentTurns=3] - Recent turns to keep at the END (live working set).
|
|
20
|
+
* @param {(msg: string) => void} [options.onNote=console.warn] - Sink for the loud one-time/backstop notes.
|
|
21
|
+
* @returns {{ skill: { name: string, description: string, instructions: string, tools: ToolDef[] }, trim: (msgs: any[], ctx: any) => Promise<any[]>, restoreHandles: () => string[] }}
|
|
22
|
+
*/
|
|
23
|
+
export function createStashSkill(options?: {
|
|
24
|
+
defaultStrategy?: "summarize" | "stash" | undefined;
|
|
25
|
+
keyPrefix?: string | undefined;
|
|
26
|
+
maxLabels?: number | undefined;
|
|
27
|
+
compaction?: {
|
|
28
|
+
ceilingTokens?: number | undefined;
|
|
29
|
+
triggerAt?: number | undefined;
|
|
30
|
+
strategy?: "summarize" | "stash" | undefined;
|
|
31
|
+
keepHeadTurns?: number | undefined;
|
|
32
|
+
keepRecentTurns?: number | undefined;
|
|
33
|
+
} | undefined;
|
|
34
|
+
onNote?: ((msg: string) => void) | undefined;
|
|
35
|
+
}): {
|
|
36
|
+
skill: {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
instructions: string;
|
|
40
|
+
tools: ToolDef[];
|
|
41
|
+
};
|
|
42
|
+
trim: (msgs: any[], ctx: any) => Promise<any[]>;
|
|
43
|
+
restoreHandles: () => string[];
|
|
44
|
+
};
|