@martian-engineering/lossless-claw 0.9.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ # Focus Briefs Implementation Plan
2
+
3
+ ## Overview
4
+
5
+ Focus briefs let a user ask Lossless to build a task-oriented view of existing
6
+ conversation memory. A focus brief is generated by a delegated subagent that can
7
+ search and traverse the canonical summary DAG with Lossless recall tools. The
8
+ brief is not a summary DAG node, is not written to `context_items`, and must not
9
+ be consumed by normal compaction. Recall tools continue to operate on the DAG.
10
+
11
+ The user-facing name is **focus**. The generated artifact is a **context brief**.
12
+
13
+ ## Command Surface
14
+
15
+ Use command words rather than flags so the interface works well on mobile:
16
+
17
+ ```text
18
+ /lossless focus
19
+ /lossless focus <prompt>
20
+ /lossless refocus
21
+ /lossless unfocus
22
+ ```
23
+
24
+ - `/lossless focus <prompt>` runs a forced full-sweep compaction, generates or
25
+ refocuses around the prompt from the fresh summary frontier, and activates the
26
+ resulting brief.
27
+ - `/lossless focus` reports active/draft focus state, prompt, preview, and diagnostics.
28
+ - `/lossless refocus` refreshes the active focus brief from post-focus summary
29
+ deltas, preserving the original prompt and using the old brief as the baseline.
30
+ - `/lossless unfocus` deactivates the active focus overlay once overlays exist,
31
+ then runs a forced full-sweep compaction.
32
+
33
+ ## Architecture Decisions
34
+
35
+ - Focus brief generation uses a delegated subagent, not a direct summarizer call.
36
+ The subagent is expected to use `lcm_grep`, `lcm_describe`, and grant-scoped
37
+ `lcm_expand` to produce evidence-oriented content.
38
+ - Focus briefs are persisted outside the summary DAG. They are inspectable,
39
+ supersedable, and later activatable, but never replace canonical storage.
40
+ - The first implementation phases deliberately stop before mutating active
41
+ assembly. This lets us debug generation quality, command behavior, persistence,
42
+ and TUI inspection before introducing prompt-prefix overlays.
43
+ - The assembler overlay phase must be assembly-only. Canonical `context_items`
44
+ remain the sole compaction source.
45
+ - New summaries while focus is active should remain visible as post-focus delta.
46
+ The later overlay implementation should use a coverage watermark rather than
47
+ relying only on masked summary IDs.
48
+ - Focus, refocus, and unfocus are prompt-prefix lifecycle operations. Because
49
+ they break prompt caching just like compaction does, they deliberately pair
50
+ the prefix mutation with forced full-sweep compaction: focus and refocus
51
+ compact before generation, while unfocus compacts after deactivation.
52
+
53
+ ## Phase 1: Slash Command Generates A Draft Brief
54
+
55
+ Build the real generation path without changing active context.
56
+
57
+ ### Scope
58
+
59
+ - Extend `/lossless` parsing with `focus` and `unfocus`.
60
+ - Implement `/lossless focus <prompt>` using the active conversation resolved
61
+ from session identity.
62
+ - Snapshot active summary context IDs and token counts.
63
+ - Spawn a delegated focus subagent with a scoped expansion grant.
64
+ - Require structured JSON from the child containing markdown brief content,
65
+ cited summary IDs, expansion prompts, and diagnostics.
66
+ - Return a preview in the command response.
67
+ - Do not persist the brief yet unless Phase 2 is already present.
68
+ - Do not modify assembler, compaction, summaries, parents, or context items.
69
+
70
+ ### Acceptance Criteria
71
+
72
+ - `/lossless focus <prompt>` returns a generated brief preview.
73
+ - The child session can use `lcm_grep`, `lcm_describe`, and grant-scoped
74
+ `lcm_expand`.
75
+ - The child task prompt tells it not to call `lcm_expand_query`.
76
+ - Malformed child JSON is handled with a useful error and raw preview.
77
+ - Existing `/lossless` commands keep working.
78
+
79
+ ### Verification
80
+
81
+ - Parser tests cover `focus`, empty `focus`, `unfocus`, and unknown focus forms.
82
+ - Delegation tests cover child prompt content, grant creation, wait handling,
83
+ parse success, and parse failure.
84
+ - Command tests cover no active conversation, no prompt, and successful preview.
85
+
86
+ ## Phase 2: Persist Draft Briefs And Expose Them In The TUI
87
+
88
+ Persist generated briefs outside active context so they can be inspected and
89
+ used as later overlay inputs. Add TUI visibility for generated focus briefs.
90
+
91
+ ### Scope
92
+
93
+ - Add migrations for focus brief tables.
94
+ - Add a store for focus brief creation, lookup, superseding, and listing.
95
+ - Update `/lossless focus <prompt>` to persist generated briefs as drafts in the pre-overlay phase.
96
+ - Update `/lossless focus` to show latest draft/active state, prompt, preview,
97
+ source count, cited IDs, generation status, and stale/error diagnostics.
98
+ - Add `/lossless unfocus` as a no-op or draft-state deactivation command until
99
+ assembly overlays exist.
100
+ - Extend the TUI with a focus brief view so generated briefs can be browsed and
101
+ read from the terminal.
102
+ - Do not show active focus content inline in conversation yet. That belongs to
103
+ the later active-overlay phase.
104
+
105
+ ### Suggested Tables
106
+
107
+ ```sql
108
+ CREATE TABLE focus_briefs (
109
+ brief_id TEXT PRIMARY KEY,
110
+ conversation_id INTEGER NOT NULL,
111
+ session_key TEXT,
112
+ prompt TEXT NOT NULL,
113
+ content TEXT NOT NULL,
114
+ status TEXT NOT NULL,
115
+ token_count INTEGER NOT NULL DEFAULT 0,
116
+ target_tokens INTEGER NOT NULL DEFAULT 0,
117
+ covered_latest_at TEXT,
118
+ covered_message_seq INTEGER,
119
+ source_context_hash TEXT NOT NULL DEFAULT '',
120
+ generator_run_id TEXT,
121
+ generator_session_key TEXT,
122
+ raw_result_json TEXT,
123
+ error TEXT,
124
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
125
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
126
+ superseded_at TEXT
127
+ );
128
+
129
+ CREATE TABLE focus_brief_sources (
130
+ brief_id TEXT NOT NULL,
131
+ summary_id TEXT NOT NULL,
132
+ ordinal INTEGER,
133
+ role TEXT NOT NULL,
134
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
135
+ PRIMARY KEY (brief_id, summary_id, role)
136
+ );
137
+ ```
138
+
139
+ ### Acceptance Criteria
140
+
141
+ - Generated briefs are durable and inspectable.
142
+ - Refocus supersedes the previous current draft for the conversation.
143
+ - Failed generations can be recorded without corrupting the latest good brief.
144
+ - TUI can list focus briefs for the selected conversation and display full
145
+ content plus prompt/metadata.
146
+ - No focus table rows are used by compaction or assembly yet.
147
+
148
+ ### Verification
149
+
150
+ - Migration tests verify tables and indexes are created.
151
+ - Store tests cover create, list latest, supersede, source recording, and failed
152
+ generation rows.
153
+ - Command tests verify persisted status and preview.
154
+ - TUI tests cover loading and rendering focus brief rows.
155
+
156
+ ## Phase 3: Brief Quality Contract
157
+
158
+ Harden the generator prompt and output schema after observing Phase 1/2 output.
159
+
160
+ ### Required Brief Sections
161
+
162
+ - Focused narrative.
163
+ - Current working state.
164
+ - Evidence map with summary IDs.
165
+ - Expansion guide with exact future recall prompts.
166
+ - Risks, gaps, contradictions, stale assumptions.
167
+ - Likely irrelevant context.
168
+ - Confidence/evidence notes.
169
+
170
+ ### Acceptance Criteria
171
+
172
+ - The schema captures cited IDs, expansion prompts, truncation state, and
173
+ confidence notes.
174
+ - The command response and TUI surface enough metadata to troubleshoot brief
175
+ quality without reading raw DB rows.
176
+
177
+ ## Phase 4: Assembly-Time Overlay
178
+
179
+ Make active focus mode affect assembled context.
180
+
181
+ ### Scope
182
+
183
+ - Add active-focus lookup to assembly.
184
+ - Replace covered summary prefix with one `<focus_brief>` user message.
185
+ - Preserve fresh tail and post-focus delta.
186
+ - Keep canonical `context_items` unchanged.
187
+ - Ensure compaction never reads focus brief rows as source material.
188
+ - Promote successful `/lossless focus <prompt>` generations to active overlays.
189
+ - Make `/lossless unfocus` deactivate active overlays without deleting brief history.
190
+ - Run forced full-sweep compaction before focus generation so the delegated
191
+ subagent reads the freshest balanced summary frontier.
192
+ - Run forced full-sweep compaction before refocus generation, then give the
193
+ delegated subagent the old active brief plus summaries beyond the old focus
194
+ watermark as delta input. A successful refocus writes a new active brief with
195
+ advanced coverage watermarks; a failed refocus leaves the old active brief in
196
+ place.
197
+ - Run forced full-sweep compaction after unfocus so the normal DAG view is
198
+ restored with the same cache break that removed the overlay.
199
+
200
+ ### Acceptance Criteria
201
+
202
+ - Assembled prompts include the active focus brief.
203
+ - Canonical context and DAG remain unchanged.
204
+ - New messages/summaries after the focus watermark remain visible.
205
+ - Unfocus restores normal assembly.
206
+
207
+ ## Phase 5: Unfocus, Refocus, And Drift Diagnostics
208
+
209
+ Complete the operational lifecycle.
210
+
211
+ ### Scope
212
+
213
+ - Harden refocus atomicity: failed new generation must not destroy the old active
214
+ focus.
215
+ - Show delta since focus: message count, summary count, and estimated tokens.
216
+ - Warn when active focus is stale, truncated, or generated from an obsolete
217
+ source snapshot.
218
+
219
+ ### Acceptance Criteria
220
+
221
+ - Focus lifecycle is reversible and debuggable.
222
+ - Drift is visible in `/lossless` status and TUI.
223
+
224
+ ## Risks And Mitigations
225
+
226
+ | Risk | Impact | Mitigation |
227
+ | --- | --- | --- |
228
+ | Child calls `lcm_expand_query` recursively | High | Prompt explicitly forbids it; reuse recursion guard language; subagent should use `lcm_expand` directly. |
229
+ | Brief is accidentally compacted | High | Never store brief as summary or context item; later overlay is assembly-only. |
230
+ | Masking breaks after DAG reshaping | High | Use coverage watermarks in overlay phase; keep masked IDs as diagnostics. |
231
+ | Command responses are too small for full briefs | Medium | Persist full content in Phase 2; command shows preview and brief ID. |
232
+ | Generation quality is weak | Medium | Ship Phases 1/2 before overlay; inspect drafts in TUI; iterate prompt/schema. |
233
+ | Ambient subagent cleanup fails | Medium | Follow existing delegated expansion cleanup/revoke patterns. |
234
+
235
+ ## Not Doing Yet
236
+
237
+ - No active prompt-prefix mutation in Phases 1 or 2.
238
+ - No `/compact` integration until the Lossless command path is proven.
239
+ - No changes to recall tool search semantics.
240
+ - No destructive cleanup of focus brief history.
package/docs/tui.md CHANGED
@@ -49,7 +49,7 @@ Lists all agents discovered under `~/.openclaw/agents/`. Select an agent to see
49
49
 
