@martian-engineering/lossless-claw 0.5.3 → 0.6.1

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,210 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+
3
+ export const FALLBACK_SUMMARY_MARKER = "[LCM fallback summary; truncated for context management]";
4
+ export const TRUNCATED_SUMMARY_PREFIX = "[Truncated from ";
5
+ export const TRUNCATED_SUMMARY_WINDOW = 40;
6
+ export const FALLBACK_SUMMARY_WINDOW = 80;
7
+
8
+ export type DoctorMarkerKind = "old" | "new" | "fallback";
9
+
10
+ export type DoctorSummaryCandidate = {
11
+ conversationId: number;
12
+ summaryId: string;
13
+ markerKind: DoctorMarkerKind;
14
+ };
15
+
16
+ export type DoctorConversationCounts = {
17
+ total: number;
18
+ old: number;
19
+ truncated: number;
20
+ fallback: number;
21
+ };
22
+
23
+ export type DoctorSummaryStats = {
24
+ candidates: DoctorSummaryCandidate[];
25
+ total: number;
26
+ old: number;
27
+ truncated: number;
28
+ fallback: number;
29
+ byConversation: Map<number, DoctorConversationCounts>;
30
+ };
31
+
32
+ export type DoctorTargetRecord = {
33
+ conversationId: number;
34
+ summaryId: string;
35
+ kind: string;
36
+ depth: number;
37
+ tokenCount: number;
38
+ content: string;
39
+ createdAt: string;
40
+ childCount: number;
41
+ markerKind: DoctorMarkerKind;
42
+ };
43
+
44
+ type DoctorTargetRow = {
45
+ conversation_id: number;
46
+ summary_id: string;
47
+ kind: string;
48
+ depth: number;
49
+ token_count: number;
50
+ content: string;
51
+ created_at: string;
52
+ child_count: number | null;
53
+ };
54
+
55
+ /**
56
+ * Detect broken summary markers that doctor should flag or repair.
57
+ */
58
+ export function detectDoctorMarker(content: string): DoctorMarkerKind | null {
59
+ if (content.startsWith(FALLBACK_SUMMARY_MARKER)) {
60
+ return "old";
61
+ }
62
+
63
+ const truncatedIndex = content.indexOf(TRUNCATED_SUMMARY_PREFIX);
64
+ if (truncatedIndex >= 0 && content.length - truncatedIndex < TRUNCATED_SUMMARY_WINDOW) {
65
+ return "new";
66
+ }
67
+
68
+ const fallbackIndex = content.indexOf(FALLBACK_SUMMARY_MARKER);
69
+ if (fallbackIndex >= 0 && content.length - fallbackIndex < FALLBACK_SUMMARY_WINDOW) {
70
+ return "fallback";
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Load doctor targets for one conversation or the whole DB.
78
+ */
79
+ export function loadDoctorTargets(
80
+ db: DatabaseSync,
81
+ conversationId?: number,
82
+ ): DoctorTargetRecord[] {
83
+ const statement = conversationId === undefined
84
+ ? db.prepare(
85
+ `SELECT
86
+ s.conversation_id,
87
+ s.summary_id,
88
+ s.kind,
89
+ COALESCE(s.depth, 0) AS depth,
90
+ COALESCE(s.token_count, 0) AS token_count,
91
+ COALESCE(s.content, '') AS content,
92
+ COALESCE(s.created_at, '') AS created_at,
93
+ COALESCE(spc.child_count, 0) AS child_count
94
+ FROM summaries s
95
+ LEFT JOIN (
96
+ SELECT summary_id, COUNT(*) AS child_count
97
+ FROM summary_parents
98
+ GROUP BY summary_id
99
+ ) spc ON spc.summary_id = s.summary_id
100
+ WHERE INSTR(COALESCE(s.content, ''), ?) > 0
101
+ OR INSTR(COALESCE(s.content, ''), ?) > 0
102
+ ORDER BY s.conversation_id ASC, COALESCE(s.depth, 0) ASC, s.created_at ASC, s.summary_id ASC`,
103
+ )
104
+ : db.prepare(
105
+ `SELECT
106
+ s.conversation_id,
107
+ s.summary_id,
108
+ s.kind,
109
+ COALESCE(s.depth, 0) AS depth,
110
+ COALESCE(s.token_count, 0) AS token_count,
111
+ COALESCE(s.content, '') AS content,
112
+ COALESCE(s.created_at, '') AS created_at,
113
+ COALESCE(spc.child_count, 0) AS child_count
114
+ FROM summaries s
115
+ LEFT JOIN (
116
+ SELECT summary_id, COUNT(*) AS child_count
117
+ FROM summary_parents
118
+ GROUP BY summary_id
119
+ ) spc ON spc.summary_id = s.summary_id
120
+ WHERE s.conversation_id = ?
121
+ AND (
122
+ INSTR(COALESCE(s.content, ''), ?) > 0
123
+ OR INSTR(COALESCE(s.content, ''), ?) > 0
124
+ )
125
+ ORDER BY COALESCE(s.depth, 0) ASC, s.created_at ASC, s.summary_id ASC`,
126
+ );
127
+
128
+ const rows = (conversationId === undefined
129
+ ? statement.all(FALLBACK_SUMMARY_MARKER, TRUNCATED_SUMMARY_PREFIX)
130
+ : statement.all(conversationId, FALLBACK_SUMMARY_MARKER, TRUNCATED_SUMMARY_PREFIX)) as DoctorTargetRow[];
131
+
132
+ const targets: DoctorTargetRecord[] = [];
133
+ for (const row of rows) {
134
+ const markerKind = detectDoctorMarker(row.content);
135
+ if (!markerKind) {
136
+ continue;
137
+ }
138
+ targets.push({
139
+ conversationId: row.conversation_id,
140
+ summaryId: row.summary_id,
141
+ kind: row.kind,
142
+ depth: Math.max(0, Math.floor(row.depth ?? 0)),
143
+ tokenCount: Math.max(0, Math.floor(row.token_count ?? 0)),
144
+ content: row.content,
145
+ createdAt: row.created_at,
146
+ childCount:
147
+ typeof row.child_count === "number" && Number.isFinite(row.child_count)
148
+ ? Math.max(0, Math.floor(row.child_count))
149
+ : 0,
150
+ markerKind,
151
+ });
152
+ }
153
+ return targets;
154
+ }
155
+
156
+ /**
157
+ * Aggregate doctor counts from target rows.
158
+ */
159
+ export function getDoctorSummaryStats(
160
+ db: DatabaseSync,
161
+ conversationId?: number,
162
+ ): DoctorSummaryStats {
163
+ const targets = loadDoctorTargets(db, conversationId);
164
+ const candidates: DoctorSummaryCandidate[] = [];
165
+ const byConversation = new Map<number, DoctorConversationCounts>();
166
+ let old = 0;
167
+ let truncated = 0;
168
+ let fallback = 0;
169
+
170
+ for (const target of targets) {
171
+ const current = byConversation.get(target.conversationId) ?? {
172
+ total: 0,
173
+ old: 0,
174
+ truncated: 0,
175
+ fallback: 0,
176
+ };
177
+ current.total += 1;
178
+
179
+ switch (target.markerKind) {
180
+ case "old":
181
+ old += 1;
182
+ current.old += 1;
183
+ break;
184
+ case "new":
185
+ truncated += 1;
186
+ current.truncated += 1;
187
+ break;
188
+ case "fallback":
189
+ fallback += 1;
190
+ current.fallback += 1;
191
+ break;
192
+ }
193
+
194
+ byConversation.set(target.conversationId, current);
195
+ candidates.push({
196
+ conversationId: target.conversationId,
197
+ summaryId: target.summaryId,
198
+ markerKind: target.markerKind,
199
+ });
200
+ }
201
+
202
+ return {
203
+ candidates,
204
+ total: candidates.length,
205
+ old,
206
+ truncated,
207
+ fallback,
208
+ byConversation,
209
+ };
210
+ }
@@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { sanitizeFts5Query } from "./fts5-sanitize.js";
4
4
  import { buildLikeSearchPlan, containsCjk, createFallbackSnippet } from "./full-text-fallback.js";
5
+ import { parseUtcTimestamp, parseUtcTimestampOrNull } from "./parse-utc-timestamp.js";
5
6
 
6
7
  export type ConversationId = number;
7
8
  export type MessageId = number;
@@ -69,12 +70,16 @@ export type CreateConversationInput = {
69
70
  sessionId: string;
70
71
  sessionKey?: string;
71
72
  title?: string;
73
+ active?: boolean;
74
+ archivedAt?: Date | null;
72
75
  };
73
76
 
74
77
  export type ConversationRecord = {
75
78
  conversationId: ConversationId;
76
79
  sessionId: string;
77
80
  sessionKey: string | null;
81
+ active: boolean;
82
+ archivedAt: Date | null;
78
83
  title: string | null;
79
84
  bootstrappedAt: Date | null;
80
85
  createdAt: Date;
@@ -105,6 +110,8 @@ interface ConversationRow {
105
110
  conversation_id: number;
106
111
  session_id: string;
107
112
  session_key: string | null;
113
+ active: number;
114
+ archived_at: string | null;
108
115
  title: string | null;
109
116
  bootstrapped_at: string | null;
110
117
  created_at: string;
@@ -159,10 +166,12 @@ function toConversationRecord(row: ConversationRow): ConversationRecord {
159
166
  conversationId: row.conversation_id,
160
167
  sessionId: row.session_id,
161
168
  sessionKey: row.session_key ?? null,
169
+ active: row.active === 1,
170
+ archivedAt: parseUtcTimestampOrNull(row.archived_at),
162
171
  title: row.title,
163
- bootstrappedAt: row.bootstrapped_at ? new Date(row.bootstrapped_at) : null,
164
- createdAt: new Date(row.created_at),
165
- updatedAt: new Date(row.updated_at),
172
+ bootstrappedAt: parseUtcTimestampOrNull(row.bootstrapped_at),
173
+ createdAt: parseUtcTimestamp(row.created_at),
174
+ updatedAt: parseUtcTimestamp(row.updated_at),
166
175
  };
167
176
  }
168
177
 
@@ -174,7 +183,7 @@ function toMessageRecord(row: MessageRow): MessageRecord {
174
183
  role: row.role,
175
184
  content: row.content,
176
185
  tokenCount: row.token_count,
177
- createdAt: new Date(row.created_at),
186
+ createdAt: parseUtcTimestamp(row.created_at),
178
187
  };
179
188
  }
180
189
 
@@ -184,7 +193,7 @@ function toSearchResult(row: MessageSearchRow): MessageSearchResult {
184
193
  conversationId: row.conversation_id,
185
194
  role: row.role,
186
195
  snippet: row.snippet,
187
- createdAt: new Date(row.created_at),
196
+ createdAt: parseUtcTimestamp(row.created_at),
188
197
  rank: row.rank,
189
198
  };
190
199
  }
@@ -276,12 +285,21 @@ export class ConversationStore {
276
285
 
277
286
  async createConversation(input: CreateConversationInput): Promise<ConversationRecord> {
278
287
  const result = this.db
279
- .prepare(`INSERT INTO conversations (session_id, session_key, title) VALUES (?, ?, ?)`)
280
- .run(input.sessionId, input.sessionKey ?? null, input.title ?? null);
288
+ .prepare(
289
+ `INSERT INTO conversations (session_id, session_key, active, archived_at, title)
290
+ VALUES (?, ?, ?, ?, ?)`,
291
+ )
292
+ .run(
293
+ input.sessionId,
294
+ input.sessionKey ?? null,
295
+ input.active === false ? 0 : 1,
296
+ input.archivedAt?.toISOString() ?? null,
297
+ input.title ?? null,
298
+ );
281
299
 
282
300
  const row = this.db
283
301
  .prepare(
284
- `SELECT conversation_id, session_id, session_key, title, bootstrapped_at, created_at, updated_at
302
+ `SELECT conversation_id, session_id, session_key, active, archived_at, title, bootstrapped_at, created_at, updated_at
285
303
  FROM conversations WHERE conversation_id = ?`,
286
304
  )
287
305
  .get(Number(result.lastInsertRowid)) as unknown as ConversationRow;
@@ -292,7 +310,7 @@ export class ConversationStore {
292
310
  async getConversation(conversationId: ConversationId): Promise<ConversationRecord | null> {
293
311
  const row = this.db
294
312
  .prepare(
295
- `SELECT conversation_id, session_id, session_key, title, bootstrapped_at, created_at, updated_at
313
+ `SELECT conversation_id, session_id, session_key, active, archived_at, title, bootstrapped_at, created_at, updated_at
296
314
  FROM conversations WHERE conversation_id = ?`,
297
315
  )
298
316
  .get(conversationId) as unknown as ConversationRow | undefined;
@@ -303,10 +321,10 @@ export class ConversationStore {
303
321
  async getConversationBySessionId(sessionId: string): Promise<ConversationRecord | null> {
304
322
  const row = this.db
305
323
  .prepare(
306
- `SELECT conversation_id, session_id, session_key, title, bootstrapped_at, created_at, updated_at
324
+ `SELECT conversation_id, session_id, session_key, active, archived_at, title, bootstrapped_at, created_at, updated_at
307
325
  FROM conversations
308
326
  WHERE session_id = ?
309
- ORDER BY created_at DESC
327
+ ORDER BY active DESC, created_at DESC
310
328
  LIMIT 1`,
311
329
  )
312
330
  .get(sessionId) as unknown as ConversationRow | undefined;
@@ -317,9 +335,11 @@ export class ConversationStore {
317
335
  async getConversationBySessionKey(sessionKey: string): Promise<ConversationRecord | null> {
318
336
  const row = this.db
319
337
  .prepare(
320
- `SELECT conversation_id, session_id, session_key, title, bootstrapped_at, created_at, updated_at
338
+ `SELECT conversation_id, session_id, session_key, active, archived_at, title, bootstrapped_at, created_at, updated_at
321
339
  FROM conversations
322
340
  WHERE session_key = ?
341
+ AND active = 1
342
+ ORDER BY created_at DESC
323
343
  LIMIT 1`,
324
344
  )
325
345
  .get(sessionKey) as unknown as ConversationRow | undefined;
@@ -353,8 +373,9 @@ export class ConversationStore {
353
373
  titleOrOpts?: string | { title?: string; sessionKey?: string },
354
374
  ): Promise<ConversationRecord> {
355
375
  const opts = typeof titleOrOpts === "string" ? { title: titleOrOpts } : titleOrOpts ?? {};
356
- if (opts.sessionKey) {
357
- const byKey = await this.getConversationBySessionKey(opts.sessionKey);
376
+ const normalizedSessionKey = opts.sessionKey?.trim();
377
+ if (normalizedSessionKey) {
378
+ const byKey = await this.getConversationBySessionKey(normalizedSessionKey);
358
379
  if (byKey) {
359
380
  if (byKey.sessionId !== sessionId) {
360
381
  this.db
@@ -370,18 +391,24 @@ export class ConversationStore {
370
391
 
371
392
  const existing = await this.getConversationBySessionId(sessionId);
372
393
  if (existing) {
373
- if (opts.sessionKey && !existing.sessionKey) {
394
+ if (!normalizedSessionKey) {
395
+ return existing;
396
+ }
397
+ if (existing.active && !existing.sessionKey) {
374
398
  this.db
375
399
  .prepare(
376
400
  `UPDATE conversations SET session_key = ?, updated_at = datetime('now') WHERE conversation_id = ?`,
377
401
  )
378
- .run(opts.sessionKey, existing.conversationId);
379
- existing.sessionKey = opts.sessionKey;
402
+ .run(normalizedSessionKey, existing.conversationId);
403
+ existing.sessionKey = normalizedSessionKey;
404
+ return existing;
405
+ }
406
+ if (existing.active && existing.sessionKey === normalizedSessionKey) {
407
+ return existing;
380
408
  }
381
- return existing;
382
409
  }
383
410
 
384
- return this.createConversation({ sessionId, title: opts.title, sessionKey: opts.sessionKey });
411
+ return this.createConversation({ sessionId, title: opts.title, sessionKey: normalizedSessionKey });
385
412
  }
386
413
 
387
414
  async markConversationBootstrapped(conversationId: ConversationId): Promise<void> {
@@ -395,6 +422,18 @@ export class ConversationStore {
395
422
  .run(conversationId);
396
423
  }
397
424
 
425
+ async archiveConversation(conversationId: ConversationId): Promise<void> {
426
+ this.db
427
+ .prepare(
428
+ `UPDATE conversations
429
+ SET active = 0,
430
+ archived_at = COALESCE(archived_at, datetime('now')),
431
+ updated_at = datetime('now')
432
+ WHERE conversation_id = ?`,
433
+ )
434
+ .run(conversationId);
435
+ }
436
+
398
437
  // ── Message operations ────────────────────────────────────────────────────
399
438
 
400
439
  async createMessage(input: CreateMessageInput): Promise<MessageRecord> {
@@ -817,7 +856,7 @@ export class ConversationStore {
817
856
  conversationId: row.conversation_id,
818
857
  role: row.role,
819
858
  snippet: createFallbackSnippet(normalizedContent, plan.terms),
820
- createdAt: new Date(row.created_at),
859
+ createdAt: parseUtcTimestamp(row.created_at),
821
860
  rank: 0,
822
861
  };
823
862
  })
@@ -882,7 +921,7 @@ export class ConversationStore {
882
921
  conversationId: row.conversation_id,
883
922
  role: row.role,
884
923
  snippet: match[0],
885
- createdAt: new Date(row.created_at),
924
+ createdAt: parseUtcTimestamp(row.created_at),
886
925
  rank: 0,
887
926
  });
888
927
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Parse a SQLite UTC timestamp string into a Date object.
3
+ * SQLite stores timestamps via datetime('now') without a Z suffix,
4
+ * which causes JS to parse them as local time instead of UTC.
5
+ * See: https://github.com/Martian-Engineering/lossless-claw/issues/216
6
+ */
7
+ export function parseUtcTimestamp(value: string): Date {
8
+ const s = value.trim();
9
+ if (/(?:[zZ]|[+-]\d{2}:\d{2})$/.test(s)) {
10
+ return new Date(s);
11
+ }
12
+
13
+ const normalized = s.includes("T") ? s : s.replace(" ", "T");
14
+ return new Date(`${normalized}Z`);
15
+ }
16
+
17
+ /**
18
+ * Parse a nullable SQLite UTC timestamp string into a Date object.
19
+ */
20
+ export function parseUtcTimestampOrNull(
21
+ value: string | null | undefined,
22
+ ): Date | null {
23
+ if (value == null) return null;
24
+ return parseUtcTimestamp(value);
25
+ }