@martian-engineering/lossless-claw 0.8.0 → 0.8.2

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.
Files changed (52) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +971 -0
  3. package/docs/configuration.md +15 -5
  4. package/openclaw.plugin.json +27 -3
  5. package/package.json +7 -6
  6. package/skills/lossless-claw/references/config.md +37 -0
  7. package/index.ts +0 -2
  8. package/src/assembler.ts +0 -1196
  9. package/src/compaction.ts +0 -1753
  10. package/src/db/config.ts +0 -345
  11. package/src/db/connection.ts +0 -151
  12. package/src/db/features.ts +0 -61
  13. package/src/db/migration.ts +0 -868
  14. package/src/engine.ts +0 -4486
  15. package/src/estimate-tokens.ts +0 -80
  16. package/src/expansion-auth.ts +0 -365
  17. package/src/expansion-policy.ts +0 -303
  18. package/src/expansion.ts +0 -383
  19. package/src/integrity.ts +0 -600
  20. package/src/large-files.ts +0 -546
  21. package/src/lcm-log.ts +0 -37
  22. package/src/openclaw-bridge.ts +0 -22
  23. package/src/plugin/index.ts +0 -2037
  24. package/src/plugin/lcm-command.ts +0 -1040
  25. package/src/plugin/lcm-doctor-apply.ts +0 -540
  26. package/src/plugin/lcm-doctor-cleaners.ts +0 -655
  27. package/src/plugin/lcm-doctor-shared.ts +0 -210
  28. package/src/plugin/shared-init.ts +0 -59
  29. package/src/prune.ts +0 -391
  30. package/src/retrieval.ts +0 -360
  31. package/src/session-patterns.ts +0 -23
  32. package/src/startup-banner-log.ts +0 -49
  33. package/src/store/compaction-telemetry-store.ts +0 -156
  34. package/src/store/conversation-store.ts +0 -929
  35. package/src/store/fts5-sanitize.ts +0 -50
  36. package/src/store/full-text-fallback.ts +0 -83
  37. package/src/store/full-text-sort.ts +0 -21
  38. package/src/store/index.ts +0 -39
  39. package/src/store/parse-utc-timestamp.ts +0 -25
  40. package/src/store/summary-store.ts +0 -1519
  41. package/src/summarize.ts +0 -1508
  42. package/src/tools/common.ts +0 -53
  43. package/src/tools/lcm-conversation-scope.ts +0 -127
  44. package/src/tools/lcm-describe-tool.ts +0 -245
  45. package/src/tools/lcm-expand-query-tool.ts +0 -1235
  46. package/src/tools/lcm-expand-tool.delegation.ts +0 -580
  47. package/src/tools/lcm-expand-tool.ts +0 -453
  48. package/src/tools/lcm-expansion-recursion-guard.ts +0 -373
  49. package/src/tools/lcm-grep-tool.ts +0 -228
  50. package/src/transaction-mutex.ts +0 -136
  51. package/src/transcript-repair.ts +0 -301
  52. package/src/types.ts +0 -165
