@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.
- package/README.md +49 -11
- package/docs/configuration.md +44 -0
- package/openclaw.plugin.json +114 -0
- package/package.json +2 -1
- package/skills/lossless-claw/SKILL.md +33 -0
- package/skills/lossless-claw/references/architecture.md +52 -0
- package/skills/lossless-claw/references/config.md +263 -0
- package/skills/lossless-claw/references/diagnostics.md +79 -0
- package/skills/lossless-claw/references/recall-tools.md +55 -0
- package/skills/lossless-claw/references/session-lifecycle.md +59 -0
- package/src/assembler.ts +321 -34
- package/src/compaction.ts +220 -19
- package/src/db/config.ts +74 -21
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +742 -133
- package/src/plugin/index.ts +156 -73
- package/src/plugin/lcm-command.ts +759 -0
- package/src/plugin/lcm-doctor-apply.ts +546 -0
- package/src/plugin/lcm-doctor-shared.ts +210 -0
- package/src/store/conversation-store.ts +60 -21
- package/src/store/parse-utc-timestamp.ts +25 -0
- package/src/store/summary-store.ts +460 -11
- package/src/summarize.ts +553 -224
- package/src/tools/lcm-expand-query-tool.ts +195 -59
- package/src/tools/lcm-expansion-recursion-guard.ts +87 -0
- package/src/types.ts +1 -0
|
@@ -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:
|
|
164
|
-
createdAt:
|
|
165
|
-
updatedAt:
|
|
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:
|
|
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:
|
|
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(
|
|
280
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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 (
|
|
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(
|
|
379
|
-
existing.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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|