@martian-engineering/lossless-claw 0.6.2 → 0.7.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.
@@ -4,6 +4,7 @@ import { withDatabaseTransaction } from "../transaction-mutex.js";
4
4
  import { sanitizeFts5Query } from "./fts5-sanitize.js";
5
5
  import { buildLikeSearchPlan, containsCjk, createFallbackSnippet } from "./full-text-fallback.js";
6
6
  import { parseUtcTimestamp, parseUtcTimestampOrNull } from "./parse-utc-timestamp.js";
7
+ import { buildFtsOrderBy, type SearchSort } from "./full-text-sort.js";
7
8
 
8
9
  export type ConversationId = number;
9
10
  export type MessageId = number;
@@ -94,6 +95,7 @@ export type MessageSearchInput = {
94
95
  since?: Date;
95
96
  before?: Date;
96
97
  limit?: number;
98
+ sort?: SearchSort;
97
99
  };
98
100
 
99
101
  export type MessageSearchResult = {
@@ -714,6 +716,7 @@ export class ConversationStore {
714
716
  input.conversationId,
715
717
  input.since,
716
718
  input.before,
719
+ input.sort,
717
720
  );
718
721
  } catch {
719
722
  return this.searchLike(
@@ -764,6 +767,7 @@ export class ConversationStore {
764
767
  conversationId?: ConversationId,
765
768
  since?: Date,
766
769
  before?: Date,
770
+ sort?: SearchSort,
767
771
  ): MessageSearchResult[] {
768
772
  const where: string[] = ["messages_fts MATCH ?"];
769
773
  const args: Array<string | number> = [sanitizeFts5Query(query)];
@@ -780,6 +784,7 @@ export class ConversationStore {
780
784
  args.push(before.toISOString());
781
785
  }
782
786
  args.push(limit);
787
+ const orderBy = buildFtsOrderBy(sort, "m.created_at");
783
788
 
784
789
  const sql = `SELECT
785
790
  m.message_id,
@@ -791,7 +796,7 @@ export class ConversationStore {
791
796
  FROM messages_fts
792
797
  JOIN messages m ON m.message_id = messages_fts.rowid
793
798
  WHERE ${where.join(" AND ")}
794
- ORDER BY m.created_at DESC
799
+ ORDER BY ${orderBy}
795
800
  LIMIT ?`;
796
801
  const rows = this.db.prepare(sql).all(...args) as unknown as MessageSearchRow[];
797
802
  return rows.map(toSearchResult);
@@ -21,9 +21,30 @@
21
21
  * 'hello "world"' → '"hello" "world"'
22
22
  */
23
23
  export function sanitizeFts5Query(raw: string): string {
24
- const tokens = raw.split(/\s+/).filter(Boolean);
25
- if (tokens.length === 0) {
26
- return '""';
24
+ // Preserve user-quoted phrases: extract "..." groups first, then tokenize the rest.
25
+ const parts: string[] = [];
26
+ const phraseRegex = /"([^"]+)"/g;
27
+ let match: RegExpExecArray | null;
28
+ let lastIndex = 0;
29
+
30
+ while ((match = phraseRegex.exec(raw)) !== null) {
31
+ // Process unquoted text before this phrase
32
+ const before = raw.slice(lastIndex, match.index);
33
+ for (const t of before.split(/\s+/).filter(Boolean)) {
34
+ parts.push(`"${t.replace(/"/g, "")}"`);
35
+ }
36
+ // Preserve the phrase as-is (strip internal quotes for safety)
37
+ const phrase = match[1].replace(/"/g, "").trim();
38
+ if (phrase) {
39
+ parts.push(`"${phrase}"`);
40
+ }
41
+ lastIndex = match.index + match[0].length;
27
42
  }
28
- return tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" ");
43
+
44
+ // Process unquoted text after last phrase
45
+ for (const t of raw.slice(lastIndex).split(/\s+/).filter(Boolean)) {
46
+ parts.push(`"${t.replace(/"/g, "")}"`);
47
+ }
48
+
49
+ return parts.length > 0 ? parts.join(" ") : '""';
29
50
  }
@@ -0,0 +1,21 @@
1
+ export type SearchSort = "recency" | "relevance" | "hybrid";
2
+
3
+ export const AGE_DECAY_RATE = 0.001;
4
+
5
+ /**
6
+ * Build the ORDER BY clause for FTS5-backed searches.
7
+ *
8
+ * `rank` is FTS5's BM25 score where lower (more negative) is better.
9
+ * `hybrid` keeps that relevance signal but applies a mild age penalty before
10
+ * LIMIT is enforced so older strong matches can still surface.
11
+ */
12
+ export function buildFtsOrderBy(sort: SearchSort | undefined, createdAtExpr: string): string {
13
+ switch (sort ?? "recency") {
14
+ case "relevance":
15
+ return `rank ASC, ${createdAtExpr} DESC`;
16
+ case "hybrid":
17
+ return `(rank / (1 + ((julianday('now') - julianday(${createdAtExpr})) * 24 * ${AGE_DECAY_RATE}))) ASC, ${createdAtExpr} DESC`;
18
+ default:
19
+ return `${createdAtExpr} DESC`;
20
+ }
21
+ }
@@ -29,3 +29,11 @@ export type {
29
29
  UpsertConversationBootstrapStateInput,
30
30
  ConversationBootstrapStateRecord,
31
31
  } from "./summary-store.js";
32
+
33
+ export { CompactionTelemetryStore } from "./compaction-telemetry-store.js";
34
+ export type {
35
+ CacheState,
36
+ ActivityBand,
37
+ ConversationCompactionTelemetryRecord,
38
+ UpsertConversationCompactionTelemetryInput,
39
+ } from "./compaction-telemetry-store.js";
@@ -3,6 +3,7 @@ import { withDatabaseTransaction } from "../transaction-mutex.js";
3
3
  import { sanitizeFts5Query } from "./fts5-sanitize.js";
4
4
  import { buildLikeSearchPlan, containsCjk, createFallbackSnippet } from "./full-text-fallback.js";
5
5
  import { parseUtcTimestamp, parseUtcTimestampOrNull } from "./parse-utc-timestamp.js";
6
+ import { buildFtsOrderBy, type SearchSort } from "./full-text-sort.js";
6
7
 
7
8
  export type SummaryKind = "leaf" | "condensed";
8
9
  export type ContextItemType = "message" | "summary";
@@ -68,6 +69,7 @@ export type SummarySearchInput = {
68
69
  since?: Date;
69
70
  before?: Date;
70
71
  limit?: number;
72
+ sort?: SearchSort;
71
73
  };
72
74
 
73
75
  export type SummarySearchResult = {
@@ -958,7 +960,10 @@ export class SummaryStore {
958
960
  .run(conversationId, startOrdinal, summaryId);
959
961
 
960
962
  // 3. Resequence all ordinals to maintain contiguity (no gaps).
961
- // Fetch current items, then update ordinals in order.
963
+ // Pre-compute ranks from a SELECT (safe snapshot), then apply
964
+ // via 2-pass UPDATE loop using negative temps to avoid UNIQUE
965
+ // constraint violations. The SELECT reads post-delete/insert
966
+ // state and provides a consistent snapshot for resequencing.
962
967
  const items = this.db
963
968
  .prepare(
964
969
  `SELECT ordinal FROM context_items
@@ -967,18 +972,17 @@ export class SummaryStore {
967
972
  )
968
973
  .all(conversationId) as unknown as { ordinal: number }[];
969
974
 
970
- const updateStmt = this.db.prepare(
971
- `UPDATE context_items
972
- SET ordinal = ?
973
- WHERE conversation_id = ? AND ordinal = ?`,
974
- );
975
-
976
- // Use negative temp ordinals first to avoid unique constraint conflicts.
977
- for (let i = 0; i < items.length; i++) {
978
- updateStmt.run(-(i + 1), conversationId, items[i].ordinal);
979
- }
980
- for (let i = 0; i < items.length; i++) {
981
- updateStmt.run(i, conversationId, -(i + 1));
975
+ if (items.length > 0 && items.some((item, i) => item.ordinal !== i)) {
976
+ const updateStmt = this.db.prepare(
977
+ `UPDATE context_items SET ordinal = ?
978
+ WHERE conversation_id = ? AND ordinal = ?`,
979
+ );
980
+ for (let i = 0; i < items.length; i++) {
981
+ updateStmt.run(-(i + 1), conversationId, items[i].ordinal);
982
+ }
983
+ for (let i = 0; i < items.length; i++) {
984
+ updateStmt.run(i, conversationId, -(i + 1));
985
+ }
982
986
  }
983
987
  }
984
988
 
@@ -1051,6 +1055,7 @@ export class SummaryStore {
1051
1055
  input.conversationId,
1052
1056
  input.since,
1053
1057
  input.before,
1058
+ input.sort,
1054
1059
  );
1055
1060
  } catch {
1056
1061
  return this.searchLike(
@@ -1073,6 +1078,7 @@ export class SummaryStore {
1073
1078
  conversationId?: number,
1074
1079
  since?: Date,
1075
1080
  before?: Date,
1081
+ sort?: SearchSort,
1076
1082
  ): SummarySearchResult[] {
1077
1083
  const where: string[] = ["summaries_fts MATCH ?"];
1078
1084
  const args: Array<string | number> = [sanitizeFts5Query(query)];
@@ -1089,6 +1095,7 @@ export class SummaryStore {
1089
1095
  args.push(before.toISOString());
1090
1096
  }
1091
1097
  args.push(limit);
1098
+ const orderBy = buildFtsOrderBy(sort, "s.created_at");
1092
1099
 
1093
1100
  const sql = `SELECT
1094
1101
  summaries_fts.summary_id,
@@ -1100,7 +1107,7 @@ export class SummaryStore {
1100
1107
  FROM summaries_fts
1101
1108
  JOIN summaries s ON s.summary_id = summaries_fts.summary_id
1102
1109
  WHERE ${where.join(" AND ")}
1103
- ORDER BY s.created_at DESC
1110
+ ORDER BY ${orderBy}
1104
1111
  LIMIT ?`;
1105
1112
  const rows = this.db.prepare(sql).all(...args) as unknown as SummarySearchRow[];
1106
1113
  return rows.map(toSearchResult);
package/src/summarize.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { describeLogError } from "./lcm-log.js";
1
2
  import type { LcmDependencies } from "./types.js";
2
3
 
3
4
  export type LcmSummarizeOptions = {
@@ -1068,6 +1069,17 @@ function resolveSummaryCandidates(params: {
1068
1069
  },
1069
1070
  ];
1070
1071
 
1072
+ // Append explicit fallback providers from config.
1073
+ for (const fb of params.deps.config.fallbackProviders ?? []) {
1074
+ resolutionCandidates.push({
1075
+ levelName: `explicit fallback (${fb.provider}/${fb.model})`,
1076
+ modelRef: `${fb.provider}/${fb.model}`,
1077
+ providerHint: fb.provider,
1078
+ hasExplicitProvider: true,
1079
+ useLegacyAuthProfile: false,
1080
+ });
1081
+ }
1082
+
1071
1083
  const resolvedCandidates: ResolvedSummaryCandidate[] = [];
1072
1084
  for (const candidate of resolutionCandidates) {
1073
1085
  if (!candidate.modelRef) {
@@ -1088,9 +1100,8 @@ function resolveSummaryCandidates(params: {
1088
1100
  });
1089
1101
  }
1090
1102
  } catch (err) {
1091
- console.error(
1092
- `[lcm] createLcmSummarize: resolveModel FAILED at ${candidate.levelName}:`,
1093
- err instanceof Error ? err.message : err,
1103
+ params.deps.log.error(
1104
+ `[lcm] createLcmSummarize: resolveModel FAILED at ${candidate.levelName}: ${describeLogError(err)}`,
1094
1105
  );
1095
1106
  }
1096
1107
  }
@@ -1111,7 +1122,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1111
1122
  }): Promise<{ fn: LcmSummarizeFn; model: string; breakerKey: string } | undefined> {
1112
1123
  const resolvedCandidates = resolveSummaryCandidates(params);
1113
1124
  if (resolvedCandidates.length === 0) {
1114
- console.error("[lcm] createLcmSummarize: no summary model candidates resolved");
1125
+ params.deps.log.error("[lcm] createLcmSummarize: no summary model candidates resolved");
1115
1126
  return undefined;
1116
1127
  }
1117
1128
 
@@ -1197,6 +1208,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1197
1208
  requestApiKey: string | undefined,
1198
1209
  label: string,
1199
1210
  reasoning?: string,
1211
+ options?: { skipModelAuth?: boolean },
1200
1212
  ) =>
1201
1213
  withTimeout(params.deps.complete({
1202
1214
  provider,
@@ -1215,6 +1227,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1215
1227
  ],
1216
1228
  maxTokens: targetTokens,
1217
1229
  ...(reasoning ? { reasoning } : {}),
1230
+ ...(options?.skipModelAuth === true ? { skipModelAuth: true } : {}),
1218
1231
  }), summarizerTimeoutMs, label);
1219
1232
 
1220
1233
  const retryWithoutModelAuth = async (
@@ -1226,8 +1239,8 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1226
1239
  if (runtimeManagedAuth) {
1227
1240
  throw initialAuthError;
1228
1241
  }
1229
- console.warn(initialAuthError.message);
1230
- console.warn(
1242
+ params.deps.log.warn(initialAuthError.message);
1243
+ params.deps.log.warn(
1231
1244
  `[lcm] summarizer auth retry: retrying ${provider}/${model} without runtime.modelAuth credentials.`,
1232
1245
  );
1233
1246
 
@@ -1236,14 +1249,16 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1236
1249
  skipModelAuth: true,
1237
1250
  });
1238
1251
  if (!directApiKey) {
1239
- console.warn(
1252
+ params.deps.log.warn(
1240
1253
  `[lcm] summarizer auth retry unavailable: no direct credentials found for ${provider}/${model}.`,
1241
1254
  );
1242
1255
  throw initialAuthError;
1243
1256
  }
1244
1257
 
1245
1258
  try {
1246
- const directResult = await runSummarizerCall(directApiKey, "auth-retry", reasoning);
1259
+ const directResult = await runSummarizerCall(directApiKey, "auth-retry", reasoning, {
1260
+ skipModelAuth: true,
1261
+ });
1247
1262
  // Use requireStructuralSignal on the retry success path too — the
1248
1263
  // summary text may legitimately contain auth-error phrases.
1249
1264
  const directFailure = extractProviderAuthFailure(directResult, {
@@ -1255,10 +1270,10 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1255
1270
  model,
1256
1271
  failure: directFailure,
1257
1272
  });
1258
- console.warn(retryAuthError.message);
1273
+ params.deps.log.warn(retryAuthError.message);
1259
1274
  throw retryAuthError;
1260
1275
  }
1261
- console.warn(
1276
+ params.deps.log.info(
1262
1277
  `[lcm] summarizer auth retry succeeded; provider=${provider}; model=${model}; source=direct-credentials`,
1263
1278
  );
1264
1279
  return directResult;
@@ -1277,7 +1292,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1277
1292
  model,
1278
1293
  failure: directFailure,
1279
1294
  });
1280
- console.warn(retryAuthError.message);
1295
+ params.deps.log.warn(retryAuthError.message);
1281
1296
  throw retryAuthError;
1282
1297
  }
1283
1298
  throw directErr;
@@ -1317,31 +1332,35 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1317
1332
  if (err instanceof LcmProviderAuthError) {
1318
1333
  lastAuthError = err;
1319
1334
  if (nextCandidate) {
1320
- console.warn(
1321
- `[lcm] summarizer auth fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} failed auth.`,
1335
+ params.deps.log.warn(
1336
+ `[lcm] PROVIDER FALLBACK: ${provider}/${model} auth failed → trying ${nextCandidate.provider}/${nextCandidate.model}`,
1322
1337
  );
1338
+ const backoffMs = Math.min(500 * Math.pow(2, index), 8000);
1339
+ await new Promise((r) => setTimeout(r, backoffMs));
1323
1340
  continue;
1324
1341
  }
1325
1342
  throw lastAuthError;
1326
1343
  }
1327
1344
  const errMsg = err instanceof Error ? err.message : String(err);
1328
1345
  const isTimeout = errMsg.includes("summarizer timeout");
1329
- console.warn(
1346
+ params.deps.log.warn(
1330
1347
  `[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${errMsg}`,
1331
1348
  );
1332
1349
  if (nextCandidate) {
1333
- console.warn(
1334
- `[lcm] summarizer candidate fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} ${isTimeout ? "timed out" : "failed"}.`,
1350
+ params.deps.log.warn(
1351
+ `[lcm] PROVIDER FALLBACK: ${provider}/${model} ${isTimeout ? "timed out" : "failed"} → trying ${nextCandidate.provider}/${nextCandidate.model}`,
1335
1352
  );
1353
+ const backoffMs = Math.min(500 * Math.pow(2, index), 8000);
1354
+ await new Promise((r) => setTimeout(r, backoffMs));
1336
1355
  continue;
1337
1356
  }
1338
1357
  if (err instanceof SummarizerTimeoutError) {
1339
- console.error(
1358
+ params.deps.log.warn(
1340
1359
  `[lcm] summarizer timed out; provider=${provider}; model=${model}; source=fallback`,
1341
1360
  );
1342
1361
  return buildDeterministicFallbackSummary(text, targetTokens);
1343
1362
  }
1344
- return "";
1363
+ break;
1345
1364
  }
1346
1365
 
1347
1366
  const normalized = normalizeCompletionSummary(result.content);
@@ -1358,7 +1377,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1358
1377
  if (envelopeNormalized.summary) {
1359
1378
  summary = envelopeNormalized.summary;
1360
1379
  summarySource = "envelope";
1361
- console.error(
1380
+ params.deps.log.info(
1362
1381
  `[lcm] recovered summary from response envelope; provider=${provider}; model=${model}; ` +
1363
1382
  `block_types=${formatBlockTypes(envelopeNormalized.blockTypes)}; source=envelope`,
1364
1383
  );
@@ -1386,7 +1405,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1386
1405
  if (responseDiag) {
1387
1406
  diagParts.push(responseDiag);
1388
1407
  }
1389
- console.error(`${diagParts.join("; ")}; retrying with conservative settings`);
1408
+ params.deps.log.warn(`${diagParts.join("; ")}; retrying with conservative settings`);
1390
1409
 
1391
1410
  // Single retry with conservative parameters: low temperature and low
1392
1411
  // reasoning budget to coax a textual response from providers that
@@ -1401,7 +1420,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1401
1420
 
1402
1421
  if (summary) {
1403
1422
  summarySource = "retry";
1404
- console.error(
1423
+ params.deps.log.info(
1405
1424
  `[lcm] retry succeeded; provider=${provider}; model=${model}; ` +
1406
1425
  `block_types=${formatBlockTypes(retryEnvelopeNormalized.blockTypes)}; source=retry`,
1407
1426
  );
@@ -1418,21 +1437,23 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1418
1437
  retryParts.push(retryDiag);
1419
1438
  }
1420
1439
  if (nextCandidate) {
1421
- console.warn(
1440
+ params.deps.log.warn(
1422
1441
  `${retryParts.join("; ")}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1423
1442
  );
1424
1443
  continue;
1425
1444
  }
1426
- console.error(`${retryParts.join("; ")}; falling back to truncation`);
1445
+ params.deps.log.warn(`${retryParts.join("; ")}; falling back to truncation`);
1427
1446
  summary = initialSummary;
1428
1447
  }
1429
1448
  } catch (retryErr) {
1430
1449
  if (retryErr instanceof LcmProviderAuthError) {
1431
1450
  lastAuthError = retryErr;
1432
1451
  if (nextCandidate) {
1433
- console.warn(
1434
- `[lcm] summarizer auth fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} failed auth.`,
1452
+ params.deps.log.warn(
1453
+ `[lcm] PROVIDER FALLBACK: ${provider}/${model} auth failed on retry → trying ${nextCandidate.provider}/${nextCandidate.model}`,
1435
1454
  );
1455
+ const backoffMs = Math.min(500 * Math.pow(2, index), 8000);
1456
+ await new Promise((r) => setTimeout(r, backoffMs));
1436
1457
  continue;
1437
1458
  }
1438
1459
  throw lastAuthError;
@@ -1441,12 +1462,12 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1441
1462
  const retryErrMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
1442
1463
  const isRetryTimeout = retryErrMsg.includes("summarizer timeout");
1443
1464
  if (nextCandidate) {
1444
- console.warn(
1465
+ params.deps.log.warn(
1445
1466
  `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${retryErrMsg}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1446
1467
  );
1447
1468
  continue;
1448
1469
  }
1449
- console.warn(
1470
+ params.deps.log.warn(
1450
1471
  `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${retryErrMsg}; falling back to truncation`,
1451
1472
  );
1452
1473
  summary = initialSummary;
@@ -1455,14 +1476,14 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1455
1476
 
1456
1477
  if (!summary) {
1457
1478
  summarySource = "fallback";
1458
- console.error(
1479
+ params.deps.log.error(
1459
1480
  `[lcm] all extraction attempts exhausted; provider=${provider}; model=${model}; source=fallback`,
1460
1481
  );
1461
1482
  return buildDeterministicFallbackSummary(text, targetTokens);
1462
1483
  }
1463
1484
 
1464
1485
  if (summarySource !== "content") {
1465
- console.error(
1486
+ params.deps.log.info(
1466
1487
  `[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
1467
1488
  );
1468
1489
  }
@@ -1470,10 +1491,13 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1470
1491
  return summary;
1471
1492
  }
1472
1493
 
1494
+ params.deps.log.error(
1495
+ `[lcm] ALL PROVIDERS EXHAUSTED: ${resolvedCandidates.length} candidate(s) tried, none succeeded. Compaction falling back to deterministic truncation. Check provider keys and quotas.`,
1496
+ );
1473
1497
  if (lastAuthError) {
1474
1498
  throw lastAuthError;
1475
1499
  }
1476
- return "";
1500
+ return buildDeterministicFallbackSummary(text, targetTokens);
1477
1501
  };
1478
1502
 
1479
1503
  return {
@@ -57,7 +57,8 @@ function normalizeRequestedTokenCap(value: unknown): number | undefined {
57
57
 
58
58
  export function createLcmDescribeTool(input: {
59
59
  deps: LcmDependencies;
60
- lcm: LcmContextEngine;
60
+ lcm?: LcmContextEngine;
61
+ getLcm?: () => Promise<LcmContextEngine>;
61
62
  sessionId?: string;
62
63
  sessionKey?: string;
63
64
  }): AnyAgentTool {
@@ -71,12 +72,16 @@ export function createLcmDescribeTool(input: {
71
72
  "token counts, and file exploration results.",
72
73
  parameters: LcmDescribeSchema,
73
74
  async execute(_toolCallId, params) {
74
- const retrieval = input.lcm.getRetrieval();
75
- const timezone = input.lcm.timezone;
75
+ const lcm = input.lcm ?? (await input.getLcm?.());
76
+ if (!lcm) {
77
+ throw new Error("LCM engine is unavailable.");
78
+ }
79
+ const retrieval = lcm.getRetrieval();
80
+ const timezone = lcm.timezone;
76
81
  const p = params as Record<string, unknown>;
77
82
  const id = (p.id as string).trim();
78
83
  const conversationScope = await resolveLcmConversationScope({
79
- lcm: input.lcm,
84
+ lcm,
80
85
  deps: input.deps,
81
86
  sessionId: input.sessionId,
82
87
  sessionKey: input.sessionKey,
@@ -195,7 +195,7 @@ function buildDelegatedExpandQueryTask(params: {
195
195
  "",
196
196
  "Strategy:",
197
197
  "1. Start with `lcm_describe` on seed summaries to inspect subtree manifests and branch costs.",
198
- "2. If additional candidates are needed, use `lcm_grep` scoped to summaries.",
198
+ "2. If additional candidates are needed, use `lcm_grep` scoped to summaries. Prefer `mode: \"full_text\"`, quote exact multi-word phrases, use `sort: \"relevance\"` for older-topic recall, and `sort: \"hybrid\"` when recency should still matter.",
199
199
  "3. Select branches that fit remaining budget; prefer high-signal paths first.",
200
200
  "4. Call `lcm_expand` selectively (do not expand everything blindly).",
201
201
  "5. Keep includeMessages=false by default; use includeMessages=true for the message-backed seed summaries above and any other specific leaf evidence.",
@@ -444,7 +444,8 @@ async function resolveSummaryCandidates(params: {
444
444
 
445
445
  export function createLcmExpandQueryTool(input: {
446
446
  deps: LcmDependencies;
447
- lcm: LcmContextEngine;
447
+ lcm?: LcmContextEngine;
448
+ getLcm?: () => Promise<LcmContextEngine>;
448
449
  /** Session id used for LCM conversation scoping. */
449
450
  sessionId?: string;
450
451
  /** Requester agent session key used for delegated child session/auth scoping. */
@@ -462,9 +463,13 @@ export function createLcmExpandQueryTool(input: {
462
463
  description:
463
464
  "Answer a focused question using delegated LCM expansion. " +
464
465
  "Find candidate summaries (by IDs or query), expand them in a delegated sub-agent, " +
465
- "and return a compact prompt-focused answer with cited summary IDs.",
466
+ "and return a compact prompt-focused answer. Tool output includes cited summary IDs for follow-up.",
466
467
  parameters: LcmExpandQuerySchema,
467
468
  async execute(_toolCallId, params) {
469
+ const lcm = input.lcm ?? (await input.getLcm?.());
470
+ if (!lcm) {
471
+ throw new Error("LCM engine is unavailable.");
472
+ }
468
473
  const p = params as Record<string, unknown>;
469
474
  const explicitSummaryIds = normalizeSummaryIds(p.summaryIds as string[] | undefined);
470
475
  const query = typeof p.query === "string" ? p.query.trim() : "";
@@ -537,7 +542,7 @@ export function createLcmExpandQueryTool(input: {
537
542
 
538
543
  try {
539
544
  const conversationScope = await resolveLcmConversationScope({
540
- lcm: input.lcm,
545
+ lcm,
541
546
  deps: input.deps,
542
547
  sessionId: input.sessionId,
543
548
  sessionKey: input.sessionKey,
@@ -552,7 +557,7 @@ export function createLcmExpandQueryTool(input: {
552
557
  scopedConversationId = await resolveRequesterConversationScopeId({
553
558
  deps: input.deps,
554
559
  requesterSessionKey: callerSessionKey,
555
- lcm: input.lcm,
560
+ lcm,
556
561
  });
557
562
  }
558
563
 
@@ -564,7 +569,7 @@ export function createLcmExpandQueryTool(input: {
564
569
  }
565
570
 
566
571
  const candidates = await resolveSummaryCandidates({
567
- lcm: input.lcm,
572
+ lcm,
568
573
  explicitSummaryIds,
569
574
  query: query || undefined,
570
575
  conversationId: scopedConversationId,
@@ -122,7 +122,8 @@ function buildOrchestrationObservability(input: {
122
122
  */
123
123
  export function createLcmExpandTool(input: {
124
124
  deps: LcmDependencies;
125
- lcm: LcmContextEngine;
125
+ lcm?: LcmContextEngine;
126
+ getLcm?: () => Promise<LcmContextEngine>;
126
127
  /** Runtime session key (used for delegated expansion auth scoping). */
127
128
  sessionId?: string;
128
129
  sessionKey?: string;
@@ -136,10 +137,14 @@ export function createLcmExpandTool(input: {
136
137
  "Use this to drill into previously-compacted context when you need detail " +
137
138
  "that was summarised away. Provide either summaryIds (direct expansion) or " +
138
139
  "query (grep-first, then expand top matches). Returns a compact text payload " +
139
- "with cited IDs for follow-up.",
140
+ "plus cited IDs in tool output for follow-up.",
140
141
  parameters: LcmExpandSchema,
141
142
  async execute(_toolCallId, params) {
142
- const retrieval = input.lcm.getRetrieval();
143
+ const lcm = input.lcm ?? (await input.getLcm?.());
144
+ if (!lcm) {
145
+ throw new Error("LCM engine is unavailable.");
146
+ }
147
+ const retrieval = lcm.getRetrieval();
143
148
  const orchestrator = new ExpansionOrchestrator(retrieval);
144
149
  const runtimeAuthManager = getRuntimeExpansionAuthManager();
145
150
 
@@ -178,7 +183,7 @@ export function createLcmExpandTool(input: {
178
183
  }
179
184
 
180
185
  const conversationScope = await resolveLcmConversationScope({
181
- lcm: input.lcm,
186
+ lcm,
182
187
  deps: input.deps,
183
188
  sessionId: input.sessionId,
184
189
  sessionKey: input.sessionKey,