@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 +17 -368
- package/docs/fts5.md +161 -0
- package/index.ts +131 -354
- package/openclaw.plugin.json +14 -0
- package/package.json +4 -1
- package/src/summarize.ts +3 -2
- package/src/types.ts +16 -2
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
|
-
##
|
|
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
|
-
##
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
-
|
|
502
|
-
-
|
|
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
|
|
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
|
-
/**
|
|
279
|
-
function
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
413
|
-
|
|
265
|
+
/** Build the minimal model shape required by runtime.modelAuth.getApiKeyForModel(). */
|
|
266
|
+
function buildModelAuthLookupModel(params: {
|
|
414
267
|
provider: string;
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
701
|
-
if (!resolvedApiKey && typeof mod.getEnvApiKey === "function") {
|
|
702
|
-
resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
|
|
703
|
-
}
|
|
450
|
+
let resolvedApiKey = apiKey?.trim();
|
|
704
451
|
if (!resolvedApiKey) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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() ||
|
|
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) =>
|
|
833
|
-
|
|
834
|
-
|
|
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
|
},
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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
|
|
62
|
-
|
|
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.
|