@martian-engineering/lossless-claw 0.5.2 → 0.6.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,546 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import { formatTimestamp } from "../compaction.js";
3
+ import type { LcmConfig } from "../db/config.js";
4
+ import type { LcmSummarizeFn } from "../summarize.js";
5
+ import { createLcmSummarizeFromLegacyParams } from "../summarize.js";
6
+ import type { LcmDependencies } from "../types.js";
7
+ import { detectDoctorMarker, loadDoctorTargets, type DoctorTargetRecord } from "./lcm-doctor-shared.js";
8
+
9
+ type SummaryOverride = {
10
+ content: string;
11
+ tokenCount: number;
12
+ };
13
+
14
+ type SummaryTimeRange = {
15
+ earliestAt: Date | null;
16
+ latestAt: Date | null;
17
+ };
18
+
19
+ type DoctorApplySkip = {
20
+ summaryId: string;
21
+ reason: string;
22
+ };
23
+
24
+ export type DoctorApplyResult =
25
+ | {
26
+ kind: "applied";
27
+ detected: number;
28
+ repaired: number;
29
+ unchanged: number;
30
+ skipped: DoctorApplySkip[];
31
+ repairedSummaryIds: string[];
32
+ }
33
+ | {
34
+ kind: "unavailable";
35
+ reason: string;
36
+ };
37
+
38
+ type DoctorApplyRow = {
39
+ summary_id: string;
40
+ ordinal: number;
41
+ content?: string;
42
+ };
43
+
44
+ /**
45
+ * Repair broken summaries for a single resolved conversation.
46
+ */
47
+ export async function applyScopedDoctorRepair(params: {
48
+ db: DatabaseSync;
49
+ config: LcmConfig;
50
+ conversationId: number;
51
+ deps?: LcmDependencies;
52
+ summarize?: LcmSummarizeFn;
53
+ runtimeConfig?: unknown;
54
+ }): Promise<DoctorApplyResult> {
55
+ const targets = loadDoctorTargets(params.db, params.conversationId);
56
+ if (targets.length === 0) {
57
+ return {
58
+ kind: "applied",
59
+ detected: 0,
60
+ repaired: 0,
61
+ unchanged: 0,
62
+ skipped: [],
63
+ repairedSummaryIds: [],
64
+ };
65
+ }
66
+
67
+ const summarize = await resolveDoctorApplySummarize(params);
68
+ if (!summarize) {
69
+ return {
70
+ kind: "unavailable",
71
+ reason: "Lossless Claw could not resolve a summarizer for native doctor apply through the normal model/auth chain.",
72
+ };
73
+ }
74
+
75
+ const ordered = orderDoctorTargets(params.db, params.conversationId, targets);
76
+ const overrides = new Map<string, SummaryOverride>();
77
+ const skipped: DoctorApplySkip[] = [];
78
+ const repairedSummaryIds: string[] = [];
79
+ let unchanged = 0;
80
+
81
+ for (const target of ordered) {
82
+ try {
83
+ const sourceText = buildSummarySourceText({
84
+ db: params.db,
85
+ target,
86
+ timezone: params.config.timezone,
87
+ overrides,
88
+ });
89
+ if (!sourceText.trim()) {
90
+ skipped.push({
91
+ summaryId: target.summaryId,
92
+ reason: "source text resolved empty",
93
+ });
94
+ continue;
95
+ }
96
+
97
+ const previousSummary = resolvePreviousSummaryContext({
98
+ db: params.db,
99
+ target,
100
+ overrides,
101
+ });
102
+
103
+ const rewritten = (await summarize(sourceText, false, {
104
+ previousSummary,
105
+ isCondensed: isCondensedTarget(target),
106
+ ...(isCondensedTarget(target) ? { depth: target.depth } : {}),
107
+ })).trim();
108
+ if (!rewritten) {
109
+ skipped.push({
110
+ summaryId: target.summaryId,
111
+ reason: "summarizer returned empty output",
112
+ });
113
+ continue;
114
+ }
115
+ if (detectDoctorMarker(rewritten)) {
116
+ skipped.push({
117
+ summaryId: target.summaryId,
118
+ reason: "rewritten content still contains a doctor marker",
119
+ });
120
+ continue;
121
+ }
122
+ if (rewritten === target.content.trim()) {
123
+ unchanged += 1;
124
+ continue;
125
+ }
126
+
127
+ const tokenCount = estimateTokens(rewritten);
128
+ overrides.set(target.summaryId, {
129
+ content: rewritten,
130
+ tokenCount,
131
+ });
132
+ repairedSummaryIds.push(target.summaryId);
133
+ } catch (error) {
134
+ skipped.push({
135
+ summaryId: target.summaryId,
136
+ reason: error instanceof Error ? error.message : "unknown repair failure",
137
+ });
138
+ }
139
+ }
140
+
141
+ if (repairedSummaryIds.length > 0) {
142
+ params.db.exec("BEGIN IMMEDIATE");
143
+ try {
144
+ for (const summaryId of repairedSummaryIds) {
145
+ const override = overrides.get(summaryId);
146
+ if (!override) {
147
+ continue;
148
+ }
149
+ params.db
150
+ .prepare(
151
+ `UPDATE summaries
152
+ SET content = ?, token_count = ?
153
+ WHERE summary_id = ?`,
154
+ )
155
+ .run(override.content, override.tokenCount, summaryId);
156
+ updateSummaryFts(params.db, summaryId, override.content);
157
+ }
158
+ params.db.exec("COMMIT");
159
+ } catch (error) {
160
+ params.db.exec("ROLLBACK");
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ return {
166
+ kind: "applied",
167
+ detected: targets.length,
168
+ repaired: repairedSummaryIds.length,
169
+ unchanged,
170
+ skipped,
171
+ repairedSummaryIds,
172
+ };
173
+ }
174
+
175
+ async function resolveDoctorApplySummarize(params: {
176
+ config: LcmConfig;
177
+ deps?: LcmDependencies;
178
+ summarize?: LcmSummarizeFn;
179
+ runtimeConfig?: unknown;
180
+ }): Promise<LcmSummarizeFn | undefined> {
181
+ if (typeof params.summarize === "function") {
182
+ return params.summarize;
183
+ }
184
+ if (!params.deps) {
185
+ return undefined;
186
+ }
187
+
188
+ const runtimeSummarizer = await createLcmSummarizeFromLegacyParams({
189
+ deps: params.deps,
190
+ legacyParams: {
191
+ config: params.runtimeConfig,
192
+ agentDir: params.deps.resolveAgentDir(),
193
+ },
194
+ customInstructions: params.config.customInstructions || undefined,
195
+ });
196
+ return runtimeSummarizer?.fn;
197
+ }
198
+
199
+ function isCondensedTarget(target: DoctorTargetRecord): boolean {
200
+ return !(target.depth === 0 || target.kind === "leaf");
201
+ }
202
+
203
+ function orderDoctorTargets(
204
+ db: DatabaseSync,
205
+ conversationId: number,
206
+ targets: DoctorTargetRecord[],
207
+ ): DoctorTargetRecord[] {
208
+ const leafOrdinals = loadDoctorLeafOrdinals(db, conversationId);
209
+ const activeLeaves: Array<DoctorTargetRecord & { contextOrdinal: number }> = [];
210
+ const orphanLeaves: DoctorTargetRecord[] = [];
211
+ const condensed: DoctorTargetRecord[] = [];
212
+
213
+ for (const target of targets) {
214
+ if (!isCondensedTarget(target)) {
215
+ const contextOrdinal = leafOrdinals.get(target.summaryId);
216
+ if (typeof contextOrdinal === "number") {
217
+ activeLeaves.push({ ...target, contextOrdinal });
218
+ } else {
219
+ orphanLeaves.push(target);
220
+ }
221
+ continue;
222
+ }
223
+ condensed.push(target);
224
+ }
225
+
226
+ activeLeaves.sort((left, right) => left.contextOrdinal - right.contextOrdinal);
227
+ orphanLeaves.sort(compareDoctorTargets);
228
+ condensed.sort(compareDoctorTargets);
229
+
230
+ return [...activeLeaves, ...orphanLeaves, ...condensed];
231
+ }
232
+
233
+ function compareDoctorTargets(left: DoctorTargetRecord, right: DoctorTargetRecord): number {
234
+ if (left.depth !== right.depth) {
235
+ return left.depth - right.depth;
236
+ }
237
+ if (left.createdAt !== right.createdAt) {
238
+ return left.createdAt.localeCompare(right.createdAt);
239
+ }
240
+ return left.summaryId.localeCompare(right.summaryId);
241
+ }
242
+
243
+ function loadDoctorLeafOrdinals(db: DatabaseSync, conversationId: number): Map<string, number> {
244
+ const rows = db
245
+ .prepare(
246
+ `SELECT ci.summary_id, ci.ordinal, COALESCE(s.content, '') AS content
247
+ FROM context_items ci
248
+ JOIN summaries s ON s.summary_id = ci.summary_id
249
+ WHERE ci.conversation_id = ?
250
+ AND ci.item_type = 'summary'
251
+ AND COALESCE(s.depth, 0) = 0
252
+ ORDER BY ci.ordinal ASC`,
253
+ )
254
+ .all(conversationId) as DoctorApplyRow[];
255
+
256
+ const ordinals = new Map<string, number>();
257
+ for (const row of rows) {
258
+ if (!detectDoctorMarker(row.content ?? "")) {
259
+ continue;
260
+ }
261
+ ordinals.set(row.summary_id, row.ordinal);
262
+ }
263
+ return ordinals;
264
+ }
265
+
266
+ function buildSummarySourceText(params: {
267
+ db: DatabaseSync;
268
+ target: DoctorTargetRecord;
269
+ timezone: string;
270
+ overrides: Map<string, SummaryOverride>;
271
+ }): string {
272
+ return isCondensedTarget(params.target)
273
+ ? buildCondensedSourceText(params)
274
+ : buildLeafSourceText(params);
275
+ }
276
+
277
+ function buildLeafSourceText(params: {
278
+ db: DatabaseSync;
279
+ target: DoctorTargetRecord;
280
+ timezone: string;
281
+ }): string {
282
+ const rows = params.db
283
+ .prepare(
284
+ `SELECT m.created_at, COALESCE(m.content, '') AS content
285
+ FROM summary_messages sm
286
+ JOIN messages m ON m.message_id = sm.message_id
287
+ WHERE sm.summary_id = ?
288
+ ORDER BY sm.ordinal ASC`,
289
+ )
290
+ .all(params.target.summaryId) as Array<{ created_at: string; content: string }>;
291
+ if (rows.length === 0) {
292
+ throw new Error("no messages linked to summary");
293
+ }
294
+
295
+ return rows
296
+ .map((row) => `[${formatSqliteTimestamp(row.created_at, params.timezone)}]\n${row.content}`)
297
+ .join("\n\n");
298
+ }
299
+
300
+ function buildCondensedSourceText(params: {
301
+ db: DatabaseSync;
302
+ target: DoctorTargetRecord;
303
+ timezone: string;
304
+ overrides: Map<string, SummaryOverride>;
305
+ }): string {
306
+ const rows = params.db
307
+ .prepare(
308
+ `SELECT
309
+ sp.parent_summary_id AS summary_id,
310
+ COALESCE(s.content, '') AS content,
311
+ s.earliest_at,
312
+ s.latest_at,
313
+ s.created_at
314
+ FROM summary_parents sp
315
+ JOIN summaries s ON s.summary_id = sp.parent_summary_id
316
+ WHERE sp.summary_id = ?
317
+ ORDER BY sp.ordinal ASC`,
318
+ )
319
+ .all(params.target.summaryId) as Array<{
320
+ summary_id: string;
321
+ content: string;
322
+ earliest_at: string | null;
323
+ latest_at: string | null;
324
+ created_at: string;
325
+ }>;
326
+ if (rows.length === 0) {
327
+ throw new Error("no child summaries linked to summary");
328
+ }
329
+
330
+ const parts = rows
331
+ .map((row) => {
332
+ const override = params.overrides.get(row.summary_id);
333
+ const content = (override?.content ?? row.content).trim();
334
+ if (!content) {
335
+ return null;
336
+ }
337
+ const timeRange = resolveSummaryTimeRange({
338
+ earliestAt: row.earliest_at,
339
+ latestAt: row.latest_at,
340
+ createdAt: row.created_at,
341
+ });
342
+ const header = formatSummaryTimeRange(timeRange, params.timezone);
343
+ return header ? `${header}\n${content}` : content;
344
+ })
345
+ .filter((value): value is string => typeof value === "string");
346
+
347
+ if (parts.length === 0) {
348
+ throw new Error("child summaries resolved empty");
349
+ }
350
+ return parts.join("\n\n");
351
+ }
352
+
353
+ function resolvePreviousSummaryContext(params: {
354
+ db: DatabaseSync;
355
+ target: DoctorTargetRecord;
356
+ overrides: Map<string, SummaryOverride>;
357
+ }): string | undefined {
358
+ return (
359
+ previousViaContextItems(params) ??
360
+ previousViaSummaryParents(params) ??
361
+ previousViaTimestamp(params)
362
+ );
363
+ }
364
+
365
+ function previousViaContextItems(params: {
366
+ db: DatabaseSync;
367
+ target: DoctorTargetRecord;
368
+ overrides: Map<string, SummaryOverride>;
369
+ }): string | undefined {
370
+ const targetRow = params.db
371
+ .prepare(
372
+ `SELECT ordinal
373
+ FROM context_items
374
+ WHERE conversation_id = ?
375
+ AND item_type = 'summary'
376
+ AND summary_id = ?
377
+ LIMIT 1`,
378
+ )
379
+ .get(params.target.conversationId, params.target.summaryId) as { ordinal: number } | undefined;
380
+ if (!targetRow) {
381
+ return undefined;
382
+ }
383
+
384
+ const previousRow = params.db
385
+ .prepare(
386
+ `SELECT s.summary_id
387
+ FROM context_items ci
388
+ JOIN summaries s ON s.summary_id = ci.summary_id
389
+ WHERE ci.conversation_id = ?
390
+ AND ci.item_type = 'summary'
391
+ AND COALESCE(s.depth, 0) = ?
392
+ AND ci.ordinal < ?
393
+ ORDER BY ci.ordinal DESC
394
+ LIMIT 1`,
395
+ )
396
+ .get(
397
+ params.target.conversationId,
398
+ params.target.depth,
399
+ targetRow.ordinal,
400
+ ) as { summary_id: string } | undefined;
401
+ return resolveSummaryContent(params.db, previousRow?.summary_id, params.overrides);
402
+ }
403
+
404
+ function previousViaSummaryParents(params: {
405
+ db: DatabaseSync;
406
+ target: DoctorTargetRecord;
407
+ overrides: Map<string, SummaryOverride>;
408
+ }): string | undefined {
409
+ const parentRow = params.db
410
+ .prepare(
411
+ `SELECT summary_id, ordinal
412
+ FROM summary_parents
413
+ WHERE parent_summary_id = ?
414
+ LIMIT 1`,
415
+ )
416
+ .get(params.target.summaryId) as { summary_id: string; ordinal: number } | undefined;
417
+ if (!parentRow) {
418
+ return undefined;
419
+ }
420
+
421
+ const previousRow = params.db
422
+ .prepare(
423
+ `SELECT parent_summary_id AS summary_id
424
+ FROM summary_parents
425
+ WHERE summary_id = ?
426
+ AND ordinal < ?
427
+ ORDER BY ordinal DESC
428
+ LIMIT 1`,
429
+ )
430
+ .get(parentRow.summary_id, parentRow.ordinal) as { summary_id: string } | undefined;
431
+ return resolveSummaryContent(params.db, previousRow?.summary_id, params.overrides);
432
+ }
433
+
434
+ function previousViaTimestamp(params: {
435
+ db: DatabaseSync;
436
+ target: DoctorTargetRecord;
437
+ overrides: Map<string, SummaryOverride>;
438
+ }): string | undefined {
439
+ if (!params.target.createdAt.trim()) {
440
+ return undefined;
441
+ }
442
+
443
+ const previousRow = params.db
444
+ .prepare(
445
+ `SELECT summary_id
446
+ FROM summaries
447
+ WHERE conversation_id = ?
448
+ AND COALESCE(depth, 0) = ?
449
+ AND (created_at < ? OR (created_at = ? AND summary_id < ?))
450
+ ORDER BY created_at DESC, summary_id DESC
451
+ LIMIT 1`,
452
+ )
453
+ .get(
454
+ params.target.conversationId,
455
+ params.target.depth,
456
+ params.target.createdAt,
457
+ params.target.createdAt,
458
+ params.target.summaryId,
459
+ ) as { summary_id: string } | undefined;
460
+ return resolveSummaryContent(params.db, previousRow?.summary_id, params.overrides);
461
+ }
462
+
463
+ function resolveSummaryContent(
464
+ db: DatabaseSync,
465
+ summaryId: string | undefined,
466
+ overrides: Map<string, SummaryOverride>,
467
+ ): string | undefined {
468
+ if (!summaryId) {
469
+ return undefined;
470
+ }
471
+
472
+ const override = overrides.get(summaryId);
473
+ if (override?.content.trim()) {
474
+ return override.content.trim();
475
+ }
476
+
477
+ const row = db
478
+ .prepare(`SELECT COALESCE(content, '') AS content FROM summaries WHERE summary_id = ?`)
479
+ .get(summaryId) as { content: string } | undefined;
480
+ const content = row?.content.trim();
481
+ return content ? content : undefined;
482
+ }
483
+
484
+ function resolveSummaryTimeRange(params: {
485
+ earliestAt: string | null;
486
+ latestAt: string | null;
487
+ createdAt: string;
488
+ }): SummaryTimeRange {
489
+ const earliestAt = parseSqliteTimestamp(params.earliestAt) ?? parseSqliteTimestamp(params.createdAt);
490
+ const latestAt = parseSqliteTimestamp(params.latestAt) ?? parseSqliteTimestamp(params.createdAt);
491
+ return {
492
+ earliestAt,
493
+ latestAt,
494
+ };
495
+ }
496
+
497
+ function formatSummaryTimeRange(range: SummaryTimeRange, timezone: string): string {
498
+ if (!range.earliestAt || !range.latestAt) {
499
+ return "";
500
+ }
501
+ return `[${formatTimestamp(range.earliestAt, timezone)} - ${formatTimestamp(range.latestAt, timezone)}]`;
502
+ }
503
+
504
+ function formatSqliteTimestamp(value: string, timezone: string): string {
505
+ const date = parseSqliteTimestamp(value);
506
+ if (date) {
507
+ return formatTimestamp(date, timezone);
508
+ }
509
+ const fallback = value.trim();
510
+ return fallback || "unknown";
511
+ }
512
+
513
+ function parseSqliteTimestamp(value: string | null | undefined): Date | null {
514
+ const normalized = value?.trim();
515
+ if (!normalized) {
516
+ return null;
517
+ }
518
+
519
+ const direct = new Date(normalized);
520
+ if (!Number.isNaN(direct.getTime())) {
521
+ return direct;
522
+ }
523
+
524
+ const sqlite = new Date(normalized.replace(" ", "T") + "Z");
525
+ if (!Number.isNaN(sqlite.getTime())) {
526
+ return sqlite;
527
+ }
528
+ return null;
529
+ }
530
+
531
+ function estimateTokens(text: string): number {
532
+ return Math.max(1, Math.ceil(text.length / 4));
533
+ }
534
+
535
+ function updateSummaryFts(db: DatabaseSync, summaryId: string, content: string): void {
536
+ try {
537
+ const update = db
538
+ .prepare(`UPDATE summaries_fts SET content = ? WHERE summary_id = ?`)
539
+ .run(content, summaryId);
540
+ if (Number(update.changes ?? 0) === 0) {
541
+ db.prepare(`INSERT INTO summaries_fts(summary_id, content) VALUES (?, ?)`).run(summaryId, content);
542
+ }
543
+ } catch {
544
+ // FTS repair is best-effort; the primary source of truth is summaries.
545
+ }
546
+ }