@martian-engineering/lossless-claw 0.4.0 → 0.5.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.
- package/package.json +2 -1
- package/src/assembler.ts +37 -3
- package/src/compaction.ts +83 -10
- package/src/db/connection.ts +2 -0
- package/src/db/migration.ts +84 -0
- package/src/engine.ts +657 -146
- package/src/large-files.ts +19 -0
- package/src/plugin/index.ts +188 -28
- package/src/store/conversation-store.ts +76 -10
- package/src/store/full-text-fallback.ts +9 -0
- package/src/store/index.ts +2 -0
- package/src/store/summary-store.ts +130 -10
- package/src/summarize.ts +209 -13
- package/src/types.ts +9 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { sanitizeFts5Query } from "./fts5-sanitize.js";
|
|
3
|
-
import { buildLikeSearchPlan, createFallbackSnippet } from "./full-text-fallback.js";
|
|
3
|
+
import { buildLikeSearchPlan, containsCjk, createFallbackSnippet } from "./full-text-fallback.js";
|
|
4
4
|
|
|
5
5
|
export type SummaryKind = "leaf" | "condensed";
|
|
6
6
|
export type ContextItemType = "message" | "summary";
|
|
@@ -18,6 +18,7 @@ export type CreateSummaryInput = {
|
|
|
18
18
|
descendantCount?: number;
|
|
19
19
|
descendantTokenCount?: number;
|
|
20
20
|
sourceMessageTokenCount?: number;
|
|
21
|
+
model?: string;
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
export type SummaryRecord = {
|
|
@@ -33,6 +34,7 @@ export type SummaryRecord = {
|
|
|
33
34
|
descendantCount: number;
|
|
34
35
|
descendantTokenCount: number;
|
|
35
36
|
sourceMessageTokenCount: number;
|
|
37
|
+
model: string;
|
|
36
38
|
createdAt: Date;
|
|
37
39
|
};
|
|
38
40
|
|
|
@@ -91,6 +93,25 @@ export type LargeFileRecord = {
|
|
|
91
93
|
createdAt: Date;
|
|
92
94
|
};
|
|
93
95
|
|
|
96
|
+
export type UpsertConversationBootstrapStateInput = {
|
|
97
|
+
conversationId: number;
|
|
98
|
+
sessionFilePath: string;
|
|
99
|
+
lastSeenSize: number;
|
|
100
|
+
lastSeenMtimeMs: number;
|
|
101
|
+
lastProcessedOffset: number;
|
|
102
|
+
lastProcessedEntryHash?: string | null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type ConversationBootstrapStateRecord = {
|
|
106
|
+
conversationId: number;
|
|
107
|
+
sessionFilePath: string;
|
|
108
|
+
lastSeenSize: number;
|
|
109
|
+
lastSeenMtimeMs: number;
|
|
110
|
+
lastProcessedOffset: number;
|
|
111
|
+
lastProcessedEntryHash: string | null;
|
|
112
|
+
updatedAt: Date;
|
|
113
|
+
};
|
|
114
|
+
|
|
94
115
|
// ── DB row shapes (snake_case) ────────────────────────────────────────────────
|
|
95
116
|
|
|
96
117
|
interface SummaryRow {
|
|
@@ -106,6 +127,7 @@ interface SummaryRow {
|
|
|
106
127
|
descendant_count: number | null;
|
|
107
128
|
descendant_token_count: number | null;
|
|
108
129
|
source_message_token_count: number | null;
|
|
130
|
+
model: string | null;
|
|
109
131
|
created_at: string;
|
|
110
132
|
}
|
|
111
133
|
|
|
@@ -161,6 +183,16 @@ interface LargeFileRow {
|
|
|
161
183
|
created_at: string;
|
|
162
184
|
}
|
|
163
185
|
|
|
186
|
+
interface ConversationBootstrapStateRow {
|
|
187
|
+
conversation_id: number;
|
|
188
|
+
session_file_path: string;
|
|
189
|
+
last_seen_size: number;
|
|
190
|
+
last_seen_mtime_ms: number;
|
|
191
|
+
last_processed_offset: number;
|
|
192
|
+
last_processed_entry_hash: string | null;
|
|
193
|
+
updated_at: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
164
196
|
// ── Row mappers ───────────────────────────────────────────────────────────────
|
|
165
197
|
|
|
166
198
|
function toSummaryRecord(row: SummaryRow): SummaryRecord {
|
|
@@ -198,6 +230,7 @@ function toSummaryRecord(row: SummaryRow): SummaryRecord {
|
|
|
198
230
|
row.source_message_token_count >= 0
|
|
199
231
|
? Math.floor(row.source_message_token_count)
|
|
200
232
|
: 0,
|
|
233
|
+
model: typeof row.model === "string" ? row.model : "unknown",
|
|
201
234
|
createdAt: new Date(row.created_at),
|
|
202
235
|
};
|
|
203
236
|
}
|
|
@@ -237,6 +270,20 @@ function toLargeFileRecord(row: LargeFileRow): LargeFileRecord {
|
|
|
237
270
|
};
|
|
238
271
|
}
|
|
239
272
|
|
|
273
|
+
function toConversationBootstrapStateRecord(
|
|
274
|
+
row: ConversationBootstrapStateRow,
|
|
275
|
+
): ConversationBootstrapStateRecord {
|
|
276
|
+
return {
|
|
277
|
+
conversationId: row.conversation_id,
|
|
278
|
+
sessionFilePath: row.session_file_path,
|
|
279
|
+
lastSeenSize: row.last_seen_size,
|
|
280
|
+
lastSeenMtimeMs: row.last_seen_mtime_ms,
|
|
281
|
+
lastProcessedOffset: row.last_processed_offset,
|
|
282
|
+
lastProcessedEntryHash: row.last_processed_entry_hash,
|
|
283
|
+
updatedAt: new Date(row.updated_at),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
240
287
|
// ── SummaryStore ──────────────────────────────────────────────────────────────
|
|
241
288
|
|
|
242
289
|
export class SummaryStore {
|
|
@@ -294,9 +341,10 @@ export class SummaryStore {
|
|
|
294
341
|
latest_at,
|
|
295
342
|
descendant_count,
|
|
296
343
|
descendant_token_count,
|
|
297
|
-
source_message_token_count
|
|
344
|
+
source_message_token_count,
|
|
345
|
+
model
|
|
298
346
|
)
|
|
299
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
347
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
300
348
|
)
|
|
301
349
|
.run(
|
|
302
350
|
input.summaryId,
|
|
@@ -311,13 +359,14 @@ export class SummaryStore {
|
|
|
311
359
|
descendantCount,
|
|
312
360
|
descendantTokenCount,
|
|
313
361
|
sourceMessageTokenCount,
|
|
362
|
+
input.model ?? "unknown",
|
|
314
363
|
);
|
|
315
364
|
|
|
316
365
|
const row = this.db
|
|
317
366
|
.prepare(
|
|
318
367
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
319
368
|
earliest_at, latest_at, descendant_count, created_at
|
|
320
|
-
, descendant_token_count, source_message_token_count
|
|
369
|
+
, descendant_token_count, source_message_token_count, model
|
|
321
370
|
FROM summaries WHERE summary_id = ?`,
|
|
322
371
|
)
|
|
323
372
|
.get(input.summaryId) as unknown as SummaryRow;
|
|
@@ -345,7 +394,7 @@ export class SummaryStore {
|
|
|
345
394
|
.prepare(
|
|
346
395
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
347
396
|
earliest_at, latest_at, descendant_count, created_at
|
|
348
|
-
, descendant_token_count, source_message_token_count
|
|
397
|
+
, descendant_token_count, source_message_token_count, model
|
|
349
398
|
FROM summaries WHERE summary_id = ?`,
|
|
350
399
|
)
|
|
351
400
|
.get(summaryId) as unknown as SummaryRow | undefined;
|
|
@@ -357,7 +406,7 @@ export class SummaryStore {
|
|
|
357
406
|
.prepare(
|
|
358
407
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
359
408
|
earliest_at, latest_at, descendant_count, created_at
|
|
360
|
-
, descendant_token_count, source_message_token_count
|
|
409
|
+
, descendant_token_count, source_message_token_count, model
|
|
361
410
|
FROM summaries
|
|
362
411
|
WHERE conversation_id = ?
|
|
363
412
|
ORDER BY created_at`,
|
|
@@ -416,7 +465,7 @@ export class SummaryStore {
|
|
|
416
465
|
.prepare(
|
|
417
466
|
`SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
|
|
418
467
|
s.file_ids, s.earliest_at, s.latest_at, s.descendant_count, s.created_at
|
|
419
|
-
, s.descendant_token_count, s.source_message_token_count
|
|
468
|
+
, s.descendant_token_count, s.source_message_token_count, s.model
|
|
420
469
|
FROM summaries s
|
|
421
470
|
JOIN summary_parents sp ON sp.summary_id = s.summary_id
|
|
422
471
|
WHERE sp.parent_summary_id = ?
|
|
@@ -434,7 +483,7 @@ export class SummaryStore {
|
|
|
434
483
|
.prepare(
|
|
435
484
|
`SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
|
|
436
485
|
s.file_ids, s.earliest_at, s.latest_at, s.descendant_count, s.created_at
|
|
437
|
-
, s.descendant_token_count, s.source_message_token_count
|
|
486
|
+
, s.descendant_token_count, s.source_message_token_count, s.model
|
|
438
487
|
FROM summaries s
|
|
439
488
|
JOIN summary_parents sp ON sp.parent_summary_id = s.summary_id
|
|
440
489
|
WHERE sp.summary_id = ?
|
|
@@ -474,6 +523,7 @@ export class SummaryStore {
|
|
|
474
523
|
s.descendant_count,
|
|
475
524
|
s.descendant_token_count,
|
|
476
525
|
s.source_message_token_count,
|
|
526
|
+
s.model,
|
|
477
527
|
s.created_at,
|
|
478
528
|
subtree.depth_from_root,
|
|
479
529
|
subtree.parent_summary_id,
|
|
@@ -700,6 +750,17 @@ export class SummaryStore {
|
|
|
700
750
|
const limit = input.limit ?? 50;
|
|
701
751
|
|
|
702
752
|
if (input.mode === "full_text") {
|
|
753
|
+
// FTS5 unicode61 can return incomplete matches for CJK text, so route
|
|
754
|
+
// those queries through the existing LIKE fallback path immediately.
|
|
755
|
+
if (containsCjk(input.query)) {
|
|
756
|
+
return this.searchLike(
|
|
757
|
+
input.query,
|
|
758
|
+
limit,
|
|
759
|
+
input.conversationId,
|
|
760
|
+
input.since,
|
|
761
|
+
input.before,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
703
764
|
if (this.fts5Available) {
|
|
704
765
|
try {
|
|
705
766
|
return this.searchFullText(
|
|
@@ -796,7 +857,7 @@ export class SummaryStore {
|
|
|
796
857
|
.prepare(
|
|
797
858
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
798
859
|
earliest_at, latest_at, descendant_count, descendant_token_count,
|
|
799
|
-
source_message_token_count, created_at
|
|
860
|
+
source_message_token_count, model, created_at
|
|
800
861
|
FROM summaries
|
|
801
862
|
${whereClause}
|
|
802
863
|
ORDER BY created_at DESC
|
|
@@ -851,7 +912,7 @@ export class SummaryStore {
|
|
|
851
912
|
.prepare(
|
|
852
913
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
853
914
|
earliest_at, latest_at, descendant_count, descendant_token_count,
|
|
854
|
-
source_message_token_count, created_at
|
|
915
|
+
source_message_token_count, model, created_at
|
|
855
916
|
FROM summaries
|
|
856
917
|
${whereClause}
|
|
857
918
|
ORDER BY created_at DESC`,
|
|
@@ -930,4 +991,63 @@ export class SummaryStore {
|
|
|
930
991
|
.all(conversationId) as unknown as LargeFileRow[];
|
|
931
992
|
return rows.map(toLargeFileRecord);
|
|
932
993
|
}
|
|
994
|
+
|
|
995
|
+
// ── Bootstrap state ──────────────────────────────────────────────────────
|
|
996
|
+
|
|
997
|
+
async getConversationBootstrapState(
|
|
998
|
+
conversationId: number,
|
|
999
|
+
): Promise<ConversationBootstrapStateRecord | null> {
|
|
1000
|
+
const row = this.db
|
|
1001
|
+
.prepare(
|
|
1002
|
+
`SELECT conversation_id, session_file_path, last_seen_size, last_seen_mtime_ms,
|
|
1003
|
+
last_processed_offset, last_processed_entry_hash, updated_at
|
|
1004
|
+
FROM conversation_bootstrap_state
|
|
1005
|
+
WHERE conversation_id = ?`,
|
|
1006
|
+
)
|
|
1007
|
+
.get(conversationId) as unknown as ConversationBootstrapStateRow | undefined;
|
|
1008
|
+
return row ? toConversationBootstrapStateRecord(row) : null;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async upsertConversationBootstrapState(
|
|
1012
|
+
input: UpsertConversationBootstrapStateInput,
|
|
1013
|
+
): Promise<ConversationBootstrapStateRecord> {
|
|
1014
|
+
this.db
|
|
1015
|
+
.prepare(
|
|
1016
|
+
`INSERT INTO conversation_bootstrap_state (
|
|
1017
|
+
conversation_id,
|
|
1018
|
+
session_file_path,
|
|
1019
|
+
last_seen_size,
|
|
1020
|
+
last_seen_mtime_ms,
|
|
1021
|
+
last_processed_offset,
|
|
1022
|
+
last_processed_entry_hash
|
|
1023
|
+
)
|
|
1024
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1025
|
+
ON CONFLICT (conversation_id) DO UPDATE SET
|
|
1026
|
+
session_file_path = excluded.session_file_path,
|
|
1027
|
+
last_seen_size = excluded.last_seen_size,
|
|
1028
|
+
last_seen_mtime_ms = excluded.last_seen_mtime_ms,
|
|
1029
|
+
last_processed_offset = excluded.last_processed_offset,
|
|
1030
|
+
last_processed_entry_hash = excluded.last_processed_entry_hash,
|
|
1031
|
+
updated_at = datetime('now')`,
|
|
1032
|
+
)
|
|
1033
|
+
.run(
|
|
1034
|
+
input.conversationId,
|
|
1035
|
+
input.sessionFilePath,
|
|
1036
|
+
Math.max(0, Math.floor(input.lastSeenSize)),
|
|
1037
|
+
Math.max(0, Math.floor(input.lastSeenMtimeMs)),
|
|
1038
|
+
Math.max(0, Math.floor(input.lastProcessedOffset)),
|
|
1039
|
+
input.lastProcessedEntryHash ?? null,
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
const row = this.db
|
|
1043
|
+
.prepare(
|
|
1044
|
+
`SELECT conversation_id, session_file_path, last_seen_size, last_seen_mtime_ms,
|
|
1045
|
+
last_processed_offset, last_processed_entry_hash, updated_at
|
|
1046
|
+
FROM conversation_bootstrap_state
|
|
1047
|
+
WHERE conversation_id = ?`,
|
|
1048
|
+
)
|
|
1049
|
+
.get(input.conversationId) as unknown as ConversationBootstrapStateRow;
|
|
1050
|
+
|
|
1051
|
+
return toConversationBootstrapStateRecord(row);
|
|
1052
|
+
}
|
|
933
1053
|
}
|
package/src/summarize.ts
CHANGED
|
@@ -31,6 +31,41 @@ const DIAGNOSTIC_MAX_OBJECT_KEYS = 16;
|
|
|
31
31
|
const DIAGNOSTIC_MAX_CHARS = 1200;
|
|
32
32
|
const DIAGNOSTIC_SENSITIVE_KEY_PATTERN =
|
|
33
33
|
/(api[-_]?key|authorization|token|secret|password|cookie|set-cookie|private[-_]?key|bearer)/i;
|
|
34
|
+
const AUTH_ERROR_TEXT_PATTERN =
|
|
35
|
+
/\b401\b|unauthorized|unauthorised|invalid[_ -]?token|invalid[_ -]?api[_ -]?key|authentication failed|authorization failed|missing scope|insufficient scope|model\.request\b/i;
|
|
36
|
+
const AUTH_ERROR_STATUS_KEYS = ["status", "statusCode", "status_code"] as const;
|
|
37
|
+
const AUTH_ERROR_NESTED_KEYS = ["error", "response", "cause", "details", "data", "body"] as const;
|
|
38
|
+
|
|
39
|
+
type ProviderAuthFailure = {
|
|
40
|
+
statusCode?: number;
|
|
41
|
+
message?: string;
|
|
42
|
+
missingModelRequestScope: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default timeout for a single summarizer LLM call. Long enough for large
|
|
47
|
+
* context windows on slower providers, short enough to prevent the gateway
|
|
48
|
+
* event loop from starving when a provider hangs.
|
|
49
|
+
*/
|
|
50
|
+
const SUMMARIZER_TIMEOUT_MS = 60_000;
|
|
51
|
+
|
|
52
|
+
/** Error used to distinguish summarizer timeouts from provider failures. */
|
|
53
|
+
class SummarizerTimeoutError extends Error {
|
|
54
|
+
constructor(ms: number, label: string) {
|
|
55
|
+
super(`[lcm] summarizer timeout after ${ms}ms (${label})`);
|
|
56
|
+
this.name = "SummarizerTimeoutError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
61
|
+
return new Promise<T>((resolve, reject) => {
|
|
62
|
+
const timer = setTimeout(() => reject(new SummarizerTimeoutError(ms, label)), ms);
|
|
63
|
+
promise.then(
|
|
64
|
+
(val) => { clearTimeout(timer); resolve(val); },
|
|
65
|
+
(err) => { clearTimeout(timer); reject(err); },
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
34
69
|
|
|
35
70
|
/** Normalize provider ids for stable config/profile lookup. */
|
|
36
71
|
function normalizeProviderId(provider: string): string {
|
|
@@ -272,6 +307,133 @@ function formatDiagnosticPayload(value: unknown): string {
|
|
|
272
307
|
}
|
|
273
308
|
}
|
|
274
309
|
|
|
310
|
+
function collectAuthFailureText(value: unknown, out: string[], depth = 0): void {
|
|
311
|
+
if (depth >= 4) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (typeof value === "string") {
|
|
315
|
+
const trimmed = value.trim();
|
|
316
|
+
if (trimmed) {
|
|
317
|
+
out.push(trimmed);
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (Array.isArray(value)) {
|
|
322
|
+
for (const entry of value.slice(0, DIAGNOSTIC_MAX_ARRAY_ITEMS)) {
|
|
323
|
+
collectAuthFailureText(entry, out, depth + 1);
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!isRecord(value)) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const entry of Object.values(value).slice(0, DIAGNOSTIC_MAX_OBJECT_KEYS)) {
|
|
332
|
+
collectAuthFailureText(entry, out, depth + 1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function extractAuthFailureStatusCode(value: unknown, depth = 0): number | undefined {
|
|
337
|
+
if (depth >= 4 || !isRecord(value)) {
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const key of AUTH_ERROR_STATUS_KEYS) {
|
|
342
|
+
const candidate = value[key];
|
|
343
|
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
344
|
+
return Math.trunc(candidate);
|
|
345
|
+
}
|
|
346
|
+
if (typeof candidate === "string") {
|
|
347
|
+
const parsed = Number.parseInt(candidate, 10);
|
|
348
|
+
if (Number.isFinite(parsed)) {
|
|
349
|
+
return parsed;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for (const key of AUTH_ERROR_NESTED_KEYS) {
|
|
355
|
+
const nested = value[key];
|
|
356
|
+
const statusCode = extractAuthFailureStatusCode(nested, depth + 1);
|
|
357
|
+
if (statusCode !== undefined) {
|
|
358
|
+
return statusCode;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function pickAuthInspectionValue(value: unknown): unknown {
|
|
366
|
+
if (!isRecord(value)) {
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
if (isRecord(value.error) && value.error.kind === "provider_auth") {
|
|
370
|
+
return value.error;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const subset: Record<string, unknown> = {};
|
|
374
|
+
for (const key of [
|
|
375
|
+
"error",
|
|
376
|
+
"errorMessage",
|
|
377
|
+
"message",
|
|
378
|
+
"status",
|
|
379
|
+
"statusCode",
|
|
380
|
+
"status_code",
|
|
381
|
+
"code",
|
|
382
|
+
"details",
|
|
383
|
+
"response",
|
|
384
|
+
"cause",
|
|
385
|
+
]) {
|
|
386
|
+
if (key in value) {
|
|
387
|
+
subset[key] = value[key];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return Object.keys(subset).length > 0 ? subset : value;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function extractProviderAuthFailure(value: unknown): ProviderAuthFailure | undefined {
|
|
394
|
+
const inspectValue = pickAuthInspectionValue(value);
|
|
395
|
+
const statusCode = extractAuthFailureStatusCode(inspectValue);
|
|
396
|
+
const textParts: string[] = [];
|
|
397
|
+
collectAuthFailureText(inspectValue, textParts);
|
|
398
|
+
const normalizedMessage = textParts.join(" ").replace(/\s+/g, " ").trim();
|
|
399
|
+
const missingModelRequestScope = /\bmodel\.request\b/i.test(normalizedMessage);
|
|
400
|
+
const hasScopeSignal =
|
|
401
|
+
missingModelRequestScope || /\b(missing|insufficient)\s+scope\b/i.test(normalizedMessage);
|
|
402
|
+
|
|
403
|
+
if (statusCode !== 401 && !hasScopeSignal && !AUTH_ERROR_TEXT_PATTERN.test(normalizedMessage)) {
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
...(statusCode !== undefined ? { statusCode } : {}),
|
|
409
|
+
...(normalizedMessage ? { message: truncateDiagnosticText(normalizedMessage, 240) } : {}),
|
|
410
|
+
missingModelRequestScope,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function buildProviderAuthWarning(params: {
|
|
415
|
+
provider: string;
|
|
416
|
+
model: string;
|
|
417
|
+
failure: ProviderAuthFailure;
|
|
418
|
+
}): string {
|
|
419
|
+
const detailParts: string[] = [];
|
|
420
|
+
if (params.failure.statusCode === 401) {
|
|
421
|
+
detailParts.push("401");
|
|
422
|
+
}
|
|
423
|
+
if (params.failure.missingModelRequestScope) {
|
|
424
|
+
detailParts.push("missing model.request scope");
|
|
425
|
+
}
|
|
426
|
+
const detail =
|
|
427
|
+
detailParts.length > 0
|
|
428
|
+
? `provider auth error (${detailParts.join(" / ")})`
|
|
429
|
+
: "provider auth error";
|
|
430
|
+
const messageSuffix =
|
|
431
|
+
params.failure.message && !params.failure.missingModelRequestScope
|
|
432
|
+
? ` Detail: ${params.failure.message}`
|
|
433
|
+
: "";
|
|
434
|
+
return `[lcm] compaction failed: ${detail}. Check that the configured summaryProvider has valid API credentials. Current: ${params.provider}/${params.model}${messageSuffix}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
275
437
|
/**
|
|
276
438
|
* Extract safe diagnostic metadata from a provider response envelope.
|
|
277
439
|
*
|
|
@@ -641,7 +803,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
641
803
|
deps: LcmDependencies;
|
|
642
804
|
legacyParams: LcmSummarizerLegacyParams;
|
|
643
805
|
customInstructions?: string;
|
|
644
|
-
}): Promise<LcmSummarizeFn | undefined> {
|
|
806
|
+
}): Promise<{ fn: LcmSummarizeFn; model: string } | undefined> {
|
|
645
807
|
const readModelRef = (value: unknown): string => {
|
|
646
808
|
if (typeof value === "string") {
|
|
647
809
|
return value.trim();
|
|
@@ -735,11 +897,14 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
735
897
|
console.error(`[lcm] createLcmSummarize: empty provider="${provider}" or model="${model}"`);
|
|
736
898
|
return undefined;
|
|
737
899
|
}
|
|
738
|
-
const
|
|
900
|
+
const legacyAuthProfileId =
|
|
739
901
|
typeof params.legacyParams.authProfileId === "string" &&
|
|
740
902
|
params.legacyParams.authProfileId.trim()
|
|
741
903
|
? params.legacyParams.authProfileId.trim()
|
|
742
904
|
: undefined;
|
|
905
|
+
// When LCM selects a dedicated summarizer model/provider, do not leak the
|
|
906
|
+
// active session's auth profile into that separate credential lookup.
|
|
907
|
+
const authProfileId = resolvedSummary === undefined ? legacyAuthProfileId : undefined;
|
|
743
908
|
const agentDir =
|
|
744
909
|
typeof params.legacyParams.agentDir === "string" && params.legacyParams.agentDir.trim()
|
|
745
910
|
? params.legacyParams.agentDir.trim()
|
|
@@ -752,7 +917,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
752
917
|
? params.deps.config.condensedTargetTokens
|
|
753
918
|
: DEFAULT_CONDENSED_TARGET_TOKENS;
|
|
754
919
|
|
|
755
|
-
|
|
920
|
+
const fn: LcmSummarizeFn = async (
|
|
756
921
|
text: string,
|
|
757
922
|
aggressive?: boolean,
|
|
758
923
|
options?: LcmSummarizeOptions,
|
|
@@ -795,7 +960,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
795
960
|
|
|
796
961
|
let result: Awaited<ReturnType<typeof params.deps.complete>>;
|
|
797
962
|
try {
|
|
798
|
-
result = await params.deps.complete({
|
|
963
|
+
result = await withTimeout(params.deps.complete({
|
|
799
964
|
provider,
|
|
800
965
|
model,
|
|
801
966
|
apiKey,
|
|
@@ -812,11 +977,30 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
812
977
|
],
|
|
813
978
|
maxTokens: targetTokens,
|
|
814
979
|
temperature: aggressive ? 0.1 : 0.2,
|
|
815
|
-
});
|
|
980
|
+
}), SUMMARIZER_TIMEOUT_MS, "initial");
|
|
816
981
|
} catch (err) {
|
|
817
|
-
|
|
818
|
-
|
|
982
|
+
const authFailure = extractProviderAuthFailure(err);
|
|
983
|
+
if (authFailure) {
|
|
984
|
+
console.warn(buildProviderAuthWarning({ provider, model, failure: authFailure }));
|
|
985
|
+
return "";
|
|
986
|
+
}
|
|
987
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
988
|
+
const isTimeout = errMsg.includes("summarizer timeout");
|
|
989
|
+
console.warn(
|
|
990
|
+
`[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${errMsg}`,
|
|
819
991
|
);
|
|
992
|
+
if (err instanceof SummarizerTimeoutError) {
|
|
993
|
+
console.error(
|
|
994
|
+
`[lcm] summarizer timed out; provider=${provider}; model=${model}; source=fallback`,
|
|
995
|
+
);
|
|
996
|
+
return buildDeterministicFallbackSummary(text, targetTokens);
|
|
997
|
+
}
|
|
998
|
+
return "";
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const authFailure = extractProviderAuthFailure(result);
|
|
1002
|
+
if (authFailure) {
|
|
1003
|
+
console.warn(buildProviderAuthWarning({ provider, model, failure: authFailure }));
|
|
820
1004
|
return "";
|
|
821
1005
|
}
|
|
822
1006
|
|
|
@@ -859,7 +1043,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
859
1043
|
// reasoning budget to coax a textual response from providers that
|
|
860
1044
|
// sometimes return reasoning-only or empty blocks on the first pass.
|
|
861
1045
|
try {
|
|
862
|
-
const retryResult = await params.deps.complete({
|
|
1046
|
+
const retryResult = await withTimeout(params.deps.complete({
|
|
863
1047
|
provider,
|
|
864
1048
|
model,
|
|
865
1049
|
apiKey,
|
|
@@ -877,7 +1061,12 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
877
1061
|
maxTokens: targetTokens,
|
|
878
1062
|
temperature: 0.05,
|
|
879
1063
|
reasoning: "low",
|
|
880
|
-
});
|
|
1064
|
+
}), SUMMARIZER_TIMEOUT_MS, "retry");
|
|
1065
|
+
const retryAuthFailure = extractProviderAuthFailure(retryResult);
|
|
1066
|
+
if (retryAuthFailure) {
|
|
1067
|
+
console.warn(buildProviderAuthWarning({ provider, model, failure: retryAuthFailure }));
|
|
1068
|
+
return "";
|
|
1069
|
+
}
|
|
881
1070
|
|
|
882
1071
|
const retryNormalized = normalizeCompletionSummary(retryResult.content);
|
|
883
1072
|
summary = retryNormalized.summary;
|
|
@@ -903,11 +1092,16 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
903
1092
|
console.error(`${retryParts.join("; ")}; falling back to truncation`);
|
|
904
1093
|
}
|
|
905
1094
|
} catch (retryErr) {
|
|
1095
|
+
const retryAuthFailure = extractProviderAuthFailure(retryErr);
|
|
1096
|
+
if (retryAuthFailure) {
|
|
1097
|
+
console.warn(buildProviderAuthWarning({ provider, model, failure: retryAuthFailure }));
|
|
1098
|
+
return "";
|
|
1099
|
+
}
|
|
906
1100
|
// Retry is best-effort; log and proceed to deterministic fallback.
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}; falling back to truncation`,
|
|
1101
|
+
const retryErrMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
1102
|
+
const isRetryTimeout = retryErrMsg.includes("summarizer timeout");
|
|
1103
|
+
console.warn(
|
|
1104
|
+
`[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; falling back to truncation`,
|
|
911
1105
|
);
|
|
912
1106
|
}
|
|
913
1107
|
}
|
|
@@ -928,4 +1122,6 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
928
1122
|
|
|
929
1123
|
return summary;
|
|
930
1124
|
};
|
|
1125
|
+
|
|
1126
|
+
return { fn, model };
|
|
931
1127
|
}
|
package/src/types.ts
CHANGED
|
@@ -17,8 +17,17 @@ export type CompletionContentBlock = {
|
|
|
17
17
|
[key: string]: unknown;
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
export type CompletionErrorInfo = {
|
|
21
|
+
kind?: string;
|
|
22
|
+
message?: string;
|
|
23
|
+
code?: string;
|
|
24
|
+
statusCode?: number;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
};
|
|
27
|
+
|
|
20
28
|
export type CompletionResult = {
|
|
21
29
|
content: CompletionContentBlock[];
|
|
30
|
+
error?: CompletionErrorInfo;
|
|
22
31
|
[key: string]: unknown;
|
|
23
32
|
};
|
|
24
33
|
|