@memorycrystal/crystal-memory 0.7.4
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/LICENSE +21 -0
- package/README.md +117 -0
- package/capture-hook.js +166 -0
- package/context-budget.js +71 -0
- package/context-budget.test.js +92 -0
- package/handler.js +247 -0
- package/index.js +1342 -0
- package/index.test.js +458 -0
- package/openclaw-hook.json +84 -0
- package/openclaw.plugin.json +61 -0
- package/package.json +37 -0
- package/recall-hook.js +456 -0
- package/reinforcement.test.js +105 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Illumin8 Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# crystal-memory — OpenClaw Plugin
|
|
2
|
+
|
|
3
|
+
Persistent memory for AI agents. Captures conversations, extracts durable memories, and injects relevant context before every response.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
curl -fsSL https://memorycrystal.ai/crystal | bash
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install manually from this repo:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
mkdir -p ~/.openclaw/extensions/crystal-memory
|
|
15
|
+
rsync -a \
|
|
16
|
+
--exclude node_modules \
|
|
17
|
+
--exclude '*.test.js' \
|
|
18
|
+
plugin/ ~/.openclaw/extensions/crystal-memory/
|
|
19
|
+
|
|
20
|
+
cd ~/.openclaw/extensions/crystal-memory && npm install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then enable the plugin in `~/.openclaw/openclaw.json` under `plugins.slots.memory`.
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
All schema-backed config is defined in `openclaw.plugin.json` under `configSchema.properties`:
|
|
28
|
+
|
|
29
|
+
| Key | Type | Default | Description |
|
|
30
|
+
|-----|------|---------|-------------|
|
|
31
|
+
| `apiKey` | string | — | Memory Crystal API key |
|
|
32
|
+
| `convexUrl` | string | `https://rightful-mockingbird-389.convex.site` | Convex backend URL |
|
|
33
|
+
| `defaultRecallMode` | string | `general` | Default recall mode (`general`, `decision`, `project`, `people`, `workflow`, `conversation`) |
|
|
34
|
+
| `defaultRecallLimit` | number | `8` | Memories to recall per query (`1`-`20`) |
|
|
35
|
+
| `channelScope` | string | — | Namespace prefix for tenant, client, or agent isolation |
|
|
36
|
+
| `localSummaryInjection` | boolean | `true` | Enable local summary injection |
|
|
37
|
+
| `localSummaryMaxTokens` | number | `2000` | Max tokens for local summaries |
|
|
38
|
+
|
|
39
|
+
## Files
|
|
40
|
+
|
|
41
|
+
| File | Purpose |
|
|
42
|
+
|------|---------|
|
|
43
|
+
| `index.js` | Main plugin entry point for the modern OpenClaw plugin API |
|
|
44
|
+
| `context-budget.js` | Model-aware context budget calculator |
|
|
45
|
+
| `openclaw.plugin.json` | Plugin manifest and config schema |
|
|
46
|
+
| `package.json` | npm metadata and optional dependencies |
|
|
47
|
+
| `compaction/` | Context compaction and summarization helpers |
|
|
48
|
+
| `tools/` | Local tool implementations |
|
|
49
|
+
| `utils/` | Shared plugin utilities |
|
|
50
|
+
| `store/` | Local SQLite-backed storage files |
|
|
51
|
+
|
|
52
|
+
## Hooks
|
|
53
|
+
|
|
54
|
+
The plugin registers hooks for these OpenClaw lifecycle events:
|
|
55
|
+
|
|
56
|
+
- `before_agent_start` — inject wake context and relevant recall
|
|
57
|
+
- `before_tool_call` — surface action-trigger warnings before risky tools
|
|
58
|
+
- `before_dispatch` — rate limiting, proactive recall, and reinforcement injection
|
|
59
|
+
- `message_received` — capture incoming user messages
|
|
60
|
+
- `llm_output` — capture assistant responses and extract durable memories
|
|
61
|
+
- `message_sent` — fallback assistant capture
|
|
62
|
+
- `session_end` — clear per-session state
|
|
63
|
+
|
|
64
|
+
It also watches `/new` and `/reset` command flows to trigger reflection behavior.
|
|
65
|
+
|
|
66
|
+
## Knowledge Bases
|
|
67
|
+
|
|
68
|
+
The plugin benefits from Knowledge Bases automatically through the same Memory Crystal backend used for recall. Use KBs for stable reference material like runbooks, policies, docs, and imported datasets while conversational memory continues to capture learned context.
|
|
69
|
+
|
|
70
|
+
- Scoped knowledge bases respect the same tenant and channel boundaries as the rest of Memory Crystal.
|
|
71
|
+
- KB management and direct query/import flows live on the MCP and HTTP API surfaces.
|
|
72
|
+
- Plugin recall can combine durable memory with scoped KB-backed reference material when relevant.
|
|
73
|
+
|
|
74
|
+
## Compaction Lifecycle
|
|
75
|
+
|
|
76
|
+
Memory Crystal owns the OpenClaw context-engine compaction path and preserves context across compaction boundaries:
|
|
77
|
+
|
|
78
|
+
- `before_compaction` — snapshot and checkpoint the source conversation before raw turns are condensed
|
|
79
|
+
- `after_compaction` — refresh local summary state so recall remains usable after compaction completes
|
|
80
|
+
|
|
81
|
+
## Procedural vs Skills
|
|
82
|
+
|
|
83
|
+
- **Procedural memories** are quiet execution patterns: repeated workflows, troubleshooting loops, and operator habits that help recall without needing explicit approval.
|
|
84
|
+
- **Skills** are curated artifacts promoted for deliberate agent use. Treat them as reviewed playbooks, not just ambient pattern extraction.
|
|
85
|
+
|
|
86
|
+
## Tools
|
|
87
|
+
|
|
88
|
+
`plugin/index.js` registers these tools directly via `api.registerTool()`:
|
|
89
|
+
|
|
90
|
+
- `crystal_set_scope` — override Memory Crystal channel scope for the current session
|
|
91
|
+
- `memory_search` — legacy compatibility search returning `crystal/<id>.md` paths
|
|
92
|
+
- `crystal_search_messages` — search short-term conversation logs
|
|
93
|
+
- `memory_get` — legacy compatibility read by memory ID or `crystal/<id>.md` path
|
|
94
|
+
- `crystal_recall` — semantic search across long-term memory
|
|
95
|
+
- `crystal_remember` — store a durable memory manually
|
|
96
|
+
- `crystal_what_do_i_know` — topic knowledge snapshot
|
|
97
|
+
- `crystal_why_did_we` — decision archaeology
|
|
98
|
+
- `crystal_checkpoint` — milestone memory snapshot
|
|
99
|
+
- `crystal_preflight` — pre-flight check returning relevant rules and lessons
|
|
100
|
+
- `crystal_recent` — fetch recent memory-backed messages
|
|
101
|
+
- `crystal_stats` — memory and usage statistics
|
|
102
|
+
- `crystal_forget` — archive or delete a memory
|
|
103
|
+
- `crystal_trace` — trace a memory back to its source conversation
|
|
104
|
+
- `crystal_wake` — session startup briefing
|
|
105
|
+
- `crystal_who_owns` — find ownership context for files, modules, or areas
|
|
106
|
+
- `crystal_explain_connection` — explain relationships between concepts
|
|
107
|
+
- `crystal_dependency_chain` — trace dependency chains
|
|
108
|
+
|
|
109
|
+
When the local store is available, the plugin also lazily registers:
|
|
110
|
+
|
|
111
|
+
- `crystal_grep` — search in-session local history and summaries
|
|
112
|
+
- `crystal_describe` — inspect a local summary node
|
|
113
|
+
- `crystal_expand` — expand a local summary into underlying context
|
|
114
|
+
|
|
115
|
+
## Version
|
|
116
|
+
|
|
117
|
+
Current: `v0.7.1`
|
package/capture-hook.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEPRECATED / LEGACY
|
|
3
|
+
* -------------------
|
|
4
|
+
* This file is a legacy duplicate of capture logic now consolidated into `index.js`.
|
|
5
|
+
* It was previously used by handler.js (via child_process.spawnSync) and as a
|
|
6
|
+
* standalone hook in older OpenClaw configurations.
|
|
7
|
+
*
|
|
8
|
+
* Canonical capture logic now lives in: `index.js`
|
|
9
|
+
* Do NOT delete — may be referenced by legacy configurations or handler.js.
|
|
10
|
+
*
|
|
11
|
+
* Crystal Capture plugin — captures conversation turns via MCP API
|
|
12
|
+
* Writes to crystalMemories (sensory store) with proper userId via API key auth
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONVEX_URL = "https://rightful-mockingbird-389.convex.site";
|
|
16
|
+
const pendingUserMessages = new Map();
|
|
17
|
+
|
|
18
|
+
function firstString(...values) {
|
|
19
|
+
for (const value of values) {
|
|
20
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
21
|
+
return value.trim();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function joinStringArray(values) {
|
|
28
|
+
if (!Array.isArray(values)) return "";
|
|
29
|
+
return values
|
|
30
|
+
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
31
|
+
.join("\n")
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractAssistantText(event) {
|
|
36
|
+
const direct = firstString(
|
|
37
|
+
joinStringArray(event?.assistantTexts),
|
|
38
|
+
joinStringArray(event?.texts),
|
|
39
|
+
joinStringArray(event?.outputs),
|
|
40
|
+
event?.lastAssistant,
|
|
41
|
+
event?.outputText,
|
|
42
|
+
event?.content,
|
|
43
|
+
event?.text,
|
|
44
|
+
event?.message?.content,
|
|
45
|
+
event?.message?.text,
|
|
46
|
+
event?.response?.content,
|
|
47
|
+
event?.response?.text,
|
|
48
|
+
event?.result?.content,
|
|
49
|
+
event?.result?.text
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (direct) {
|
|
53
|
+
return direct;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const candidates = [
|
|
57
|
+
event?.response?.messages,
|
|
58
|
+
event?.result?.messages,
|
|
59
|
+
event?.messages,
|
|
60
|
+
event?.response?.parts,
|
|
61
|
+
event?.result?.parts,
|
|
62
|
+
event?.parts,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
if (!Array.isArray(candidate)) continue;
|
|
67
|
+
const text = candidate
|
|
68
|
+
.map((item) =>
|
|
69
|
+
firstString(
|
|
70
|
+
item?.content,
|
|
71
|
+
item?.text,
|
|
72
|
+
item?.message?.content,
|
|
73
|
+
item?.message?.text
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.join("\n")
|
|
78
|
+
.trim();
|
|
79
|
+
if (text) {
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractUserText(event) {
|
|
88
|
+
return firstString(
|
|
89
|
+
event?.context?.content,
|
|
90
|
+
event?.content,
|
|
91
|
+
event?.text,
|
|
92
|
+
event?.message?.content,
|
|
93
|
+
event?.message?.text,
|
|
94
|
+
event?.input,
|
|
95
|
+
event?.prompt
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function captureToMCP(apiKey, convexUrl, payload) {
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(`${convexUrl}/api/mcp/capture`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: {
|
|
104
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(payload),
|
|
108
|
+
});
|
|
109
|
+
return res.ok;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = (api) => {
|
|
116
|
+
// Get API key from config or env
|
|
117
|
+
const getConfig = (ctx) => {
|
|
118
|
+
const apiKey =
|
|
119
|
+
ctx?.config?.apiKey ||
|
|
120
|
+
process.env.CRYSTAL_API_KEY ||
|
|
121
|
+
api.config?.apiKey;
|
|
122
|
+
const convexUrl =
|
|
123
|
+
ctx?.config?.convexUrl ||
|
|
124
|
+
process.env.CRYSTAL_CONVEX_URL ||
|
|
125
|
+
DEFAULT_CONVEX_URL;
|
|
126
|
+
return { apiKey, convexUrl };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Capture user message before each turn
|
|
130
|
+
api.on("message_received", (event, ctx) => {
|
|
131
|
+
const text = extractUserText(event);
|
|
132
|
+
if (text && ctx?.sessionKey) {
|
|
133
|
+
pendingUserMessages.set(ctx.sessionKey, String(text));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Fire capture after each LLM response
|
|
138
|
+
api.on("llm_output", async (event, ctx) => {
|
|
139
|
+
const assistantText = extractAssistantText(event);
|
|
140
|
+
if (!assistantText) return;
|
|
141
|
+
|
|
142
|
+
const { apiKey, convexUrl } = getConfig(ctx);
|
|
143
|
+
if (!apiKey) return;
|
|
144
|
+
|
|
145
|
+
const sessionKey = ctx?.sessionKey || "";
|
|
146
|
+
const channel = ctx?.messageProvider || "openclaw";
|
|
147
|
+
const userMessage = sessionKey ? (pendingUserMessages.get(sessionKey) || "") : "";
|
|
148
|
+
if (sessionKey) pendingUserMessages.delete(sessionKey);
|
|
149
|
+
|
|
150
|
+
const content = [
|
|
151
|
+
userMessage ? `User: ${userMessage}` : null,
|
|
152
|
+
`Assistant: ${assistantText}`,
|
|
153
|
+
].filter(Boolean).join("\n\n");
|
|
154
|
+
|
|
155
|
+
await captureToMCP(apiKey, convexUrl, {
|
|
156
|
+
title: `Conversation — ${new Date().toISOString().slice(0, 16).replace("T", " ")}`,
|
|
157
|
+
content,
|
|
158
|
+
store: "sensory",
|
|
159
|
+
category: "conversation",
|
|
160
|
+
tags: ["openclaw", "auto-capture", channel],
|
|
161
|
+
channel,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
api.logger?.info?.("[crystal] capture hooks registered (message_received + llm_output)");
|
|
166
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// context-budget.js — Model-aware injection budget calculator
|
|
2
|
+
//
|
|
3
|
+
// Memory Crystal injects context (recall results, recent messages, etc.) into
|
|
4
|
+
// the agent's system prompt. This module ensures we don't blow past the model's
|
|
5
|
+
// effective context capacity. Research shows effective capacity is ~60-70% of
|
|
6
|
+
// advertised max, and past that hallucination climbs.
|
|
7
|
+
|
|
8
|
+
const MODEL_EFFECTIVE_CAPACITY = {
|
|
9
|
+
"claude-opus": { maxTokens: 1000000, effectiveTokens: 600000, safeInjectionPct: 0.15 },
|
|
10
|
+
"claude-sonnet": { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
|
|
11
|
+
"claude-haiku": { maxTokens: 200000, effectiveTokens: 120000, safeInjectionPct: 0.12 },
|
|
12
|
+
"gpt-5": { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
|
|
13
|
+
"gpt-4.1": { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
|
|
14
|
+
"gpt-4o": { maxTokens: 128000, effectiveTokens: 80000, safeInjectionPct: 0.12 },
|
|
15
|
+
"gemini-2.5-pro": { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
|
|
16
|
+
"gemini-2.5-flash": { maxTokens: 1000000, effectiveTokens: 400000, safeInjectionPct: 0.12 },
|
|
17
|
+
"gemini-3-pro": { maxTokens: 2000000, effectiveTokens: 800000, safeInjectionPct: 0.15 },
|
|
18
|
+
"gemini-3-flash": { maxTokens: 1000000, effectiveTokens: 400000, safeInjectionPct: 0.12 },
|
|
19
|
+
codex: { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
|
|
20
|
+
default: { maxTokens: 128000, effectiveTokens: 75000, safeInjectionPct: 0.10 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function getModelCapacity(modelName) {
|
|
24
|
+
const normalized = String(modelName || "").toLowerCase();
|
|
25
|
+
for (const [key, capacity] of Object.entries(MODEL_EFFECTIVE_CAPACITY)) {
|
|
26
|
+
if (key === "default") continue;
|
|
27
|
+
if (normalized.includes(key)) return capacity;
|
|
28
|
+
}
|
|
29
|
+
return MODEL_EFFECTIVE_CAPACITY.default;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getInjectionBudget(modelName) {
|
|
33
|
+
const cap = getModelCapacity(modelName);
|
|
34
|
+
const maxTokens = Math.floor(cap.effectiveTokens * cap.safeInjectionPct);
|
|
35
|
+
return {
|
|
36
|
+
maxChars: maxTokens * 4,
|
|
37
|
+
maxTokens,
|
|
38
|
+
model: modelName,
|
|
39
|
+
effectiveCapacity: cap.effectiveTokens,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Trims an array of labeled sections to fit within a character budget.
|
|
45
|
+
* Drops lowest-priority sections first.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<{label: string, text: string}>} sections - Sections to trim
|
|
48
|
+
* @param {number} maxChars - Maximum total characters
|
|
49
|
+
* @param {string[]} dropOrder - Labels ordered from lowest to highest priority
|
|
50
|
+
* @returns {Array<{label: string, text: string}>} Trimmed sections
|
|
51
|
+
*/
|
|
52
|
+
function trimSections(sections, maxChars, dropOrder) {
|
|
53
|
+
let totalChars = sections.reduce((sum, s) => sum + s.text.length, 0);
|
|
54
|
+
if (totalChars <= maxChars) return sections;
|
|
55
|
+
|
|
56
|
+
const result = [...sections];
|
|
57
|
+
for (const label of dropOrder) {
|
|
58
|
+
// Drop ALL sections matching this label (handles duplicate labels)
|
|
59
|
+
for (let i = result.length - 1; i >= 0; i--) {
|
|
60
|
+
if (totalChars <= maxChars) break;
|
|
61
|
+
if (result[i].label === label) {
|
|
62
|
+
totalChars -= result[i].text.length;
|
|
63
|
+
result.splice(i, 1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (totalChars <= maxChars) break;
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { MODEL_EFFECTIVE_CAPACITY, getModelCapacity, getInjectionBudget, trimSections };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const { getModelCapacity, getInjectionBudget, trimSections } = require("./context-budget");
|
|
2
|
+
|
|
3
|
+
let passed = 0;
|
|
4
|
+
let failed = 0;
|
|
5
|
+
|
|
6
|
+
function test(name, fn) {
|
|
7
|
+
try {
|
|
8
|
+
fn();
|
|
9
|
+
passed++;
|
|
10
|
+
console.log(` PASS: ${name}`);
|
|
11
|
+
} catch (err) {
|
|
12
|
+
failed++;
|
|
13
|
+
console.error(` FAIL: ${name} — ${err.message}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assert(condition, msg) {
|
|
18
|
+
if (!condition) throw new Error(msg || "assertion failed");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log("context-budget tests:");
|
|
22
|
+
|
|
23
|
+
test("getInjectionBudget for claude-opus returns opus capacity", () => {
|
|
24
|
+
const budget = getInjectionBudget("claude-opus-4-6");
|
|
25
|
+
assert(budget.maxTokens === Math.floor(600000 * 0.15), `expected ${Math.floor(600000 * 0.15)}, got ${budget.maxTokens}`);
|
|
26
|
+
assert(budget.effectiveCapacity === 600000, `expected 600000, got ${budget.effectiveCapacity}`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("getInjectionBudget for gpt-4o returns gpt-4o capacity", () => {
|
|
30
|
+
const budget = getInjectionBudget("gpt-4o-mini");
|
|
31
|
+
assert(budget.maxTokens === Math.floor(80000 * 0.12), `expected ${Math.floor(80000 * 0.12)}, got ${budget.maxTokens}`);
|
|
32
|
+
assert(budget.effectiveCapacity === 80000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("getInjectionBudget for unknown model returns default", () => {
|
|
36
|
+
const budget = getInjectionBudget("unknown-model-xyz");
|
|
37
|
+
assert(budget.effectiveCapacity === 75000, `expected 75000, got ${budget.effectiveCapacity}`);
|
|
38
|
+
assert(budget.maxTokens === Math.floor(75000 * 0.10));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("getInjectionBudget for empty string returns default", () => {
|
|
42
|
+
const budget = getInjectionBudget("");
|
|
43
|
+
assert(budget.effectiveCapacity === 75000);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("128K model budget is smaller than 1M model budget", () => {
|
|
47
|
+
const small = getInjectionBudget("gpt-4o");
|
|
48
|
+
const large = getInjectionBudget("claude-opus-4");
|
|
49
|
+
assert(small.maxChars < large.maxChars, `${small.maxChars} should be < ${large.maxChars}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("getModelCapacity matches partial model names", () => {
|
|
53
|
+
const opus = getModelCapacity("anthropic/claude-opus-4-6");
|
|
54
|
+
assert(opus.effectiveTokens === 600000, `expected 600000, got ${opus.effectiveTokens}`);
|
|
55
|
+
|
|
56
|
+
const codex = getModelCapacity("openai-codex/gpt-5.3-codex");
|
|
57
|
+
// Should match gpt-5 or codex
|
|
58
|
+
assert(codex.effectiveTokens >= 500000, `expected >= 500000, got ${codex.effectiveTokens}`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("trimSections returns all sections when under budget", () => {
|
|
62
|
+
const sections = [
|
|
63
|
+
{ label: "A", text: "hello" },
|
|
64
|
+
{ label: "B", text: "world" },
|
|
65
|
+
];
|
|
66
|
+
const result = trimSections(sections, 1000, ["A", "B"]);
|
|
67
|
+
assert(result.length === 2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("trimSections drops lowest-priority first", () => {
|
|
71
|
+
const sections = [
|
|
72
|
+
{ label: "Recent Context", text: "x".repeat(500) },
|
|
73
|
+
{ label: "Relevant Recall", text: "y".repeat(500) },
|
|
74
|
+
];
|
|
75
|
+
const result = trimSections(sections, 600, ["Recent Context", "Relevant Recall"]);
|
|
76
|
+
assert(result.length === 1, `expected 1, got ${result.length}`);
|
|
77
|
+
assert(result[0].label === "Relevant Recall", `expected Relevant Recall, got ${result[0].label}`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("trimSections drops multiple sections if needed", () => {
|
|
81
|
+
const sections = [
|
|
82
|
+
{ label: "A", text: "x".repeat(300) },
|
|
83
|
+
{ label: "B", text: "y".repeat(300) },
|
|
84
|
+
{ label: "C", text: "z".repeat(300) },
|
|
85
|
+
];
|
|
86
|
+
const result = trimSections(sections, 350, ["A", "B", "C"]);
|
|
87
|
+
assert(result.length === 1, `expected 1, got ${result.length}`);
|
|
88
|
+
assert(result[0].label === "C");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
92
|
+
if (failed > 0) process.exit(1);
|