50
50
  ### Screen 2: Session List
51
51
 
52
- Shows JSONL session files for the selected agent, sorted by last modified time. Each entry shows the filename, last update time, message count, conversation ID (if LCM-tracked), summary count, and large file count.
52
+ Shows JSONL session files for the selected agent, sorted by last modified time. Each entry shows the filename, last update time, message count, conversation ID (if LCM-tracked), summary count, and large file count. If an OpenClaw session has a Codex app-server binding, the row also shows a `codex:` marker with the local backend rollout row count when available.
53
53
 
54
54
  Sessions load in batches of 50. Scrolling near the bottom automatically loads more.
55
55
 
@@ -57,6 +57,8 @@ Sessions load in batches of 50. Scrolling near the bottom automatically loads mo
57
57
  |-----|--------|
58
58
  | `↑`/`↓` or `k`/`j` | Move cursor |
59
59
  | `Enter` | Open conversation |
60
+ | `x` | Open bound Codex backend rollout transcript, when available |
61
+ | `v` | Compare bound Codex backend rollout against the LCM active context |
60
62
  | `b`/`Backspace` | Back to agents |
61
63
  | `r` | Reload sessions |
62
64
  | `q` | Quit |
@@ -84,11 +86,17 @@ For sessions with an LCM `conv_id`, the conversation view uses keyset-paged wind
84
86
  | `]` | Load newer message window |