package/src/retrieval.ts DELETED
@@ -1,360 +0,0 @@
1
- import type {
2
- ConversationStore,
3
- MessageRecord,
4
- MessageSearchResult,
5
- } from "./store/conversation-store.js";
6
- import type {
7
- SummaryStore,
8
- SummaryRecord,
9
- SummarySearchResult,
10
- LargeFileRecord,
11
- } from "./store/summary-store.js";
12
- import type { SearchSort } from "./store/full-text-sort.js";
13
- import { estimateTokens } from "./estimate-tokens.js";
14
-
15
- // ── Public interfaces ────────────────────────────────────────────────────────
16
-
17
- export interface DescribeResult {
18
- id: string;
19
- type: "summary" | "file";
20
- /** Summary-specific fields */
21
- summary?: {
22
- conversationId: number;
23
- kind: "leaf" | "condensed";
24
- content: string;
25
- depth: number;
26
- tokenCount: number;
27
- descendantCount: number;
28
- descendantTokenCount: number;
29
- sourceMessageTokenCount: number;
30
- fileIds: string[];
31
- parentIds: string[];
32
- childIds: string[];
33
- messageIds: number[];
34
- earliestAt: Date | null;
35
- latestAt: Date | null;
36
- subtree: Array<{
37
- summaryId: string;
38
- parentSummaryId: string | null;
39
- depthFromRoot: number;
40
- kind: "leaf" | "condensed";
41
- depth: number;
42
- tokenCount: number;
43
- descendantCount: number;
44
- descendantTokenCount: number;
45
- sourceMessageTokenCount: number;
46
- earliestAt: Date | null;
47
- latestAt: Date | null;
48
- childCount: number;
49
- path: string;
50
- }>;
51
- createdAt: Date;
52
- };
53
- /** File-specific fields */
54
- file?: {
55
- conversationId: number;
56
- fileName: string | null;
57
- mimeType: string | null;
58
- byteSize: number | null;
59
- storageUri: string;
60
- explorationSummary: string | null;
61
- createdAt: Date;
62
- };
63
- }
64
-
65
- export interface GrepInput {
66
- query: string;
67
- mode: "regex" | "full_text";
68
- scope: "messages" | "summaries" | "both";
69
- conversationId?: number;
70
- since?: Date;
71
- before?: Date;
72
- limit?: number;
73
- /** Sort order for results. Default "recency" (newest first).
74
- * "relevance" sorts by FTS5 BM25 rank (full_text mode only).
75
- * "hybrid" blends relevance with recency. */
76
- sort?: SearchSort;
77
- }
78
-
79
- export interface GrepResult {
80
- messages: MessageSearchResult[];
81
- summaries: SummarySearchResult[];
82
- totalMatches: number;
83
- }
84
-
85
- export interface ExpandInput {
86
- summaryId: string;
87
- /** Max traversal depth (default 1) */
88
- depth?: number;
89
- /** Include raw source messages at leaf level */
90
- includeMessages?: boolean;
91
- /** Max tokens to return before truncating */
92
- tokenCap?: number;
93
- }
94
-
95
- export interface ExpandResult {
96
- /** Child summaries found */
97
- children: Array<{
98
- summaryId: string;
99
- kind: "leaf" | "condensed";
100
- content: string;
101
- tokenCount: number;
102
- }>;
103
- /** Source messages (only if includeMessages=true and hitting leaf summaries) */
104
- messages: Array<{
105
- messageId: number;
106
- role: string;
107
- content: string;
108
- tokenCount: number;
109
- }>;
110
- /** Total estimated tokens in result */
111
- estimatedTokens: number;
112
- /** Whether result was truncated due to tokenCap */
113
- truncated: boolean;
114
- }
115
-
116
- // ── Helpers ──────────────────────────────────────────────────────────────────
117
-
118
-
119
- // ── RetrievalEngine ──────────────────────────────────────────────────────────
120
-
121
- export class RetrievalEngine {
122
- constructor(
123
- private conversationStore: ConversationStore,
124
- private summaryStore: SummaryStore,
125
- ) {}
126
-
127
- // ── describe ─────────────────────────────────────────────────────────────
128
-
129
- /**
130
- * Describe an LCM item by ID.
131
- *
132
- * - IDs starting with "sum_" are looked up as summaries (with lineage).
133
- * - IDs starting with "file_" are looked up as large files.
134
- * - Returns null if the item is not found.
135
- */
136
- async describe(id: string): Promise<DescribeResult | null> {
137
- if (id.startsWith("sum_")) {
138
- return this.describeSummary(id);
139
- }
140
- if (id.startsWith("file_")) {
141
- return this.describeFile(id);
142
- }
143
- return null;
144
- }
145
-
146
- private async describeSummary(id: string): Promise<DescribeResult | null> {
147
- const summary = await this.summaryStore.getSummary(id);
148
- if (!summary) {
149
- return null;
150
- }
151
-
152
- // Fetch lineage in parallel
153
- const [parents, children, messageIds, subtree] = await Promise.all([
154
- this.summaryStore.getSummaryParents(id),
155
- this.summaryStore.getSummaryChildren(id),
156
- this.summaryStore.getSummaryMessages(id),
157
- this.summaryStore.getSummarySubtree(id),
158
- ]);
159
-
160
- return {
161
- id,
162
- type: "summary",
163
- summary: {
164
- conversationId: summary.conversationId,
165
- kind: summary.kind,
166
- content: summary.content,
167
- depth: summary.depth,
168
- tokenCount: summary.tokenCount,
169
- descendantCount: summary.descendantCount,
170
- descendantTokenCount: summary.descendantTokenCount,
171
- sourceMessageTokenCount: summary.sourceMessageTokenCount,
172
- fileIds: summary.fileIds,
173
- parentIds: parents.map((p) => p.summaryId),
174
- childIds: children.map((c) => c.summaryId),
175
- messageIds,
176
- earliestAt: summary.earliestAt,
177
- latestAt: summary.latestAt,
178
- subtree: subtree.map((node) => ({
179
- summaryId: node.summaryId,
180
- parentSummaryId: node.parentSummaryId,
181
- depthFromRoot: node.depthFromRoot,
182
- kind: node.kind,
183
- depth: node.depth,
184
- tokenCount: node.tokenCount,
185
- descendantCount: node.descendantCount,
186
- descendantTokenCount: node.descendantTokenCount,
187
- sourceMessageTokenCount: node.sourceMessageTokenCount,
188
- earliestAt: node.earliestAt,
189
- latestAt: node.latestAt,
190
- childCount: node.childCount,
191
- path: node.path,
192
- })),
193
- createdAt: summary.createdAt,
194
- },
195
- };
196
- }
197
-
198
- private async describeFile(id: string): Promise<DescribeResult | null> {
199
- const file = await this.summaryStore.getLargeFile(id);
200
- if (!file) {
201
- return null;
202
- }
203
-
204
- return {
205
- id,
206
- type: "file",
207
- file: {
208
- conversationId: file.conversationId,
209
- fileName: file.fileName,
210
- mimeType: file.mimeType,
211
- byteSize: file.byteSize,
212
- storageUri: file.storageUri,
213
- explorationSummary: file.explorationSummary,
214
- createdAt: file.createdAt,
215
- },
216
- };
217
- }
218
-
219
- // ── grep ─────────────────────────────────────────────────────────────────
220
-
221
- /**
222
- * Search compacted history using regex or full-text search.
223
- *
224
- * Depending on `scope`, searches messages, summaries, or both (in parallel).
225
- */
226
- async grep(input: GrepInput): Promise<GrepResult> {
227
- const { query, mode, scope, conversationId, since, before, limit, sort } = input;
228
-
229
- const searchInput = { query, mode, conversationId, since, before, limit, sort };
230
-
231
- let messages: MessageSearchResult[] = [];
232
- let summaries: SummarySearchResult[] = [];
233
-
234
- if (scope === "messages") {
235
- messages = await this.conversationStore.searchMessages(searchInput);
236
- } else if (scope === "summaries") {
237
- summaries = await this.summaryStore.searchSummaries(searchInput);
238
- } else {
239
- // scope === "both" — run in parallel
240
- [messages, summaries] = await Promise.all([
241
- this.conversationStore.searchMessages(searchInput),
242
- this.summaryStore.searchSummaries(searchInput),
243
- ]);
244
- }
245
-
246
- return {
247
- messages,
248
- summaries,
249
- totalMatches: messages.length + summaries.length,
250
- };
251
- }
252
-
253
- // ── expand ───────────────────────────────────────────────────────────────
254
-
255
- /**
256
- * Expand a summary to its children and/or source messages.
257
- *
258
- * - Condensed summaries: returns child summaries, recursing up to `depth`.
259
- * - Leaf summaries with `includeMessages`: fetches the source messages.
260
- * - Respects `tokenCap` and sets `truncated` when the cap is exceeded.
261
- */
262
- async expand(input: ExpandInput): Promise<ExpandResult> {
263
- const depth = input.depth ?? 1;
264
- const includeMessages = input.includeMessages ?? false;
265
- const tokenCap = input.tokenCap ?? Infinity;
266
-
267
- const result: ExpandResult = {
268
- children: [],
269
- messages: [],
270
- estimatedTokens: 0,
271
- truncated: false,
272
- };
273
-
274
- await this.expandRecursive(input.summaryId, depth, includeMessages, tokenCap, result);
275
-
276
- return result;
277
- }
278
-
279
- private async expandRecursive(
280
- summaryId: string,
281
- depth: number,
282
- includeMessages: boolean,
283
- tokenCap: number,
284
- result: ExpandResult,
285
- ): Promise<void> {
286
- if (depth <= 0) {
287
- return;
288
- }
289
- if (result.truncated) {
290
- return;
291
- }
292
-
293
- const summary = await this.summaryStore.getSummary(summaryId);
294
- if (!summary) {
295
- return;
296
- }
297
-
298
- if (summary.kind === "condensed") {
299
- // IMPORTANT: a condensed summary is linked to the summaries that were
300
- // compacted into it via summary_parents(summary_id, parent_summary_id).
301
- // For expansion/replay we need to walk those source summaries, not newer
302
- // summaries that may later derive from this node.
303
- const children = await this.summaryStore.getSummaryParents(summaryId);
304
-
305
- for (const child of children) {
306
- if (result.truncated) {
307
- break;
308
- }
309
-
310
- // Check if adding this child would exceed the token cap
311
- if (result.estimatedTokens + child.tokenCount > tokenCap) {
312
- result.truncated = true;
313
- break;
314
- }
315
-
316
- result.children.push({
317
- summaryId: child.summaryId,
318
- kind: child.kind,
319
- content: child.content,
320
- tokenCount: child.tokenCount,
321
- });
322
- result.estimatedTokens += child.tokenCount;
323
-
324
- // Recurse into children if depth allows
325
- if (depth > 1) {
326
- await this.expandRecursive(child.summaryId, depth - 1, includeMessages, tokenCap, result);
327
- }
328
- }
329
- } else if (summary.kind === "leaf" && includeMessages) {
330
- // Leaf summary — fetch source messages
331
- const messageIds = await this.summaryStore.getSummaryMessages(summaryId);
332
-
333
- for (const msgId of messageIds) {
334
- if (result.truncated) {
335
- break;
336
- }
337
-
338
- const msg = await this.conversationStore.getMessageById(msgId);
339
- if (!msg) {
340
- continue;
341
- }
342
-
343
- const tokenCount = msg.tokenCount || estimateTokens(msg.content);
344
-
345
- if (result.estimatedTokens + tokenCount > tokenCap) {
346
- result.truncated = true;
347
- break;
348
- }
349
-
350
- result.messages.push({
351
- messageId: msg.messageId,
352
- role: msg.role,
353
- content: msg.content,
354
- tokenCount,
355
- });
356
- result.estimatedTokens += tokenCount;
357
- }
358
- }
359
- }
360
- }
@@ -1,23 +0,0 @@
1
- /**
2
- * Compile a session glob into a regex.
3
- *
4
- * `*` matches any non-colon characters, while `**` can span colons.
5
- */
6
- export function compileSessionPattern(pattern: string): RegExp {
7
- const escaped = pattern
8
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
9
- .replace(/\*\*/g, "\u0000")
10
- .replace(/\*/g, "[^:]*")
11
- .replace(/\u0000/g, ".*");
12
- return new RegExp(`^${escaped}$`);
13
- }
14
-
15
- /** Compile all configured ignore patterns once at startup. */
16
- export function compileSessionPatterns(patterns: string[]): RegExp[] {
17
- return patterns.map((pattern) => compileSessionPattern(pattern));
18
- }
19
-
20
- /** Check whether a session key matches any compiled ignore pattern. */
21
- export function matchesSessionPattern(sessionKey: string, patterns: RegExp[]): boolean {
22
- return patterns.some((pattern) => pattern.test(sessionKey));
23
- }
@@ -1,49 +0,0 @@
1
- type StartupBannerKey =
2
- | "plugin-loaded"
3
- | "compaction-model"
4
- | "fallback-providers"
5
- | "ignore-session-patterns"
6
- | "stateless-session-patterns";
7
-
8
- type StartupBannerLogState = {
9
- emitted: Set<StartupBannerKey>;
10
- };
11
-
12
- const STARTUP_BANNER_LOG_STATE = Symbol.for(
13
- "@martian-engineering/lossless-claw/startup-banner-log-state",
14
- );
15
-
16
- /** Return the process-global startup banner log state. */
17
- function getStartupBannerLogState(): StartupBannerLogState {
18
- const globalState = globalThis as typeof globalThis & {
19
- [STARTUP_BANNER_LOG_STATE]?: StartupBannerLogState;
20
- };
21
-
22
- if (!globalState[STARTUP_BANNER_LOG_STATE]) {
23
- globalState[STARTUP_BANNER_LOG_STATE] = {
24
- emitted: new Set<StartupBannerKey>(),
25
- };
26
- }
27
-
28
- return globalState[STARTUP_BANNER_LOG_STATE];
29
- }
30
-
31
- /** Emit a startup/config banner only once per process. */
32
- export function logStartupBannerOnce(params: {
33
- key: StartupBannerKey;
34
- log: (message: string) => void;
35
- message: string;
36
- }): void {
37
- const state = getStartupBannerLogState();
38
- if (state.emitted.has(params.key)) {
39
- return;
40
- }
41
-
42
- state.emitted.add(params.key);
43
- params.log(params.message);
44
- }
45
-
46
- /** Reset startup/config banner dedupe state for tests. */
47
- export function resetStartupBannerLogsForTests(): void {
48
- getStartupBannerLogState().emitted.clear();
49
- }
@@ -1,156 +0,0 @@
1
- import type { DatabaseSync } from "node:sqlite";
2
- import { withDatabaseTransaction } from "../transaction-mutex.js";
3
- import { parseUtcTimestampOrNull } from "./parse-utc-timestamp.js";
4
-
5
- export type CacheState = "hot" | "cold" | "unknown";
6
- export type ActivityBand = "low" | "medium" | "high";
7
-
8
- export type ConversationCompactionTelemetryRecord = {
9
- conversationId: number;
10
- lastObservedCacheRead: number | null;
11
- lastObservedCacheWrite: number | null;
12
- lastObservedCacheHitAt: Date | null;
13
- lastObservedCacheBreakAt: Date | null;
14
- cacheState: CacheState;
15
- retention: string | null;
16
- lastLeafCompactionAt: Date | null;
17
- turnsSinceLeafCompaction: number;
18
- tokensAccumulatedSinceLeafCompaction: number;
19
- lastActivityBand: ActivityBand;
20
- updatedAt: Date;
21
- };
22
-
23
- export type UpsertConversationCompactionTelemetryInput = {
24
- conversationId: number;
25
- lastObservedCacheRead?: number | null;
26
- lastObservedCacheWrite?: number | null;
27
- lastObservedCacheHitAt?: Date | null;
28
- lastObservedCacheBreakAt?: Date | null;
29
- cacheState: CacheState;
30
- retention?: string | null;
31
- lastLeafCompactionAt?: Date | null;
32
- turnsSinceLeafCompaction?: number;
33
- tokensAccumulatedSinceLeafCompaction?: number;
34
- lastActivityBand?: ActivityBand;
35
- };
36
-
37
- type ConversationCompactionTelemetryRow = {
38
- conversation_id: number;
39
- last_observed_cache_read: number | null;
40
- last_observed_cache_write: number | null;
41
- last_observed_cache_hit_at: string | null;
42
- last_observed_cache_break_at: string | null;
43
- cache_state: CacheState;
44
- retention: string | null;
45
- last_leaf_compaction_at: string | null;
46
- turns_since_leaf_compaction: number | null;
47
- tokens_accumulated_since_leaf_compaction: number | null;
48
- last_activity_band: ActivityBand | null;
49
- updated_at: string;
50
- };
51
-
52
- function toConversationCompactionTelemetryRecord(
53
- row: ConversationCompactionTelemetryRow,
54
- ): ConversationCompactionTelemetryRecord {
55
- return {
56
- conversationId: row.conversation_id,
57
- lastObservedCacheRead: row.last_observed_cache_read,
58
- lastObservedCacheWrite: row.last_observed_cache_write,
59
- lastObservedCacheHitAt: parseUtcTimestampOrNull(row.last_observed_cache_hit_at),
60
- lastObservedCacheBreakAt: parseUtcTimestampOrNull(row.last_observed_cache_break_at),
61
- cacheState: row.cache_state,
62
- retention: row.retention,
63
- lastLeafCompactionAt: parseUtcTimestampOrNull(row.last_leaf_compaction_at),
64
- turnsSinceLeafCompaction: row.turns_since_leaf_compaction ?? 0,
65
- tokensAccumulatedSinceLeafCompaction: row.tokens_accumulated_since_leaf_compaction ?? 0,
66
- lastActivityBand: row.last_activity_band ?? "low",
67
- updatedAt: parseUtcTimestampOrNull(row.updated_at) ?? new Date(0),
68
- };
69
- }
70
-
71
- /**
72
- * Persist and query per-conversation prompt-cache telemetry used by
73
- * cache-aware incremental compaction.
74
- */
75
- export class CompactionTelemetryStore {
76
- constructor(private readonly db: DatabaseSync) {}
77
-
78
- /** Execute multiple telemetry writes atomically. */
79
- withTransaction<T>(fn: () => Promise<T>): Promise<T> {
80
- return withDatabaseTransaction(this.db, "BEGIN", fn);
81
- }
82
-
83
- /** Load the latest persisted telemetry for a conversation. */
84
- async getConversationCompactionTelemetry(
85
- conversationId: number,
86
- ): Promise<ConversationCompactionTelemetryRecord | null> {
87
- const row = this.db
88
- .prepare(
89
- `SELECT
90
- conversation_id,
91
- last_observed_cache_read,
92
- last_observed_cache_write,
93
- last_observed_cache_hit_at,
94
- last_observed_cache_break_at,
95
- cache_state,
96
- retention,
97
- last_leaf_compaction_at,
98
- turns_since_leaf_compaction,
99
- tokens_accumulated_since_leaf_compaction,
100
- last_activity_band,
101
- updated_at
102
- FROM conversation_compaction_telemetry
103
- WHERE conversation_id = ?`,
104
- )
105
- .get(conversationId) as ConversationCompactionTelemetryRow | undefined;
106
- return row ? toConversationCompactionTelemetryRecord(row) : null;
107
- }
108
-
109
- /** Upsert the current cache telemetry snapshot for a conversation. */
110
- async upsertConversationCompactionTelemetry(
111
- input: UpsertConversationCompactionTelemetryInput,
112
- ): Promise<void> {
113
- this.db
114
- .prepare(
115
- `INSERT INTO conversation_compaction_telemetry (
116
- conversation_id,
117
- last_observed_cache_read,
118
- last_observed_cache_write,
119
- last_observed_cache_hit_at,
120
- last_observed_cache_break_at,
121
- cache_state,
122
- retention,
123
- last_leaf_compaction_at,
124
- turns_since_leaf_compaction,
125
- tokens_accumulated_since_leaf_compaction,
126
- last_activity_band,
127
- updated_at
128
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
129
- ON CONFLICT(conversation_id) DO UPDATE SET
130
- last_observed_cache_read = excluded.last_observed_cache_read,
131
- last_observed_cache_write = excluded.last_observed_cache_write,
132
- last_observed_cache_hit_at = excluded.last_observed_cache_hit_at,
133
- last_observed_cache_break_at = excluded.last_observed_cache_break_at,
134
- cache_state = excluded.cache_state,
135
- retention = excluded.retention,
136
- last_leaf_compaction_at = excluded.last_leaf_compaction_at,
137
- turns_since_leaf_compaction = excluded.turns_since_leaf_compaction,
138
- tokens_accumulated_since_leaf_compaction = excluded.tokens_accumulated_since_leaf_compaction,
139
- last_activity_band = excluded.last_activity_band,
140
- updated_at = datetime('now')`,
141
- )
142
- .run(
143
- input.conversationId,
144
- input.lastObservedCacheRead ?? null,
145
- input.lastObservedCacheWrite ?? null,
146
- input.lastObservedCacheHitAt?.toISOString() ?? null,
147
- input.lastObservedCacheBreakAt?.toISOString() ?? null,
148
- input.cacheState,
149
- input.retention ?? null,
150
- input.lastLeafCompactionAt?.toISOString() ?? null,
151
- input.turnsSinceLeafCompaction ?? 0,
152
- input.tokensAccumulatedSinceLeafCompaction ?? 0,
153
- input.lastActivityBand ?? "low",
154
- );
155
- }
156
- }