@martian-engineering/lossless-claw 0.2.5 → 0.2.7

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Lossless Context Management plugin for [OpenClaw](https://github.com/openclaw/openclaw), based on the [LCM paper](https://papers.voltropy.com/LCM). Replaces OpenClaw's built-in sliding-window compaction with a DAG-based summarization system that preserves every message while keeping active context within model token limits.
4
4
 
5
+ ## Table of contents
6
+
7
+ - [What it does](#what-it-does)
8
+ - [Quick start](#quick-start)
9
+ - [Configuration](#configuration)
10
+ - [Documentation](#documentation)
11
+ - [Development](#development)
12
+ - [License](#license)
13
+
5
14
  ## What it does
6
15
 
7
16
  Two ways to learn: read the below, or [check out this super cool animated visualization](https://losslesscontext.ai).
@@ -18,7 +27,7 @@ Nothing is lost. Raw messages stay in the database. Summaries link back to their
18
27
 
19
28
  **It feels like talking to an agent that never forgets. Because it doesn't. In normal operation, you'll never need to think about compaction again.**
20
29
 
21
- ## Installation
30
+ ## Quick start
22
31
 
23
32
  ### Prerequisites
24
33
 
@@ -68,168 +77,6 @@ If you need to set it manually, ensure the context engine slot points at lossles
68
77
 
69
78
  Restart OpenClaw after configuration changes.
70
79
 
71
- ### Optional: enable FTS5 for fast full-text search
72
-
73
- `lossless-claw` works without FTS5 as of the current release. When FTS5 is unavailable in the
74
- Node runtime that runs the OpenClaw gateway, the plugin:
75
-
76
- - keeps persisting messages and summaries
77
- - falls back from `"full_text"` search to a slower `LIKE`-based search
78
- - loses FTS ranking/snippet quality
79
-
80
- If you want native FTS5 search performance and ranking, the **exact Node runtime that runs the
81
- gateway** must have SQLite FTS5 compiled in.
82
-
83
- #### Probe the gateway runtime
84
-
85
- Run this with the same `node` binary your gateway uses:
86
-
87
- ```bash
88
- node --input-type=module - <<'NODE'
89
- import { DatabaseSync } from 'node:sqlite';
90
- const db = new DatabaseSync(':memory:');
91
- const options = db.prepare('pragma compile_options').all().map((row) => row.compile_options);
92
-
93
- console.log(options.filter((value) => value.includes('FTS')).join('\n') || 'no fts compile options');
94
-
95
- try {
96
- db.exec("CREATE VIRTUAL TABLE t USING fts5(content)");
97
- console.log("fts5: ok");
98
- } catch (err) {
99
- console.log("fts5: fail");
100
- console.log(err instanceof Error ? err.message : String(err));
101
- }
102
- NODE
103
- ```
104
-
105
- Expected output:
106
-
107
- ```text
108
- ENABLE_FTS5
109
- fts5: ok
110
- ```
111
-
112
- If you get `fts5: fail`, build or install an FTS5-capable Node and point the gateway at that runtime.
113
-
114
- #### Build an FTS5-capable Node on macOS
115
-
116
- This workflow was verified with Node `v22.15.0`.
117
-
118
- ```bash
119
- cd ~/Projects
120
- git clone --depth 1 --branch v22.15.0 https://github.com/nodejs/node.git node-fts5
121
- cd node-fts5
122
- ```
123
-
124
- Edit `deps/sqlite/sqlite.gyp` and add `SQLITE_ENABLE_FTS5` to the `defines` list for the `sqlite`
125
- target:
126
-
127
- ```diff
128
- 'defines': [
129
- 'SQLITE_DEFAULT_MEMSTATUS=0',
130
- + 'SQLITE_ENABLE_FTS5',
131
- 'SQLITE_ENABLE_MATH_FUNCTIONS',
132
- 'SQLITE_ENABLE_SESSION',
133
- 'SQLITE_ENABLE_PREUPDATE_HOOK'
134
- ],
135
- ```
136
-
137
- Important:
138
-
139
- - patch `deps/sqlite/sqlite.gyp`, not only `node.gyp`
140
- - `node:sqlite` uses the embedded SQLite built from `deps/sqlite/sqlite.gyp`
141
-
142
- Build the runtime:
143
-
144
- ```bash
145
- ./configure --prefix="$PWD/out-install"
146
- make -j8 node
147
- ```
148
-
149
- Expose the binary under a Node-compatible basename that OpenClaw recognizes:
150
-
151
- ```bash
152
- mkdir -p ~/Projects/node-fts5/bin
153
- ln -sfn ~/Projects/node-fts5/out/Release/node ~/Projects/node-fts5/bin/node-22.15.0
154
- ```
155
-
156
- Use a basename like `node-22.15.0`, `node`, or `nodejs`. Names like
157
- `node-v22.15.0-fts5` may not be recognized correctly by OpenClaw's CLI/runtime parsing.
158
-
159
- Verify the new runtime:
160
-
161
- ```bash
162
- ~/Projects/node-fts5/bin/node-22.15.0 --version
163
- ~/Projects/node-fts5/bin/node-22.15.0 --input-type=module - <<'NODE'
164
- import { DatabaseSync } from 'node:sqlite';
165
- const db = new DatabaseSync(':memory:');
166
- db.exec("CREATE VIRTUAL TABLE t USING fts5(content)");
167
- console.log("fts5: ok");
168
- NODE
169
- ```
170
-
171
- #### Point the OpenClaw gateway at that runtime on macOS
172
-
173
- Back up the existing LaunchAgent plist first:
174
-
175
- ```bash
176
- cp ~/Library/LaunchAgents/ai.openclaw.gateway.plist \
177
- ~/Library/LaunchAgents/ai.openclaw.gateway.plist.bak-$(date +%Y%m%d-%H%M%S)
178
- ```
179
-
180
- Replace the runtime path, then reload the agent:
181
-
182
- ```bash
183
- /usr/libexec/PlistBuddy -c 'Set :ProgramArguments:0 /Users/youruser/Projects/node-fts5/bin/node-22.15.0' \
184
- ~/Library/LaunchAgents/ai.openclaw.gateway.plist
185
-
186
- launchctl bootout gui/$UID ~/Library/LaunchAgents/ai.openclaw.gateway.plist 2>/dev/null || true
187
- launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.openclaw.gateway.plist
188
- launchctl kickstart -k gui/$UID/ai.openclaw.gateway
189
- ```
190
-
191
- Verify the live runtime:
192
-
193
- ```bash
194
- launchctl print gui/$UID/ai.openclaw.gateway | sed -n '1,80p'
195
- ```
196
-
197
- You should see:
198
-
199
- ```text
200
- program = /Users/youruser/Projects/node-fts5/bin/node-22.15.0
201
- ```
202
-
203
- #### Verify `lossless-claw`
204
-
205
- Check the logs:
206
-
207
- ```bash
208
- tail -n 60 ~/.openclaw/logs/gateway.log
209
- tail -n 60 ~/.openclaw/logs/gateway.err.log
210
- ```
211
-
212
- You want:
213
-
214
- - `[gateway] [lcm] Plugin loaded ...`
215
- - no new `no such module: fts5`
216
-
217
- Then force one turn through the gateway and verify the DB fills:
218
-
219
- ```bash
220
- /Users/youruser/Projects/node-fts5/bin/node-22.15.0 \
221
- /path/to/openclaw/dist/index.js \
222
- agent --session-id fts5-smoke --message 'Reply with exactly: ok' --timeout 60
223
-
224
- sqlite3 ~/.openclaw/lcm.db '
225
- select count(*) as conversations from conversations;
226
- select count(*) as messages from messages;
227
- select count(*) as summaries from summaries;
228
- '
229
- ```
230
-
231
- Those counts should increase after a real turn.
232
-
233
80
  ## Configuration
234
81
 
235
82
  LCM is configured through a combination of plugin config and environment variables. Environment variables take precedence for backward compatibility.
@@ -332,212 +179,14 @@ For most long-lived LCM setups, a good starting point is:
332
179
  }
333
180
  ```
334
181
 
335
- ## How it works
336
-
337
- See [docs/architecture.md](docs/architecture.md) for the full technical deep-dive. Here's the summary:
338
-
339
- ### The DAG
340
-
341
- LCM builds a directed acyclic graph of summaries:
342
-
343
- ```
344
- Raw messages → Leaf summaries (d0) → Condensed (d1) → Condensed (d2) → ...
345
- ```
346
-
347
- - **Leaf summaries** (depth 0) are created from chunks of raw messages. They preserve timestamps, decisions, file operations, and key details.
348
- - **Condensed summaries** (depth 1+) merge multiple summaries at the same depth into a higher-level node. Each depth tier uses a different prompt strategy optimized for its level of abstraction.
349
- - **Parent links** connect each condensed summary to its source summaries, enabling drill-down via `lcm_expand_query`.
350
-
351
- ### Context assembly
352
-
353
- Each turn, the assembler builds model context by:
354
-
355
- 1. Fetching the conversation's **context items** (an ordered list of summary and message references)
356
- 2. Resolving each item into an `AgentMessage`
357
- 3. Protecting the **fresh tail** (most recent N messages) from eviction
358
- 4. Filling remaining token budget from oldest to newest, dropping the oldest items first if over budget
359
- 5. Wrapping summaries in XML with metadata (id, depth, timestamps, descendant count)
360
-
361
- The model sees something like:
362
-
363
- ```xml
364
- <summary id="sum_abc123" kind="condensed" depth="1" descendant_count="8"
365
- earliest_at="2026-02-17T07:37:00" latest_at="2026-02-17T15:43:00">
366
- <parents>
367
- <summary_ref id="sum_def456" />
368
- <summary_ref id="sum_ghi789" />
369
- </parents>
370
- <content>
371
- ...summary text...
372
- </content>
373
- </summary>
374
- ```
375
-
376
- This gives the model enough information to know what was discussed, when, and how to drill deeper via the expansion tools.
377
-
378
- ### Compaction triggers
379
-
380
- Compaction runs in two modes:
381
-
382
- - **Proactive (after each turn):** If raw messages outside the fresh tail exceed `leafChunkTokens`, a leaf pass runs. If `incrementalMaxDepth != 0`, condensation follows (cascading to the configured depth, or unlimited with `-1`).
383
- - **Reactive (overflow/manual):** When total context exceeds `contextThreshold × tokenBudget`, a full sweep runs: all eligible leaf chunks are compacted, then condensation proceeds depth-by-depth until stable.
384
-
385
- ### Depth-aware prompts
386
-
387
- Each summary depth gets a tailored prompt:
388
-
389
- | Depth | Kind | Strategy |
390
- |-------|------|----------|
391
- | 0 | Leaf | Narrative with timestamps, file tracking, preserves operational detail |
392
- | 1 | Condensed | Chronological session summary, deduplicates against `previous_context` |
393
- | 2 | Condensed | Arc-focused: goals, outcomes, what carries forward. Self-contained. |
394
- | 3+ | Condensed | Durable context only: key decisions, relationships, lessons learned |
395
-
396
- All summaries end with an "Expand for details about:" footer listing what was compressed, guiding agents on when to use `lcm_expand_query`.
397
-
398
- ### Large file handling
399
-
400
- Files over `largeFileTokenThreshold` (default 25k tokens) embedded in messages are intercepted during ingestion:
401
-
402
- 1. Content is stored to `~/.openclaw/lcm-files/<conversation_id>/<file_id>.<ext>`
403
- 2. A ~200 token exploration summary replaces the file in the message
404
- 3. The `lcm_describe` tool can retrieve the full file content on demand
405
-
406
- This prevents large file pastes from consuming the entire context window.
407
-
408
- ## Agent tools
409
-
410
- LCM registers four tools that agents can use to search and recall compacted history:
411
-
412
- ### `lcm_grep`
413
-
414
- Full-text and regex search across messages and summaries.
415
-
416
- ```
417
- lcm_grep(pattern: "database migration", mode: "full_text")
418
- lcm_grep(pattern: "config\\.threshold", mode: "regex", scope: "summaries")
419
- ```
420
-
421
- Parameters:
422
- - `pattern` — Search string (regex or full-text)
423
- - `mode` — `"regex"` (default) or `"full_text"`
424
- - `scope` — `"messages"`, `"summaries"`, or `"both"` (default)
425
- - `conversationId` — Scope to a specific conversation
426
- - `allConversations` — Search across all conversations
427
- - `since` / `before` — ISO timestamp filters
428
- - `limit` — Max results (default 50, max 200)
429
-
430
- ### `lcm_describe`
431
-
432
- Inspect a specific summary or stored file by ID.
433
-
434
- ```
435
- lcm_describe(id: "sum_abc123")
436
- lcm_describe(id: "file_def456")
437
- ```
438
-
439
- Returns the full content, metadata, parent/child relationships, and token counts. For files, returns the stored content.
440
-
441
- ### `lcm_expand_query`
442
-
443
- Deep recall via delegated sub-agent. Finds relevant summaries, expands them by walking the DAG down to source material, and answers a focused question.
444
-
445
- ```
446
- lcm_expand_query(
447
- query: "database migration",
448
- prompt: "What migration strategy was decided on?"
449
- )
450
-
451
- lcm_expand_query(
452
- summaryIds: ["sum_abc123"],
453
- prompt: "What were the exact config changes?"
454
- )
455
- ```
456
-
457
- Parameters:
458
- - `prompt` — The question to answer (required)
459
- - `query` — Text query to find relevant summaries (when you don't have IDs)
460
- - `summaryIds` — Specific summary IDs to expand (when you have them)
461
- - `maxTokens` — Answer length cap (default 2000)
462
- - `conversationId` / `allConversations` — Scope control
463
-
464
- Returns a compact answer with cited summary IDs.
465
-
466
- ### `lcm_expand`
467
-
468
- Low-level DAG expansion (sub-agent only). Main agents should use `lcm_expand_query` instead; this tool is available to delegated sub-agents spawned by `lcm_expand_query`.
469
-
470
- ## TUI
471
-
472
- The repo includes an interactive terminal UI (`tui/`) for inspecting, repairing, and managing the LCM database. It's a separate Go binary — not part of the npm package.
473
-
474
- ### Install
475
-
476
- **From GitHub releases** (recommended):
477
-
478
- Download the latest binary for your platform from [Releases](https://github.com/Martian-Engineering/lossless-claw/releases).
479
-
480
- **Build from source:**
481
-
482
- ```bash
483
- cd tui
484
- go build -o lcm-tui .
485
- # or: make build
486
- # or: go install github.com/Martian-Engineering/lossless-claw/tui@latest
487
- ```
488
-
489
- Requires Go 1.24+.
490
-
491
- ### Usage
492
-
493
- ```bash
494
- lcm-tui [--db path/to/lcm.db] [--sessions path/to/sessions/dir]
495
- ```
182
+ ## Documentation
496
183
 
497
- Defaults to `~/.openclaw/lcm.db` and auto-discovers session directories.
498
-
499
- ### Features
500
-
501
- - **Conversation browser** — List all conversations with message/summary counts and token totals
502
- - **Summary DAG view** Navigate the full summary hierarchy with depth, kind, token counts, and parent/child relationships
503
- - **Context view** — See exactly what the model sees: ordered context items with token breakdowns (summaries + fresh tail messages)
504
- - **Dissolve** — Surgically restore a condensed summary back to its parent summaries (with ordinal shift preview)
505
- - **Rewrite** — Re-summarize nodes using actual OpenClaw prompts with scrollable diffs and auto-accept mode
506
- - **Repair** — Fix corrupted summaries (fallback truncations, empty content) using proper LLM summarization
507
- - **Transplant** — Deep-copy summary DAGs between conversations (preserves all messages, message_parts, summary_messages)
508
- - **Previous context viewer** — Inspect the `previous_context` text used during summarization
509
-
510
- ### Keybindings
511
-
512
- | Key | Action |
513
- |-----|--------|
514
- | `c` | Context view (from conversation list) |
515
- | `s` | Summary DAG view |
516
- | `d` | Dissolve a condensed summary |
517
- | `r` | Rewrite a summary |
518
- | `R` | Repair corrupted summaries |
519
- | `t` | Transplant summaries between conversations |
520
- | `p` | View previous_context |
521
- | `Enter` | Expand/select |
522
- | `Esc`/`q` | Back/quit |
523
-
524
- ## Database
525
-
526
- LCM uses SQLite via Node's built-in `node:sqlite` module. The default database path is `~/.openclaw/lcm.db`.
527
-
528
- ### Schema overview
529
-
530
- - **conversations** — Maps session IDs to conversation IDs
531
- - **messages** — Every ingested message with role, content, token count, timestamps
532
- - **message_parts** — Structured content blocks (text, tool calls, reasoning, files) linked to messages
533
- - **summaries** — The summary DAG nodes with content, depth, kind, token counts, timestamps
534
- - **summary_messages** — Links leaf summaries to their source messages
535
- - **summary_parents** — Links condensed summaries to their parent summaries
536
- - **context_items** — The ordered context list for each conversation (what the model sees)
537
- - **large_files** — Metadata for intercepted large files
538
- - **expansion_grants** — Delegation grants for sub-agent expansion queries
539
-
540
- Migrations run automatically on first use. The schema is forward-compatible; new columns are added with defaults.
184
+ - [Configuration guide](docs/configuration.md)
185
+ - [Architecture](docs/architecture.md)
186
+ - [Agent tools](docs/agent-tools.md)
187
+ - [TUI Reference](docs/tui.md)
188
+ - [lcm-tui](tui/README.md)
189
+ - [Optional: enable FTS5 for fast full-text search](docs/fts5.md)
541
190
 
542
191
  ## Development
543
192
 
package/docs/fts5.md ADDED
@@ -0,0 +1,161 @@
1
+ # Optional: enable FTS5 for fast full-text search
2
+
3
+ `lossless-claw` works without FTS5 as of the current release. When FTS5 is unavailable in the
4
+ Node runtime that runs the OpenClaw gateway, the plugin:
5
+
6
+ - keeps persisting messages and summaries
7
+ - falls back from `"full_text"` search to a slower `LIKE`-based search
8
+ - loses FTS ranking/snippet quality
9
+
10
+ If you want native FTS5 search performance and ranking, the **exact Node runtime that runs the
11
+ gateway** must have SQLite FTS5 compiled in.
12
+
13
+ ## Probe the gateway runtime
14
+
15
+ Run this with the same `node` binary your gateway uses:
16
+
17
+ ```bash
18
+ node --input-type=module - <<'NODE'
19
+ import { DatabaseSync } from 'node:sqlite';
20
+ const db = new DatabaseSync(':memory:');
21
+ const options = db.prepare('pragma compile_options').all().map((row) => row.compile_options);
22
+
23
+ console.log(options.filter((value) => value.includes('FTS')).join('\n') || 'no fts compile options');
24
+
25
+ try {
26
+ db.exec("CREATE VIRTUAL TABLE t USING fts5(content)");
27
+ console.log("fts5: ok");
28
+ } catch (err) {
29
+ console.log("fts5: fail");
30
+ console.log(err instanceof Error ? err.message : String(err));
31
+ }
32
+ NODE
33
+ ```
34
+
35
+ Expected output:
36
+
37
+ ```text
38
+ ENABLE_FTS5
39
+ fts5: ok
40
+ ```
41
+
42
+ If you get `fts5: fail`, build or install an FTS5-capable Node and point the gateway at that runtime.
43
+
44
+ ## Build an FTS5-capable Node on macOS
45
+
46
+ This workflow was verified with Node `v22.15.0`.
47
+
48
+ ```bash
49
+ cd ~/Projects
50
+ git clone --depth 1 --branch v22.15.0 https://github.com/nodejs/node.git node-fts5
51
+ cd node-fts5
52
+ ```
53
+
54
+ Edit `deps/sqlite/sqlite.gyp` and add `SQLITE_ENABLE_FTS5` to the `defines` list for the `sqlite`
55
+ target:
56
+
57
+ ```diff
58
+ 'defines': [
59
+ 'SQLITE_DEFAULT_MEMSTATUS=0',
60
+ + 'SQLITE_ENABLE_FTS5',
61
+ 'SQLITE_ENABLE_MATH_FUNCTIONS',
62
+ 'SQLITE_ENABLE_SESSION',
63
+ 'SQLITE_ENABLE_PREUPDATE_HOOK'
64
+ ],
65
+ ```
66
+
67
+ Important:
68
+
69
+ - patch `deps/sqlite/sqlite.gyp`, not only `node.gyp`
70
+ - `node:sqlite` uses the embedded SQLite built from `deps/sqlite/sqlite.gyp`
71
+
72
+ Build the runtime:
73
+
74
+ ```bash
75
+ ./configure --prefix="$PWD/out-install"
76
+ make -j8 node
77
+ ```
78
+
79
+ Expose the binary under a Node-compatible basename that OpenClaw recognizes:
80
+
81
+ ```bash
82
+ mkdir -p ~/Projects/node-fts5/bin
83
+ ln -sfn ~/Projects/node-fts5/out/Release/node ~/Projects/node-fts5/bin/node-22.15.0
84
+ ```
85
+
86
+ Use a basename like `node-22.15.0`, `node`, or `nodejs`. Names like
87
+ `node-v22.15.0-fts5` may not be recognized correctly by OpenClaw's CLI/runtime parsing.
88
+
89
+ Verify the new runtime:
90
+
91
+ ```bash
92
+ ~/Projects/node-fts5/bin/node-22.15.0 --version
93
+ ~/Projects/node-fts5/bin/node-22.15.0 --input-type=module - <<'NODE'
94
+ import { DatabaseSync } from 'node:sqlite';
95
+ const db = new DatabaseSync(':memory:');
96
+ db.exec("CREATE VIRTUAL TABLE t USING fts5(content)");
97
+ console.log("fts5: ok");
98
+ NODE
99
+ ```
100
+
101
+ ## Point the OpenClaw gateway at that runtime on macOS
102
+
103
+ Back up the existing LaunchAgent plist first:
104
+
105
+ ```bash
106
+ cp ~/Library/LaunchAgents/ai.openclaw.gateway.plist \
107
+ ~/Library/LaunchAgents/ai.openclaw.gateway.plist.bak-$(date +%Y%m%d-%H%M%S)
108
+ ```
109
+
110
+ Replace the runtime path, then reload the agent:
111
+
112
+ ```bash
113
+ /usr/libexec/PlistBuddy -c 'Set :ProgramArguments:0 /Users/youruser/Projects/node-fts5/bin/node-22.15.0' \
114
+ ~/Library/LaunchAgents/ai.openclaw.gateway.plist
115
+
116
+ launchctl bootout gui/$UID ~/Library/LaunchAgents/ai.openclaw.gateway.plist 2>/dev/null || true
117
+ launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.openclaw.gateway.plist
118
+ launchctl kickstart -k gui/$UID/ai.openclaw.gateway
119
+ ```
120
+
121
+ Verify the live runtime:
122
+
123
+ ```bash
124
+ launchctl print gui/$UID/ai.openclaw.gateway | sed -n '1,80p'
125
+ ```
126
+
127
+ You should see:
128
+
129
+ ```text
130
+ program = /Users/youruser/Projects/node-fts5/bin/node-22.15.0
131
+ ```
132
+
133
+ ## Verify `lossless-claw`
134
+
135
+ Check the logs:
136
+
137
+ ```bash
138
+ tail -n 60 ~/.openclaw/logs/gateway.log
139
+ tail -n 60 ~/.openclaw/logs/gateway.err.log
140
+ ```
141
+
142
+ You want:
143
+
144
+ - `[gateway] [lcm] Plugin loaded ...`
145
+ - no new `no such module: fts5`
146
+
147
+ Then force one turn through the gateway and verify the DB fills:
148
+
149
+ ```bash
150
+ /Users/youruser/Projects/node-fts5/bin/node-22.15.0 \
151
+ /path/to/openclaw/dist/index.js \
152
+ agent --session-id fts5-smoke --message 'Reply with exactly: ok' --timeout 60
153
+
154
+ sqlite3 ~/.openclaw/lcm.db '
155
+ select count(*) as conversations from conversations;
156
+ select count(*) as messages from messages;
157
+ select count(*) as summaries from summaries;
158
+ '
159
+ ```
160
+
161
+ Those counts should increase after a real turn.
package/index.ts CHANGED
@@ -4,8 +4,7 @@
4
4
  * DAG-based conversation summarization with incremental compaction,
5
5
  * full-text search, and sub-agent expansion.
6
6
  */
7
- import { readFileSync, writeFileSync } from "node:fs";
8
- import { join } from "node:path";
7
+ import { readFileSync } from "node:fs";
9
8
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
9
  import { resolveLcmConfig } from "./src/db/config.js";
11
10
  import { LcmContextEngine } from "./src/engine.js";
@@ -42,14 +41,12 @@ function normalizeAgentId(agentId: string | undefined): string {
42
41
  type PluginEnvSnapshot = {
43
42
  lcmSummaryModel: string;
44
43
  lcmSummaryProvider: string;
44
+ pluginSummaryModel: string;
45
+ pluginSummaryProvider: string;
45
46
  openclawProvider: string;
46
47
  openclawDefaultModel: string;
47
- agentDir: string;
48
- home: string;
49
48
  };
50
49
 
51
- type ReadEnvFn = (key: string) => string | undefined;
52
-
53
50
  type CompleteSimpleOptions = {
54
51
  apiKey?: string;
55
52
  maxTokens: number;
@@ -57,15 +54,51 @@ type CompleteSimpleOptions = {
57
54
  reasoning?: string;
58
55
  };
59
56
 
57
+ type RuntimeModelAuthResult = {
58
+ apiKey?: string;
59
+ };
60
+
61
+ type RuntimeModelAuthModel = {
62
+ id: string;
63
+ provider: string;
64
+ api: string;
65
+ name?: string;
66
+ reasoning?: boolean;
67
+ input?: string[];
68
+ cost?: {
69
+ input: number;
70
+ output: number;
71
+ cacheRead: number;
72
+ cacheWrite: number;
73
+ };
74
+ contextWindow?: number;
75
+ maxTokens?: number;
76
+ };
77
+
78
+ type RuntimeModelAuth = {
79
+ getApiKeyForModel: (params: {
80
+ model: RuntimeModelAuthModel;
81
+ cfg?: OpenClawPluginApi["config"];
82
+ profileId?: string;
83
+ preferredProfile?: string;
84
+ }) => Promise<RuntimeModelAuthResult | undefined>;
85
+ resolveApiKeyForProvider: (params: {
86
+ provider: string;
87
+ cfg?: OpenClawPluginApi["config"];
88
+ profileId?: string;
89
+ preferredProfile?: string;
90
+ }) => Promise<RuntimeModelAuthResult | undefined>;
91
+ };
92
+
60
93
  /** Capture plugin env values once during initialization. */
61
94
  function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
62
95
  return {
63
96
  lcmSummaryModel: env.LCM_SUMMARY_MODEL?.trim() ?? "",
64
97
  lcmSummaryProvider: env.LCM_SUMMARY_PROVIDER?.trim() ?? "",
98
+ pluginSummaryModel: "",
99
+ pluginSummaryProvider: "",
65
100
  openclawProvider: env.OPENCLAW_PROVIDER?.trim() ?? "",
66
101
  openclawDefaultModel: "",
67
- agentDir: env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || "",
68
- home: env.HOME?.trim() ?? "",
69
102
  };
70
103
  }
71
104
 
@@ -84,58 +117,6 @@ function readDefaultModelFromConfig(config: unknown): string {
84
117
  return typeof primary === "string" ? primary.trim() : "";
85
118
  }
86
119
 
87
- /** Resolve common provider API keys from environment. */
88
- function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined {
89
- const keyMap: Record<string, string[]> = {
90
- openai: ["OPENAI_API_KEY"],
91
- anthropic: ["ANTHROPIC_API_KEY"],
92
- google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
93
- groq: ["GROQ_API_KEY"],
94
- xai: ["XAI_API_KEY"],
95
- mistral: ["MISTRAL_API_KEY"],
96
- together: ["TOGETHER_API_KEY"],
97
- openrouter: ["OPENROUTER_API_KEY"],
98
- "github-copilot": ["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"],
99
- };
100
-
101
- const providerKey = provider.trim().toLowerCase();
102
- const keys = keyMap[providerKey] ?? [];
103
- const normalizedProviderEnv = `${providerKey.replace(/[^a-z0-9]/g, "_").toUpperCase()}_API_KEY`;
104
- keys.push(normalizedProviderEnv);
105
-
106
- for (const key of keys) {
107
- const value = readEnv(key)?.trim();
108
- if (value) {
109
- return value;
110
- }
111
- }
112
- return undefined;
113
- }
114
-
115
- type AuthProfileCredential =
116
- | { type: "api_key"; provider: string; key?: string; email?: string }
117
- | { type: "token"; provider: string; token?: string; expires?: number; email?: string }
118
- | ({
119
- type: "oauth";
120
- provider: string;
121
- access?: string;
122
- refresh?: string;
123
- expires?: number;
124
- email?: string;
125
- } & Record<string, unknown>);
126
-
127
- type AuthProfileStore = {
128
- profiles: Record<string, AuthProfileCredential>;
129
- order?: Record<string, string[]>;
130
- };
131
-
132
- type PiAiOAuthCredentials = {
133
- refresh: string;
134
- access: string;
135
- expires: number;
136
- [key: string]: unknown;
137
- };
138
-
139
120
  type PiAiModule = {
140
121
  completeSimple?: (
141
122
  model: {
@@ -167,11 +148,6 @@ type PiAiModule = {
167
148
  ) => Promise<Record<string, unknown> & { content?: Array<{ type: string; text?: string }> }>;
168
149
  getModel?: (provider: string, modelId: string) => unknown;
169
150
  getModels?: (provider: string) => unknown[];
170
- getEnvApiKey?: (provider: string) => string | undefined;
171
- getOAuthApiKey?: (
172
- providerId: string,
173
- credentials: Record<string, PiAiOAuthCredentials>,
174
- ) => Promise<{ apiKey: string; newCredentials: PiAiOAuthCredentials } | null>;
175
151
  };
176
152
 
177
153
  /** Narrow unknown values to plain objects. */
@@ -275,283 +251,45 @@ function resolveProviderApiFromRuntimeConfig(
275
251
  return typeof api === "string" && api.trim() ? api.trim() : undefined;
276
252
  }
277
253
 
278
- /** Parse auth-profiles JSON into a minimal store shape. */
279
- function parseAuthProfileStore(raw: string): AuthProfileStore | undefined {
280
- try {
281
- const parsed = JSON.parse(raw) as unknown;
282
- if (!isRecord(parsed) || !isRecord(parsed.profiles)) {
283
- return undefined;
284
- }
285
-
286
- const profiles: Record<string, AuthProfileCredential> = {};
287
- for (const [profileId, value] of Object.entries(parsed.profiles)) {
288
- if (!isRecord(value)) {
289
- continue;
290
- }
291
- const type = value.type;
292
- const provider = typeof value.provider === "string" ? value.provider.trim() : "";
293
- if (!provider || (type !== "api_key" && type !== "token" && type !== "oauth")) {
294
- continue;
295
- }
296
- profiles[profileId] = value as AuthProfileCredential;
297
- }
298
-
299
- const rawOrder = isRecord(parsed.order) ? parsed.order : undefined;
300
- const order: Record<string, string[]> | undefined = rawOrder
301
- ? Object.entries(rawOrder).reduce<Record<string, string[]>>((acc, [provider, value]) => {
302
- if (!Array.isArray(value)) {
303
- return acc;
304
- }
305
- const ids = value
306
- .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
307
- .filter(Boolean);
308
- if (ids.length > 0) {
309
- acc[provider] = ids;
310
- }
311
- return acc;
312
- }, {})
313
- : undefined;
314
-
315
- return {
316
- profiles,
317
- ...(order && Object.keys(order).length > 0 ? { order } : {}),
318
- };
319
- } catch {
320
- return undefined;
321
- }
322
- }
323
-
324
- /** Merge auth stores, letting later stores override earlier profiles/order. */
325
- function mergeAuthProfileStores(stores: AuthProfileStore[]): AuthProfileStore | undefined {
326
- if (stores.length === 0) {
327
- return undefined;
328
- }
329
- const merged: AuthProfileStore = { profiles: {} };
330
- for (const store of stores) {
331
- merged.profiles = { ...merged.profiles, ...store.profiles };
332
- if (store.order) {
333
- merged.order = { ...(merged.order ?? {}), ...store.order };
334
- }
335
- }
336
- return merged;
337
- }
338
-
339
- /** Determine candidate auth store paths ordered by precedence. */
340
- function resolveAuthStorePaths(params: { agentDir?: string; envSnapshot: PluginEnvSnapshot }): string[] {
341
- const paths: string[] = [];
342
- const directAgentDir = params.agentDir?.trim();
343
- if (directAgentDir) {
344
- paths.push(join(directAgentDir, "auth-profiles.json"));
345
- }
346
-
347
- const envAgentDir = params.envSnapshot.agentDir;
348
- if (envAgentDir) {
349
- paths.push(join(envAgentDir, "auth-profiles.json"));
350
- }
351
-
352
- const home = params.envSnapshot.home;
353
- if (home) {
354
- paths.push(join(home, ".openclaw", "agents", "main", "agent", "auth-profiles.json"));
355
- }
356
-
357
- return [...new Set(paths)];
358
- }
359
-
360
- /** Build profile selection order for provider auth lookup. */
361
- function resolveAuthProfileCandidates(params: {
362
- provider: string;
363
- store: AuthProfileStore;
364
- authProfileId?: string;
365
- runtimeConfig?: unknown;
366
- }): string[] {
367
- const candidates: string[] = [];
368
- const normalizedProvider = normalizeProviderId(params.provider);
369
- const push = (value: string | undefined) => {
370
- const profileId = value?.trim();
371
- if (!profileId) {
372
- return;
373
- }
374
- if (!candidates.includes(profileId)) {
375
- candidates.push(profileId);
376
- }
254
+ /** Resolve runtime.modelAuth from plugin runtime, even before plugin-sdk typings land locally. */
255
+ function getRuntimeModelAuth(api: OpenClawPluginApi): RuntimeModelAuth {
256
+ const runtime = api.runtime as OpenClawPluginApi["runtime"] & {
257
+ modelAuth?: RuntimeModelAuth;
377
258
  };
378
-
379
- push(params.authProfileId);
380
-
381
- const storeOrder = findProviderConfigValue(params.store.order, params.provider);
382
- for (const profileId of storeOrder ?? []) {
383
- push(profileId);
384
- }
385
-
386
- if (isRecord(params.runtimeConfig)) {
387
- const auth = params.runtimeConfig.auth;
388
- if (isRecord(auth)) {
389
- const order = findProviderConfigValue(
390
- isRecord(auth.order) ? (auth.order as Record<string, unknown>) : undefined,
391
- params.provider,
392
- );
393
- if (Array.isArray(order)) {
394
- for (const profileId of order) {
395
- if (typeof profileId === "string") {
396
- push(profileId);
397
- }
398
- }
399
- }
400
- }
401
- }
402
-
403
- for (const [profileId, credential] of Object.entries(params.store.profiles)) {
404
- if (normalizeProviderId(credential.provider) === normalizedProvider) {
405
- push(profileId);
406
- }
259
+ if (!runtime.modelAuth) {
260
+ throw new Error("OpenClaw runtime.modelAuth is required by lossless-claw.");
407
261
  }
408
-
409
- return candidates;
262
+ return runtime.modelAuth;
410
263
  }
411
264
 
412
- /** Resolve OAuth/api-key/token credentials from auth-profiles store. */
413
- async function resolveApiKeyFromAuthProfiles(params: {
265
+ /** Build the minimal model shape required by runtime.modelAuth.getApiKeyForModel(). */
266
+ function buildModelAuthLookupModel(params: {
414
267
  provider: string;
415
- authProfileId?: string;
416
- agentDir?: string;
417
- runtimeConfig?: unknown;
418
- piAiModule: PiAiModule;
419
- envSnapshot: PluginEnvSnapshot;
420
- }): Promise<string | undefined> {
421
- const storesWithPaths = resolveAuthStorePaths({
422
- agentDir: params.agentDir,
423
- envSnapshot: params.envSnapshot,
424
- })
425
- .map((path) => {
426
- try {
427
- const parsed = parseAuthProfileStore(readFileSync(path, "utf8"));
428
- return parsed ? { path, store: parsed } : undefined;
429
- } catch {
430
- return undefined;
431
- }
432
- })
433
- .filter((entry): entry is { path: string; store: AuthProfileStore } => !!entry);
434
- if (storesWithPaths.length === 0) {
435
- return undefined;
436
- }
437
-
438
- const mergedStore = mergeAuthProfileStores(storesWithPaths.map((entry) => entry.store));
439
- if (!mergedStore) {
440
- return undefined;
441
- }
442
-
443
- const candidates = resolveAuthProfileCandidates({
268
+ model: string;
269
+ api?: string;
270
+ }): RuntimeModelAuthModel {
271
+ return {
272
+ id: params.model,
273
+ name: params.model,
444
274
  provider: params.provider,
445
- store: mergedStore,
446
- authProfileId: params.authProfileId,
447
- runtimeConfig: params.runtimeConfig,
448
- });
449
- if (candidates.length === 0) {
450
- return undefined;
451
- }
452
-
453
- const persistPath =
454
- params.agentDir?.trim() ? join(params.agentDir.trim(), "auth-profiles.json") : storesWithPaths[0]?.path;
455
-
456
- for (const profileId of candidates) {
457
- const credential = mergedStore.profiles[profileId];
458
- if (!credential) {
459
- continue;
460
- }
461
- if (normalizeProviderId(credential.provider) !== normalizeProviderId(params.provider)) {
462
- continue;
463
- }
464
-
465
- if (credential.type === "api_key") {
466
- const key = credential.key?.trim();
467
- if (key) {
468
- return key;
469
- }
470
- continue;
471
- }
472
-
473
- if (credential.type === "token") {
474
- const token = credential.token?.trim();
475
- if (!token) {
476
- continue;
477
- }
478
- const expires = credential.expires;
479
- if (typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires) {
480
- continue;
481
- }
482
- return token;
483
- }
484
-
485
- const access = credential.access?.trim();
486
- const expires = credential.expires;
487
- const isExpired =
488
- typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires;
489
-
490
- if (!isExpired && access) {
491
- if (
492
- (credential.provider === "google-gemini-cli" || credential.provider === "google-antigravity") &&
493
- typeof credential.projectId === "string" &&
494
- credential.projectId.trim()
495
- ) {
496
- return JSON.stringify({
497
- token: access,
498
- projectId: credential.projectId.trim(),
499
- });
500
- }
501
- return access;
502
- }
503
-
504
- if (typeof params.piAiModule.getOAuthApiKey !== "function") {
505
- continue;
506
- }
507
-
508
- try {
509
- const oauthCredential = {
510
- access: credential.access ?? "",
511
- refresh: credential.refresh ?? "",
512
- expires: typeof credential.expires === "number" ? credential.expires : 0,
513
- ...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
514
- ...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
515
- };
516
- const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
517
- [params.provider]: oauthCredential,
518
- });
519
- if (!refreshed?.apiKey) {
520
- continue;
521
- }
522
- mergedStore.profiles[profileId] = {
523
- ...credential,
524
- ...refreshed.newCredentials,
525
- type: "oauth",
526
- };
527
- if (persistPath) {
528
- try {
529
- writeFileSync(
530
- persistPath,
531
- JSON.stringify(
532
- {
533
- version: 1,
534
- profiles: mergedStore.profiles,
535
- ...(mergedStore.order ? { order: mergedStore.order } : {}),
536
- },
537
- null,
538
- 2,
539
- ),
540
- "utf8",
541
- );
542
- } catch {
543
- // Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
544
- }
545
- }
546
- return refreshed.apiKey;
547
- } catch {
548
- if (access) {
549
- return access;
550
- }
551
- }
552
- }
275
+ api: params.api?.trim() || inferApiFromProvider(params.provider),
276
+ reasoning: false,
277
+ input: ["text"],
278
+ cost: {
279
+ input: 0,
280
+ output: 0,
281
+ cacheRead: 0,
282
+ cacheWrite: 0,
283
+ },
284
+ contextWindow: 200_000,
285
+ maxTokens: 8_000,
286
+ };
287
+ }
553
288
 
554
- return undefined;
289
+ /** Normalize an auth result down to the API key that pi-ai expects. */
290
+ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined): string | undefined {
291
+ const apiKey = auth?.apiKey?.trim();
292
+ return apiKey ? apiKey : undefined;
555
293
  }
556
294
 
557
295
  /** Build a minimal but useful sub-agent prompt. */
@@ -614,13 +352,25 @@ function readLatestAssistantReply(messages: unknown[]): string | undefined {
614
352
  function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
615
353
  const envSnapshot = snapshotPluginEnv();
616
354
  envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
617
- const readEnv: ReadEnvFn = (key) => process.env[key];
355
+ const modelAuth = getRuntimeModelAuth(api);
618
356
  const pluginConfig =
619
357
  api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
620
358
  ? api.pluginConfig
621
359
  : undefined;
622
360
  const config = resolveLcmConfig(process.env, pluginConfig);
623
361
 
362
+ // Read model overrides from plugin config
363
+ if (pluginConfig) {
364
+ const summaryModel = pluginConfig.summaryModel;
365
+ const summaryProvider = pluginConfig.summaryProvider;
366
+ if (typeof summaryModel === "string") {
367
+ envSnapshot.pluginSummaryModel = summaryModel.trim();
368
+ }
369
+ if (typeof summaryProvider === "string") {
370
+ envSnapshot.pluginSummaryProvider = summaryProvider.trim();
371
+ }
372
+ }
373
+
624
374
  return {
625
375
  config,
626
376
  complete: async ({
@@ -697,19 +447,22 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
697
447
  maxTokens: 8_000,
698
448
  };
699
449
 
700
- let resolvedApiKey = apiKey?.trim() || resolveApiKey(providerId, readEnv);
701
- if (!resolvedApiKey && typeof mod.getEnvApiKey === "function") {
702
- resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
703
- }
450
+ let resolvedApiKey = apiKey?.trim();
704
451
  if (!resolvedApiKey) {
705
- resolvedApiKey = await resolveApiKeyFromAuthProfiles({
706
- provider: providerId,
707
- authProfileId,
708
- agentDir,
709
- runtimeConfig,
710
- piAiModule: mod,
711
- envSnapshot,
712
- });
452
+ try {
453
+ resolvedApiKey = resolveApiKeyFromAuthResult(
454
+ await modelAuth.resolveApiKeyForProvider({
455
+ provider: providerId,
456
+ cfg: api.config,
457
+ ...(authProfileId ? { profileId: authProfileId } : {}),
458
+ }),
459
+ );
460
+ } catch (err) {
461
+ console.error(
462
+ `[lcm] modelAuth.resolveApiKeyForProvider FAILED:`,
463
+ err instanceof Error ? err.message : err,
464
+ );
465
+ }
713
466
  }
714
467
 
715
468
  const completeOptions = buildCompleteSimpleOptions({
@@ -808,7 +561,10 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
808
561
  },
809
562
  resolveModel: (modelRef, providerHint) => {
810
563
  const raw =
811
- (modelRef?.trim() || envSnapshot.lcmSummaryModel || envSnapshot.openclawDefaultModel).trim();
564
+ (modelRef?.trim() ||
565
+ envSnapshot.pluginSummaryModel ||
566
+ envSnapshot.lcmSummaryModel ||
567
+ envSnapshot.openclawDefaultModel).trim();
812
568
  if (!raw) {
813
569
  throw new Error("No model configured for LCM summarization.");
814
570
  }
@@ -822,18 +578,39 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
822
578
  }
823
579
 
824
580
  const provider = (
825
- envSnapshot.lcmSummaryProvider ||
826
581
  providerHint?.trim() ||
582
+ envSnapshot.pluginSummaryProvider ||
583
+ envSnapshot.lcmSummaryProvider ||
827
584
  envSnapshot.openclawProvider ||
828
585
  "openai"
829
586
  ).trim();
830
587
  return { provider, model: raw };
831
588
  },
832
- getApiKey: (provider) => resolveApiKey(provider, readEnv),
833
- requireApiKey: (provider) => {
834
- const key = resolveApiKey(provider, readEnv);
589
+ getApiKey: async (provider, model, options) => {
590
+ try {
591
+ return resolveApiKeyFromAuthResult(
592
+ await modelAuth.getApiKeyForModel({
593
+ model: buildModelAuthLookupModel({ provider, model }),
594
+ cfg: api.config,
595
+ ...(options?.profileId ? { profileId: options.profileId } : {}),
596
+ ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
597
+ }),
598
+ );
599
+ } catch {
600
+ return undefined;
601
+ }
602
+ },
603
+ requireApiKey: async (provider, model, options) => {
604
+ const key = await resolveApiKeyFromAuthResult(
605
+ await modelAuth.getApiKeyForModel({
606
+ model: buildModelAuthLookupModel({ provider, model }),
607
+ cfg: api.config,
608
+ ...(options?.profileId ? { profileId: options.profileId } : {}),
609
+ ...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
610
+ }),
611
+ );
835
612
  if (!key) {
836
- throw new Error(`Missing API key for provider '${provider}'.`);
613
+ throw new Error(`Missing API key for provider '${provider}' (model '${model}').`);
837
614
  }
838
615
  return key;
839
616
  },
@@ -16,6 +16,14 @@
16
16
  "dbPath": {
17
17
  "label": "Database Path",
18
18
  "help": "Path to LCM SQLite database (default: ~/.openclaw/lcm.db)"
19
+ },
20
+ "summaryModel": {
21
+ "label": "Summary Model",
22
+ "help": "Model override for LCM summarization (e.g., 'gpt-5.4' or 'openai-resp/gpt-5.4')"
23
+ },
24
+ "summaryProvider": {
25
+ "label": "Summary Provider",
26
+ "help": "Provider override for LCM summarization (e.g., 'openai-resp')"
19
27
  }
20
28
  },
21
29
  "configSchema": {
@@ -56,6 +64,12 @@
56
64
  "largeFileThresholdTokens": {
57
65
  "type": "integer",
58
66
  "minimum": 1000
67
+ },
68
+ "summaryModel": {
69
+ "type": "string"
70
+ },
71
+ "summaryProvider": {
72
+ "type": "string"
59
73
  }
60
74
  }
61
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -23,6 +23,9 @@
23
23
  "README.md",
24
24
  "LICENSE"
25
25
  ],
26
+ "scripts": {
27
+ "test": "vitest run --dir test"
28
+ },
26
29
  "dependencies": {
27
30
  "@mariozechner/pi-agent-core": "*",
28
31
  "@mariozechner/pi-ai": "*",
package/src/summarize.ts CHANGED
@@ -672,8 +672,6 @@ export async function createLcmSummarizeFromLegacyParams(params: {
672
672
  : undefined;
673
673
  const providerApi = resolveProviderApiFromLegacyConfig(params.legacyParams.config, provider);
674
674
 
675
- const apiKey = params.deps.getApiKey(provider, model);
676
-
677
675
  const condensedTargetTokens =
678
676
  Number.isFinite(params.deps.config.condensedTargetTokens) &&
679
677
  params.deps.config.condensedTargetTokens > 0
@@ -691,6 +689,9 @@ export async function createLcmSummarizeFromLegacyParams(params: {
691
689
 
692
690
  const mode: SummaryMode = aggressive ? "aggressive" : "normal";
693
691
  const isCondensed = options?.isCondensed === true;
692
+ const apiKey = await params.deps.getApiKey(provider, model, {
693
+ profileId: authProfileId,
694
+ });
694
695
  const targetTokens = resolveTargetTokens({
695
696
  inputTokens: estimateTokens(text),
696
697
  mode,
package/src/types.ts CHANGED
@@ -58,8 +58,22 @@ export type ResolveModelFn = (modelRef?: string, providerHint?: string) => {
58
58
  /**
59
59
  * API key resolution function.
60
60
  */
61
- export type GetApiKeyFn = (provider: string, model: string) => string | undefined;
62
- export type RequireApiKeyFn = (provider: string, model: string) => string;
61
+ export type ApiKeyLookupOptions = {
62
+ profileId?: string;
63
+ preferredProfile?: string;
64
+ };
65
+
66
+ export type GetApiKeyFn = (
67
+ provider: string,
68
+ model: string,
69
+ options?: ApiKeyLookupOptions,
70
+ ) => Promise<string | undefined>;
71
+
72
+ export type RequireApiKeyFn = (
73
+ provider: string,
74
+ model: string,
75
+ options?: ApiKeyLookupOptions,
76
+ ) => Promise<string>;
63
77
 
64
78
  /**
65
79
  * Session key utilities.