85
87
  | `l` | Open **Summary DAG** view |
86
88
  | `c` | Open **Context** view |
89
+ | `o` | Open **Focus Briefs** view |
87
90
  | `f` | Open **Large Files** view |
91
+ | `v` | Open **Codex ↔ LCM** comparison view |
88
92
  | `b`/`Backspace` | Back to sessions |
89
93
  | `r` | Reload messages |
90
94
  | `q` | Quit |
91
95
 
96
+ ### Codex ↔ LCM Comparison
97
+
98
+ For Codex app-server bound sessions, the comparison view renders native Codex backend rollout rows beside the Lossless-managed active context items for the same OpenClaw session. The panes are index-aligned for inspection rather than treated as a causal one-to-one mapping: Codex rows show what the backend session recorded, while LCM rows show summaries and fresh-tail messages that Lossless would assemble.
99
+
92
100
  ## Summary DAG View
93
101
 
94
102
  The core inspection tool. Shows the full hierarchy of LCM summaries for a conversation as an expandable tree.
@@ -161,6 +169,22 @@ The status bar shows totals: how many summaries, how many messages, total items,
161
169
  | `b`/`Backspace` | Back to conversation |
162
170
  | `q` | Quit |
163
171
 
172
+ ## Focus Briefs View
173
+
174
+ Lists focus briefs generated for the selected LCM conversation. Each row shows status, creation time, brief ID, token count, and prompt preview. The detail panel shows generator metadata, source/citation counts, post-focus drift diagnostics, cited and expanded summary IDs, the original focus prompt, and the generated brief content.
175
+
176
+ This view is read-only. When a focus brief is active, the conversation and active-context screens show a compact focus banner with the brief ID, prompt preview, token count, and stale/source-snapshot diagnostics.
177
+
178
+ | Key | Action |
179
+ |-----|--------|
180
+ | `↑`/`↓` or `k`/`j` | Move cursor |
181
+ | `g`/`G` | Jump to first/last |
182
+ | `Shift+J` | Scroll detail panel down |
183
+ | `Shift+K` | Scroll detail panel up |
184
+ | `r` | Reload focus briefs |
185
+ | `b`/`Backspace` | Back to conversation |
186
+ | `q` | Quit |
187
+
164
188
  ## Large Files View
