@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.
@@ -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 authProfileId =
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
- return async (
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
- console.error(
818
- `[lcm] summarizer call failed; provider=${provider}; model=${model}; error=${err instanceof Error ? err.message : String(err)}`,
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
- console.error(
908
- `[lcm] retry failed; provider=${provider} model=${model}; error=${
909
- retryErr instanceof Error ? retryErr.message : String(retryErr)
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