165
189
 
166
190
  Lists files that exceeded the large file threshold (default 25k tokens) and were intercepted by LCM. Shows file ID, display name, MIME type, byte size, and creation time. The detail panel shows the exploration summary that was generated as a lightweight stand-in.
@@ -0,0 +1,34 @@
1
+ export type LosslessRuntimeLlmModelRef = {
2
+ field: string;
3
+ modelRef: string;
4
+ configPath: string;
5
+ };
6
+
7
+ export type LosslessRuntimeLlmSkippedModelRef = {
8
+ field: string;
9
+ configPath: string;
10
+ reason: string;
11
+ };
12
+
13
+ export type LegacyConfigRule = {
14
+ path: string[];
15
+ message: string;
16
+ match?: (value: unknown, root: Record<string, unknown>) => boolean;
17
+ };
18
+
19
+ export const legacyConfigRules: LegacyConfigRule[];
20
+
21
+ export function normalizeCompatibilityConfig(params: { cfg: unknown }): {
22
+ config: any;
23
+ changes: string[];
24
+ };
25
+
26
+ export function collectLosslessRuntimeLlmModelRefs(cfg: unknown): {
27
+ modelRefs: LosslessRuntimeLlmModelRef[];
28
+ skipped: LosslessRuntimeLlmSkippedModelRef[];
29
+ };
30
+
31
+ export function collectLosslessSubagentModelRefs(cfg: unknown): {
32
+ modelRefs: LosslessRuntimeLlmModelRef[];
33
+ skipped: LosslessRuntimeLlmSkippedModelRef[];
34
+ };
@@ -0,0 +1,349 @@
1
+ const PLUGIN_ID = "lossless-claw";
2
+ const ENTRY_PATH = ["plugins", "entries", PLUGIN_ID];
3
+ const CONFIG_PATH = [...ENTRY_PATH, "config"];
4
+
5
+ function isRecord(value) {
6
+ return !!value && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+
9
+ function readString(value) {
10
+ return typeof value === "string" && value.trim() ? value.trim() : "";
11
+ }
12
+
13
+ function readEntry(cfg) {
14
+ const plugins = isRecord(cfg) ? cfg.plugins : undefined;
15
+ const entries = isRecord(plugins) ? plugins.entries : undefined;
16
+ const entry = isRecord(entries) ? entries[PLUGIN_ID] : undefined;
17
+ return isRecord(entry) ? entry : undefined;
18
+ }
19
+
20
+ function readConfig(cfg) {
21
+ const config = readEntry(cfg)?.config;
22
+ return isRecord(config) ? config : undefined;
23
+ }
24
+
25
+ function readModelOverridePolicy(cfg, policyKey) {
26
+ const policy = readEntry(cfg)?.[policyKey];
27
+ if (!isRecord(policy)) {
28
+ return {
29
+ allowModelOverride: false,
30
+ allowedModels: [],
31
+ };
32
+ }
33
+ return {
34
+ allowModelOverride: policy.allowModelOverride === true,
35
+ allowedModels: Array.isArray(policy.allowedModels) ? policy.allowedModels : [],
36
+ };
37
+ }
38
+
39
+ function readLlmPolicy(cfg) {
40
+ return readModelOverridePolicy(cfg, "llm");
41
+ }
42
+
43
+ function readSubagentPolicy(cfg) {
44
+ return readModelOverridePolicy(cfg, "subagent");
45
+ }
46
+
47
+ function toModelRef(provider, model) {
48
+ const modelId = readString(model);
49
+ if (!modelId) {
50
+ return undefined;
51
+ }
52
+ const slash = modelId.indexOf("/");
53
+ if (slash > 0 && slash < modelId.length - 1) {
54
+ const directProvider = modelId.slice(0, slash).trim();
55
+ const directModel = modelId.slice(slash + 1).trim();
56
+ return directProvider && directModel ? `${directProvider}/${directModel}` : undefined;
57
+ }
58
+ const providerId = readString(provider);
59
+ return providerId ? `${providerId}/${modelId}` : undefined;
60
+ }
61
+
62
+ function uniqueModelRefs(modelRefs) {
63
+ const seen = new Set();
64
+ return modelRefs.filter((entry) => {
65
+ const key = `${entry.field}:${entry.modelRef}`;
66
+ if (seen.has(key)) {
67
+ return false;
68
+ }
69
+ seen.add(key);
70
+ return true;
71
+ });
72
+ }
73
+
74
+ /** Collect configured Lossless summary model refs that doctor can safely allowlist. */
75
+ function collectLosslessRuntimeLlmModelRefs(cfg) {
76
+ const config = readConfig(cfg);
77
+ if (!config) {
78
+ return { modelRefs: [], skipped: [] };
79
+ }
80
+
81
+ const modelRefs = [];
82
+ const skipped = [];
83
+ const addConfiguredModel = (field, model, provider, configPath) => {
84
+ const modelId = readString(model);
85
+ if (!modelId) {
86
+ return;
87
+ }
88
+ const modelRef = toModelRef(provider, modelId);
89
+ if (modelRef) {
90
+ modelRefs.push({ field, modelRef, configPath });
91
+ return;
92
+ }
93
+ skipped.push({
94
+ field,
95
+ configPath,
96
+ reason: `${field} is a bare model without a provider; use provider/model or set the matching provider field so doctor can update plugins.entries.${PLUGIN_ID}.llm.allowedModels.`,
97
+ });
98
+ };
99
+
100
+ addConfiguredModel(
101
+ "summaryModel",
102
+ config.summaryModel,
103
+ config.summaryProvider,
104
+ [...CONFIG_PATH, "summaryModel"].join("."),
105
+ );
106
+ addConfiguredModel(
107
+ "largeFileSummaryModel",
108
+ config.largeFileSummaryModel,
109
+ config.largeFileSummaryProvider,
110
+ [...CONFIG_PATH, "largeFileSummaryModel"].join("."),
111
+ );
112
+
113
+ if (Array.isArray(config.fallbackProviders)) {
114
+ for (const [index, fallback] of config.fallbackProviders.entries()) {
115
+ if (!isRecord(fallback)) {
116
+ skipped.push({
117
+ field: "fallbackProviders",
118
+ configPath: `${[...CONFIG_PATH, "fallbackProviders"].join(".")}[${index}]`,
119
+ reason:
120
+ "fallbackProviders entries must be objects with provider and model before doctor can update llm.allowedModels.",
121
+ });
122
+ continue;
123
+ }
124
+ const modelRef = toModelRef(fallback.provider, fallback.model);
125
+ if (modelRef) {
126
+ modelRefs.push({
127
+ field: "fallbackProviders",
128
+ modelRef,
129
+ configPath: `${[...CONFIG_PATH, "fallbackProviders"].join(".")}[${index}]`,
130
+ });
131
+ } else if (readString(fallback.model) || readString(fallback.provider)) {
132
+ skipped.push({
133
+ field: "fallbackProviders",
134
+ configPath: `${[...CONFIG_PATH, "fallbackProviders"].join(".")}[${index}]`,
135
+ reason:
136
+ "fallbackProviders entries need both provider and model before doctor can update llm.allowedModels.",
137
+ });
138
+ }
139
+ }
140
+ }
141
+
142
+ return {
143
+ modelRefs: uniqueModelRefs(modelRefs),
144
+ skipped,
145
+ };
146
+ }
147
+
148
+ /** Collect configured Lossless expansion model refs that doctor can safely allowlist. */
149
+ function collectLosslessSubagentModelRefs(cfg) {
150
+ const config = readConfig(cfg);
151
+ if (!config) {
152
+ return { modelRefs: [], skipped: [] };
153
+ }
154
+
155
+ const modelRefs = [];
156
+ const skipped = [];
157
+ const modelId = readString(config.expansionModel);
158
+ if (modelId) {
159
+ const modelRef = toModelRef(config.expansionProvider, modelId);
160
+ if (modelRef) {
161
+ modelRefs.push({
162
+ field: "expansionModel",
163
+ modelRef,
164
+ configPath: [...CONFIG_PATH, "expansionModel"].join("."),
165
+ });
166
+ } else {
167
+ skipped.push({
168
+ field: "expansionModel",
169
+ configPath: [...CONFIG_PATH, "expansionModel"].join("."),
170
+ reason:
171
+ `expansionModel is a bare model without a provider; use provider/model or set plugins.entries.${PLUGIN_ID}.config.expansionProvider so doctor can update plugins.entries.${PLUGIN_ID}.subagent.allowedModels.`,
172
+ });
173
+ }
174
+ }
175
+
176
+ return {
177
+ modelRefs: uniqueModelRefs(modelRefs),
178
+ skipped,
179
+ };
180
+ }
181
+
182
+ function collectMissingPolicyEntries(cfg) {
183
+ const { modelRefs, skipped } = collectLosslessRuntimeLlmModelRefs(cfg);
184
+ const policy = readLlmPolicy(cfg);
185
+ const allowedStrings = new Set(policy.allowedModels.filter((entry) => typeof entry === "string"));
186
+ const missingRefs = allowedStrings.has("*")
187
+ ? []
188
+ : modelRefs.filter((entry) => !allowedStrings.has(entry.modelRef));
189
+ return {
190
+ modelRefs,
191
+ skipped,
192
+ missingRefs,
193
+ missingAllowModelOverride: modelRefs.length > 0 && policy.allowModelOverride !== true,
194
+ };
195
+ }
196
+
197
+ function collectMissingSubagentPolicyEntries(cfg) {
198
+ const { modelRefs, skipped } = collectLosslessSubagentModelRefs(cfg);
199
+ const policy = readSubagentPolicy(cfg);
200
+ const allowedStrings = new Set(policy.allowedModels.filter((entry) => typeof entry === "string"));
201
+ const missingRefs = allowedStrings.has("*")
202
+ ? []
203
+ : modelRefs.filter((entry) => !allowedStrings.has(entry.modelRef));
204
+ return {
205
+ modelRefs,
206
+ skipped,
207
+ missingRefs,
208
+ missingAllowModelOverride: modelRefs.length > 0 && policy.allowModelOverride !== true,
209
+ };
210
+ }
211
+
212
+ function hasIssueForField(cfg, field) {
213
+ const issues = collectMissingPolicyEntries(cfg);
214
+ return (
215
+ issues.missingAllowModelOverride ||
216
+ issues.missingRefs.some((entry) => entry.field === field) ||
217
+ issues.skipped.some((entry) => entry.field === field)
218
+ );
219
+ }
220
+
221
+ function hasSubagentIssueForField(cfg, field) {
222
+ const issues = collectMissingSubagentPolicyEntries(cfg);
223
+ return (
224
+ issues.missingAllowModelOverride ||
225
+ issues.missingRefs.some((entry) => entry.field === field) ||
226
+ issues.skipped.some((entry) => entry.field === field)
227
+ );
228
+ }
229
+
230
+ function needsPolicyRepair(issues) {
231
+ return issues.missingAllowModelOverride || issues.missingRefs.length > 0;
232
+ }
233
+
234
+ /** Doctor warning rules for Lossless runtime LLM and subagent model override policy. */
235
+ export const legacyConfigRules = [
236
+ {
237
+ path: [...CONFIG_PATH, "summaryModel"],
238
+ message:
239
+ 'Lossless summaryModel uses api.runtime.llm.complete model overrides. Configure plugins.entries.lossless-claw.llm.allowModelOverride and allowedModels, or run "openclaw doctor --fix".',
240
+ match: (_value, root) => hasIssueForField(root, "summaryModel"),
241
+ },
242
+ {
243
+ path: [...CONFIG_PATH, "largeFileSummaryModel"],
244
+ message:
245
+ 'Lossless largeFileSummaryModel uses api.runtime.llm.complete model overrides. Configure plugins.entries.lossless-claw.llm.allowModelOverride and allowedModels, or run "openclaw doctor --fix".',
246
+ match: (_value, root) => hasIssueForField(root, "largeFileSummaryModel"),
247
+ },
248
+ {
249
+ path: [...CONFIG_PATH, "fallbackProviders"],
250
+ message:
251
+ 'Lossless fallbackProviders use api.runtime.llm.complete model overrides. Configure plugins.entries.lossless-claw.llm.allowModelOverride and allowedModels, or run "openclaw doctor --fix".',
252
+ match: (_value, root) => hasIssueForField(root, "fallbackProviders"),
253
+ },
254
+ {
255
+ path: [...CONFIG_PATH, "expansionModel"],
256
+ message:
257
+ 'Lossless expansionModel uses delegated sub-agent model overrides. Configure plugins.entries.lossless-claw.subagent.allowModelOverride and allowedModels, or run "openclaw doctor --fix".',
258
+ match: (_value, root) => hasSubagentIssueForField(root, "expansionModel"),
259
+ },
260
+ ];
261
+
262
+ function cloneRootWithLosslessEntry(cfg) {
263
+ const root = isRecord(cfg) ? { ...cfg } : {};
264
+ const plugins = isRecord(root.plugins) ? { ...root.plugins } : {};
265
+ const entries = isRecord(plugins.entries) ? { ...plugins.entries } : {};
266
+ const entry = isRecord(entries[PLUGIN_ID]) ? { ...entries[PLUGIN_ID] } : {};
267
+
268
+ root.plugins = plugins;
269
+ plugins.entries = entries;
270
+ entries[PLUGIN_ID] = entry;
271
+
272
+ return { root, entry };
273
+ }
274
+
275
+ function ensurePolicy(entry, policyKey) {
276
+ const policy = isRecord(entry[policyKey]) ? { ...entry[policyKey] } : {};
277
+ entry[policyKey] = policy;
278
+ return policy;
279
+ }
280
+
281
+ function applyModelOverridePolicyRepair({ policy, issues, changes, policyPath, subject }) {
282
+ if (issues.modelRefs.length === 0) {
283
+ return;
284
+ }
285
+
286
+ if (policy.allowModelOverride !== true) {
287
+ policy.allowModelOverride = true;
288
+ changes.push(
289
+ `Set plugins.entries.lossless-claw.${policyPath}.allowModelOverride = true for configured Lossless ${subject} model overrides.`,
290
+ );
291
+ }
292
+
293
+ const currentAllowed = Array.isArray(policy.allowedModels) ? [...policy.allowedModels] : [];
294
+ const allowedStrings = new Set(currentAllowed.filter((entry) => typeof entry === "string"));
295
+ const added = [];
296
+ if (!allowedStrings.has("*")) {
297
+ for (const { modelRef } of issues.modelRefs) {
298
+ if (!allowedStrings.has(modelRef)) {
299
+ currentAllowed.push(modelRef);
300
+ allowedStrings.add(modelRef);
301
+ added.push(modelRef);
302
+ }
303
+ }
304
+ }
305
+
306
+ if (added.length > 0 || !Array.isArray(policy.allowedModels)) {
307
+ policy.allowedModels = currentAllowed;
308
+ changes.push(
309
+ `Added plugins.entries.lossless-claw.${policyPath}.allowedModels entries for configured Lossless ${subject} models: ${added.join(", ")}`,
310
+ );
311
+ }
312
+ }
313
+
314
+ /** Add the minimal plugin policies needed for configured Lossless model overrides. */
315
+ export function normalizeCompatibilityConfig({ cfg }) {
316
+ const issues = collectMissingPolicyEntries(cfg);
317
+ const subagentIssues = collectMissingSubagentPolicyEntries(cfg);
318
+ const repairRuntimeLlmPolicy = needsPolicyRepair(issues);
319
+ const repairSubagentPolicy = needsPolicyRepair(subagentIssues);
320
+ if (!repairRuntimeLlmPolicy && !repairSubagentPolicy) {
321
+ return { config: cfg, changes: [] };
322
+ }
323
+
324
+ const { root, entry } = cloneRootWithLosslessEntry(cfg);
325
+ const changes = [];
326
+
327
+ if (repairRuntimeLlmPolicy) {
328
+ applyModelOverridePolicyRepair({
329
+ policy: ensurePolicy(entry, "llm"),
330
+ issues,
331
+ changes,
332
+ policyPath: "llm",
333
+ subject: "summary",
334
+ });
335
+ }
336
+ if (repairSubagentPolicy) {
337
+ applyModelOverridePolicyRepair({
338
+ policy: ensurePolicy(entry, "subagent"),
339
+ issues: subagentIssues,
340
+ changes,
341
+ policyPath: "subagent",
342
+ subject: "expansion",
343
+ });
344
+ }
345
+
346
+ return { config: root, changes };
347
+ }
348
+
349
+ export { collectLosslessRuntimeLlmModelRefs, collectLosslessSubagentModelRefs };