@martian-engineering/lossless-claw 0.1.4 → 0.1.6
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/docs/configuration.md +7 -0
- package/docs/tui.md +5 -0
- package/index.ts +93 -9
- package/package.json +1 -1
- package/src/compaction.ts +36 -1
- package/src/db/migration.ts +58 -6
- package/src/expansion-auth.ts +53 -1
- package/src/retrieval.ts +44 -1
- package/src/store/summary-store.ts +122 -3
- package/src/summarize.ts +303 -4
- package/src/tools/lcm-describe-tool.ts +104 -17
- package/src/tools/lcm-expand-query-tool.ts +128 -16
- package/src/tools/lcm-expand-tool.delegation.ts +96 -0
- package/src/tools/lcm-expansion-recursion-guard.ts +286 -0
- package/src/types.ts +12 -1
|
@@ -15,6 +15,8 @@ export type CreateSummaryInput = {
|
|
|
15
15
|
earliestAt?: Date;
|
|
16
16
|
latestAt?: Date;
|
|
17
17
|
descendantCount?: number;
|
|
18
|
+
descendantTokenCount?: number;
|
|
19
|
+
sourceMessageTokenCount?: number;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
export type SummaryRecord = {
|
|
@@ -28,9 +30,18 @@ export type SummaryRecord = {
|
|
|
28
30
|
earliestAt: Date | null;
|
|
29
31
|
latestAt: Date | null;
|
|
30
32
|
descendantCount: number;
|
|
33
|
+
descendantTokenCount: number;
|
|
34
|
+
sourceMessageTokenCount: number;
|
|
31
35
|
createdAt: Date;
|
|
32
36
|
};
|
|
33
37
|
|
|
38
|
+
export type SummarySubtreeNodeRecord = SummaryRecord & {
|
|
39
|
+
depthFromRoot: number;
|
|
40
|
+
parentSummaryId: string | null;
|
|
41
|
+
path: string;
|
|
42
|
+
childCount: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
34
45
|
export type ContextItemRecord = {
|
|
35
46
|
conversationId: number;
|
|
36
47
|
ordinal: number;
|
|
@@ -92,9 +103,18 @@ interface SummaryRow {
|
|
|
92
103
|
earliest_at: string | null;
|
|
93
104
|
latest_at: string | null;
|
|
94
105
|
descendant_count: number | null;
|
|
106
|
+
descendant_token_count: number | null;
|
|
107
|
+
source_message_token_count: number | null;
|
|
95
108
|
created_at: string;
|
|
96
109
|
}
|
|
97
110
|
|
|
111
|
+
interface SummarySubtreeRow extends SummaryRow {
|
|
112
|
+
depth_from_root: number;
|
|
113
|
+
parent_summary_id: string | null;
|
|
114
|
+
path: string;
|
|
115
|
+
child_count: number | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
interface ContextItemRow {
|
|
99
119
|
conversation_id: number;
|
|
100
120
|
ordinal: number;
|
|
@@ -165,6 +185,18 @@ function toSummaryRecord(row: SummaryRow): SummaryRecord {
|
|
|
165
185
|
row.descendant_count >= 0
|
|
166
186
|
? Math.floor(row.descendant_count)
|
|
167
187
|
: 0,
|
|
188
|
+
descendantTokenCount:
|
|
189
|
+
typeof row.descendant_token_count === "number" &&
|
|
190
|
+
Number.isFinite(row.descendant_token_count) &&
|
|
191
|
+
row.descendant_token_count >= 0
|
|
192
|
+
? Math.floor(row.descendant_token_count)
|
|
193
|
+
: 0,
|
|
194
|
+
sourceMessageTokenCount:
|
|
195
|
+
typeof row.source_message_token_count === "number" &&
|
|
196
|
+
Number.isFinite(row.source_message_token_count) &&
|
|
197
|
+
row.source_message_token_count >= 0
|
|
198
|
+
? Math.floor(row.source_message_token_count)
|
|
199
|
+
: 0,
|
|
168
200
|
createdAt: new Date(row.created_at),
|
|
169
201
|
};
|
|
170
202
|
}
|
|
@@ -221,6 +253,18 @@ export class SummaryStore {
|
|
|
221
253
|
input.descendantCount >= 0
|
|
222
254
|
? Math.floor(input.descendantCount)
|
|
223
255
|
: 0;
|
|
256
|
+
const descendantTokenCount =
|
|
257
|
+
typeof input.descendantTokenCount === "number" &&
|
|
258
|
+
Number.isFinite(input.descendantTokenCount) &&
|
|
259
|
+
input.descendantTokenCount >= 0
|
|
260
|
+
? Math.floor(input.descendantTokenCount)
|
|
261
|
+
: 0;
|
|
262
|
+
const sourceMessageTokenCount =
|
|
263
|
+
typeof input.sourceMessageTokenCount === "number" &&
|
|
264
|
+
Number.isFinite(input.sourceMessageTokenCount) &&
|
|
265
|
+
input.sourceMessageTokenCount >= 0
|
|
266
|
+
? Math.floor(input.sourceMessageTokenCount)
|
|
267
|
+
: 0;
|
|
224
268
|
const depth =
|
|
225
269
|
typeof input.depth === "number" && Number.isFinite(input.depth) && input.depth >= 0
|
|
226
270
|
? Math.floor(input.depth)
|
|
@@ -240,9 +284,11 @@ export class SummaryStore {
|
|
|
240
284
|
file_ids,
|
|
241
285
|
earliest_at,
|
|
242
286
|
latest_at,
|
|
243
|
-
descendant_count
|
|
287
|
+
descendant_count,
|
|
288
|
+
descendant_token_count,
|
|
289
|
+
source_message_token_count
|
|
244
290
|
)
|
|
245
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
291
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
246
292
|
)
|
|
247
293
|
.run(
|
|
248
294
|
input.summaryId,
|
|
@@ -255,6 +301,8 @@ export class SummaryStore {
|
|
|
255
301
|
earliestAt,
|
|
256
302
|
latestAt,
|
|
257
303
|
descendantCount,
|
|
304
|
+
descendantTokenCount,
|
|
305
|
+
sourceMessageTokenCount,
|
|
258
306
|
);
|
|
259
307
|
|
|
260
308
|
// Index in FTS5 as best-effort; compaction flow must continue even if
|
|
@@ -272,6 +320,7 @@ export class SummaryStore {
|
|
|
272
320
|
.prepare(
|
|
273
321
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
274
322
|
earliest_at, latest_at, descendant_count, created_at
|
|
323
|
+
, descendant_token_count, source_message_token_count
|
|
275
324
|
FROM summaries WHERE summary_id = ?`,
|
|
276
325
|
)
|
|
277
326
|
.get(input.summaryId) as unknown as SummaryRow;
|
|
@@ -284,6 +333,7 @@ export class SummaryStore {
|
|
|
284
333
|
.prepare(
|
|
285
334
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
286
335
|
earliest_at, latest_at, descendant_count, created_at
|
|
336
|
+
, descendant_token_count, source_message_token_count
|
|
287
337
|
FROM summaries WHERE summary_id = ?`,
|
|
288
338
|
)
|
|
289
339
|
.get(summaryId) as unknown as SummaryRow | undefined;
|
|
@@ -295,6 +345,7 @@ export class SummaryStore {
|
|
|
295
345
|
.prepare(
|
|
296
346
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
297
347
|
earliest_at, latest_at, descendant_count, created_at
|
|
348
|
+
, descendant_token_count, source_message_token_count
|
|
298
349
|
FROM summaries
|
|
299
350
|
WHERE conversation_id = ?
|
|
300
351
|
ORDER BY created_at`,
|
|
@@ -353,6 +404,7 @@ export class SummaryStore {
|
|
|
353
404
|
.prepare(
|
|
354
405
|
`SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
|
|
355
406
|
s.file_ids, s.earliest_at, s.latest_at, s.descendant_count, s.created_at
|
|
407
|
+
, s.descendant_token_count, s.source_message_token_count
|
|
356
408
|
FROM summaries s
|
|
357
409
|
JOIN summary_parents sp ON sp.summary_id = s.summary_id
|
|
358
410
|
WHERE sp.parent_summary_id = ?
|
|
@@ -367,6 +419,7 @@ export class SummaryStore {
|
|
|
367
419
|
.prepare(
|
|
368
420
|
`SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
|
|
369
421
|
s.file_ids, s.earliest_at, s.latest_at, s.descendant_count, s.created_at
|
|
422
|
+
, s.descendant_token_count, s.source_message_token_count
|
|
370
423
|
FROM summaries s
|
|
371
424
|
JOIN summary_parents sp ON sp.parent_summary_id = s.summary_id
|
|
372
425
|
WHERE sp.summary_id = ?
|
|
@@ -376,6 +429,71 @@ export class SummaryStore {
|
|
|
376
429
|
return rows.map(toSummaryRecord);
|
|
377
430
|
}
|
|
378
431
|
|
|
432
|
+
async getSummarySubtree(summaryId: string): Promise<SummarySubtreeNodeRecord[]> {
|
|
433
|
+
const rows = this.db
|
|
434
|
+
.prepare(
|
|
435
|
+
`WITH RECURSIVE subtree(summary_id, parent_summary_id, depth_from_root, path) AS (
|
|
436
|
+
SELECT ?, NULL, 0, ''
|
|
437
|
+
UNION ALL
|
|
438
|
+
SELECT
|
|
439
|
+
sp.summary_id,
|
|
440
|
+
sp.parent_summary_id,
|
|
441
|
+
subtree.depth_from_root + 1,
|
|
442
|
+
CASE
|
|
443
|
+
WHEN subtree.path = '' THEN printf('%04d', sp.ordinal)
|
|
444
|
+
ELSE subtree.path || '.' || printf('%04d', sp.ordinal)
|
|
445
|
+
END
|
|
446
|
+
FROM summary_parents sp
|
|
447
|
+
JOIN subtree ON sp.parent_summary_id = subtree.summary_id
|
|
448
|
+
)
|
|
449
|
+
SELECT
|
|
450
|
+
s.summary_id,
|
|
451
|
+
s.conversation_id,
|
|
452
|
+
s.kind,
|
|
453
|
+
s.depth,
|
|
454
|
+
s.content,
|
|
455
|
+
s.token_count,
|
|
456
|
+
s.file_ids,
|
|
457
|
+
s.earliest_at,
|
|
458
|
+
s.latest_at,
|
|
459
|
+
s.descendant_count,
|
|
460
|
+
s.descendant_token_count,
|
|
461
|
+
s.source_message_token_count,
|
|
462
|
+
s.created_at,
|
|
463
|
+
subtree.depth_from_root,
|
|
464
|
+
subtree.parent_summary_id,
|
|
465
|
+
subtree.path,
|
|
466
|
+
(
|
|
467
|
+
SELECT COUNT(*) FROM summary_parents sp2
|
|
468
|
+
WHERE sp2.parent_summary_id = s.summary_id
|
|
469
|
+
) AS child_count
|
|
470
|
+
FROM subtree
|
|
471
|
+
JOIN summaries s ON s.summary_id = subtree.summary_id
|
|
472
|
+
ORDER BY subtree.depth_from_root ASC, subtree.path ASC, s.created_at ASC`,
|
|
473
|
+
)
|
|
474
|
+
.all(summaryId) as unknown as SummarySubtreeRow[];
|
|
475
|
+
|
|
476
|
+
const seen = new Set<string>();
|
|
477
|
+
const output: SummarySubtreeNodeRecord[] = [];
|
|
478
|
+
for (const row of rows) {
|
|
479
|
+
if (seen.has(row.summary_id)) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
seen.add(row.summary_id);
|
|
483
|
+
output.push({
|
|
484
|
+
...toSummaryRecord(row),
|
|
485
|
+
depthFromRoot: Math.max(0, Math.floor(row.depth_from_root ?? 0)),
|
|
486
|
+
parentSummaryId: row.parent_summary_id ?? null,
|
|
487
|
+
path: typeof row.path === "string" ? row.path : "",
|
|
488
|
+
childCount:
|
|
489
|
+
typeof row.child_count === "number" && Number.isFinite(row.child_count)
|
|
490
|
+
? Math.max(0, Math.floor(row.child_count))
|
|
491
|
+
: 0,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return output;
|
|
495
|
+
}
|
|
496
|
+
|
|
379
497
|
// ── Context items ─────────────────────────────────────────────────────────
|
|
380
498
|
|
|
381
499
|
async getContextItems(conversationId: number): Promise<ContextItemRecord[]> {
|
|
@@ -644,7 +762,8 @@ export class SummaryStore {
|
|
|
644
762
|
const rows = this.db
|
|
645
763
|
.prepare(
|
|
646
764
|
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
647
|
-
earliest_at, latest_at, descendant_count,
|
|
765
|
+
earliest_at, latest_at, descendant_count, descendant_token_count,
|
|
766
|
+
source_message_token_count, created_at
|
|
648
767
|
FROM summaries
|
|
649
768
|
${whereClause}
|
|
650
769
|
ORDER BY created_at DESC`,
|
package/src/summarize.ts
CHANGED
|
@@ -24,6 +24,14 @@ export type LcmSummarizerLegacyParams = {
|
|
|
24
24
|
type SummaryMode = "normal" | "aggressive";
|
|
25
25
|
|
|
26
26
|
const DEFAULT_CONDENSED_TARGET_TOKENS = 2000;
|
|
27
|
+
const LCM_SUMMARIZER_SYSTEM_PROMPT =
|
|
28
|
+
"You are a context-compaction summarization engine. Follow user instructions exactly and return plain text summary content only.";
|
|
29
|
+
const DIAGNOSTIC_MAX_DEPTH = 4;
|
|
30
|
+
const DIAGNOSTIC_MAX_ARRAY_ITEMS = 8;
|
|
31
|
+
const DIAGNOSTIC_MAX_OBJECT_KEYS = 16;
|
|
32
|
+
const DIAGNOSTIC_MAX_CHARS = 1200;
|
|
33
|
+
const DIAGNOSTIC_SENSITIVE_KEY_PATTERN =
|
|
34
|
+
/(api[-_]?key|authorization|token|secret|password|cookie|set-cookie|private[-_]?key|bearer)/i;
|
|
27
35
|
|
|
28
36
|
/** Normalize provider ids for stable config/profile lookup. */
|
|
29
37
|
function normalizeProviderId(provider: string): string {
|
|
@@ -193,6 +201,202 @@ function formatBlockTypes(blockTypes: string[]): string {
|
|
|
193
201
|
return blockTypes.join(",");
|
|
194
202
|
}
|
|
195
203
|
|
|
204
|
+
/** Truncate long diagnostic text values to keep logs bounded and readable. */
|
|
205
|
+
function truncateDiagnosticText(value: string, maxChars = DIAGNOSTIC_MAX_CHARS): string {
|
|
206
|
+
if (value.length <= maxChars) {
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
return `${value.slice(0, maxChars)}...[truncated:${value.length - maxChars} chars]`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Build a JSON-safe, redacted, depth-limited clone for diagnostic logging. */
|
|
213
|
+
function sanitizeForDiagnostics(value: unknown, depth = 0): unknown {
|
|
214
|
+
if (depth >= DIAGNOSTIC_MAX_DEPTH) {
|
|
215
|
+
return "[max-depth]";
|
|
216
|
+
}
|
|
217
|
+
if (typeof value === "string") {
|
|
218
|
+
return truncateDiagnosticText(value);
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
value === null ||
|
|
222
|
+
typeof value === "number" ||
|
|
223
|
+
typeof value === "boolean" ||
|
|
224
|
+
typeof value === "bigint"
|
|
225
|
+
) {
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
if (value === undefined) {
|
|
229
|
+
return "[undefined]";
|
|
230
|
+
}
|
|
231
|
+
if (typeof value === "function") {
|
|
232
|
+
return "[function]";
|
|
233
|
+
}
|
|
234
|
+
if (typeof value === "symbol") {
|
|
235
|
+
return "[symbol]";
|
|
236
|
+
}
|
|
237
|
+
if (Array.isArray(value)) {
|
|
238
|
+
const head = value
|
|
239
|
+
.slice(0, DIAGNOSTIC_MAX_ARRAY_ITEMS)
|
|
240
|
+
.map((entry) => sanitizeForDiagnostics(entry, depth + 1));
|
|
241
|
+
if (value.length > DIAGNOSTIC_MAX_ARRAY_ITEMS) {
|
|
242
|
+
head.push(`[+${value.length - DIAGNOSTIC_MAX_ARRAY_ITEMS} more items]`);
|
|
243
|
+
}
|
|
244
|
+
return head;
|
|
245
|
+
}
|
|
246
|
+
if (!isRecord(value)) {
|
|
247
|
+
return String(value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const out: Record<string, unknown> = {};
|
|
251
|
+
const entries = Object.entries(value);
|
|
252
|
+
for (const [key, entry] of entries.slice(0, DIAGNOSTIC_MAX_OBJECT_KEYS)) {
|
|
253
|
+
out[key] = DIAGNOSTIC_SENSITIVE_KEY_PATTERN.test(key)
|
|
254
|
+
? "[redacted]"
|
|
255
|
+
: sanitizeForDiagnostics(entry, depth + 1);
|
|
256
|
+
}
|
|
257
|
+
if (entries.length > DIAGNOSTIC_MAX_OBJECT_KEYS) {
|
|
258
|
+
out.__truncated_keys__ = entries.length - DIAGNOSTIC_MAX_OBJECT_KEYS;
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Encode diagnostic payloads in a compact JSON string with safety guards. */
|
|
264
|
+
function formatDiagnosticPayload(value: unknown): string {
|
|
265
|
+
try {
|
|
266
|
+
const json = JSON.stringify(sanitizeForDiagnostics(value));
|
|
267
|
+
if (!json) {
|
|
268
|
+
return "\"\"";
|
|
269
|
+
}
|
|
270
|
+
return truncateDiagnosticText(json);
|
|
271
|
+
} catch {
|
|
272
|
+
return "\"[unserializable]\"";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Extract safe diagnostic metadata from a provider response envelope.
|
|
278
|
+
*
|
|
279
|
+
* Picks common metadata fields (request id, model echo, usage counters) without
|
|
280
|
+
* leaking secrets like API keys or auth tokens. The result object from
|
|
281
|
+
* `deps.complete` is typed narrowly but real provider responses carry extra
|
|
282
|
+
* fields that are useful for debugging empty-summary incidents.
|
|
283
|
+
*/
|
|
284
|
+
function extractResponseDiagnostics(result: unknown): string {
|
|
285
|
+
if (!isRecord(result)) {
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const parts: string[] = [];
|
|
290
|
+
|
|
291
|
+
// Envelope-shape diagnostics for empty-block incidents.
|
|
292
|
+
const topLevelKeys = Object.keys(result).slice(0, 24);
|
|
293
|
+
if (topLevelKeys.length > 0) {
|
|
294
|
+
parts.push(`keys=${topLevelKeys.join(",")}`);
|
|
295
|
+
}
|
|
296
|
+
if ("content" in result) {
|
|
297
|
+
const contentVal = result.content;
|
|
298
|
+
if (Array.isArray(contentVal)) {
|
|
299
|
+
parts.push(`content_kind=array`);
|
|
300
|
+
parts.push(`content_len=${contentVal.length}`);
|
|
301
|
+
} else if (contentVal === null) {
|
|
302
|
+
parts.push(`content_kind=null`);
|
|
303
|
+
} else {
|
|
304
|
+
parts.push(`content_kind=${typeof contentVal}`);
|
|
305
|
+
}
|
|
306
|
+
parts.push(`content_preview=${formatDiagnosticPayload(contentVal)}`);
|
|
307
|
+
} else {
|
|
308
|
+
parts.push("content_kind=missing");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Preview common non-content payload envelopes used by provider SDKs.
|
|
312
|
+
const envelopePayload: Record<string, unknown> = {};
|
|
313
|
+
for (const key of ["summary", "output", "message", "response"]) {
|
|
314
|
+
if (key in result) {
|
|
315
|
+
envelopePayload[key] = result[key];
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (Object.keys(envelopePayload).length > 0) {
|
|
319
|
+
parts.push(`payload_preview=${formatDiagnosticPayload(envelopePayload)}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Request / response id — present in most provider envelopes.
|
|
323
|
+
for (const key of ["id", "request_id", "x-request-id"]) {
|
|
324
|
+
const val = result[key];
|
|
325
|
+
if (typeof val === "string" && val.trim()) {
|
|
326
|
+
parts.push(`${key}=${val.trim()}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Model echo — useful when the provider selects a different checkpoint.
|
|
331
|
+
if (typeof result.model === "string" && result.model.trim()) {
|
|
332
|
+
parts.push(`resp_model=${result.model.trim()}`);
|
|
333
|
+
}
|
|
334
|
+
if (typeof result.provider === "string" && result.provider.trim()) {
|
|
335
|
+
parts.push(`resp_provider=${result.provider.trim()}`);
|
|
336
|
+
}
|
|
337
|
+
for (const key of [
|
|
338
|
+
"request_provider",
|
|
339
|
+
"request_model",
|
|
340
|
+
"request_api",
|
|
341
|
+
"request_reasoning",
|
|
342
|
+
"request_has_system",
|
|
343
|
+
"request_temperature",
|
|
344
|
+
"request_temperature_sent",
|
|
345
|
+
]) {
|
|
346
|
+
const val = result[key];
|
|
347
|
+
if (typeof val === "string" && val.trim()) {
|
|
348
|
+
parts.push(`${key}=${val.trim()}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Usage counters — safe numeric diagnostics.
|
|
353
|
+
if (isRecord(result.usage)) {
|
|
354
|
+
const u = result.usage;
|
|
355
|
+
const tokens: string[] = [];
|
|
356
|
+
for (const k of [
|
|
357
|
+
"prompt_tokens",
|
|
358
|
+
"completion_tokens",
|
|
359
|
+
"total_tokens",
|
|
360
|
+
"input",
|
|
361
|
+
"output",
|
|
362
|
+
"cacheRead",
|
|
363
|
+
"cacheWrite",
|
|
364
|
+
]) {
|
|
365
|
+
if (typeof u[k] === "number") {
|
|
366
|
+
tokens.push(`${k}=${u[k]}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (tokens.length > 0) {
|
|
370
|
+
parts.push(tokens.join(","));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Finish reason — helps explain empty content.
|
|
375
|
+
const finishReason =
|
|
376
|
+
typeof result.finish_reason === "string"
|
|
377
|
+
? result.finish_reason
|
|
378
|
+
: typeof result.stopReason === "string"
|
|
379
|
+
? result.stopReason
|
|
380
|
+
: typeof result.stop_reason === "string"
|
|
381
|
+
? result.stop_reason
|
|
382
|
+
: undefined;
|
|
383
|
+
if (finishReason) {
|
|
384
|
+
parts.push(`finish=${finishReason}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Provider-level error payloads (most useful when finish=error and content is empty).
|
|
388
|
+
const errorMessage = result.errorMessage;
|
|
389
|
+
if (typeof errorMessage === "string" && errorMessage.trim()) {
|
|
390
|
+
parts.push(`error_message=${truncateDiagnosticText(errorMessage.trim(), 400)}`);
|
|
391
|
+
}
|
|
392
|
+
const errorPayload = result.error;
|
|
393
|
+
if (errorPayload !== undefined) {
|
|
394
|
+
parts.push(`error_preview=${formatDiagnosticPayload(errorPayload)}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return parts.join("; ");
|
|
398
|
+
}
|
|
399
|
+
|
|
196
400
|
/**
|
|
197
401
|
* Resolve a practical target token count for leaf and condensed summaries.
|
|
198
402
|
* Aggressive leaf mode intentionally aims lower so compaction converges faster.
|
|
@@ -522,6 +726,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
522
726
|
authProfileId,
|
|
523
727
|
agentDir,
|
|
524
728
|
runtimeConfig: params.legacyParams.config,
|
|
729
|
+
system: LCM_SUMMARIZER_SYSTEM_PROMPT,
|
|
525
730
|
messages: [
|
|
526
731
|
{
|
|
527
732
|
role: "user",
|
|
@@ -533,17 +738,111 @@ export async function createLcmSummarizeFromLegacyParams(params: {
|
|
|
533
738
|
});
|
|
534
739
|
|
|
535
740
|
const normalized = normalizeCompletionSummary(result.content);
|
|
536
|
-
|
|
741
|
+
let summary = normalized.summary;
|
|
742
|
+
let summarySource: "content" | "envelope" | "retry" | "fallback" = "content";
|
|
537
743
|
|
|
744
|
+
// --- Empty-summary hardening: envelope → retry → deterministic fallback ---
|
|
538
745
|
if (!summary) {
|
|
746
|
+
// Envelope-aware extraction: some providers place summary text in
|
|
747
|
+
// top-level response fields (output, message, response) rather than
|
|
748
|
+
// inside the content array. Re-run normalization against the full
|
|
749
|
+
// response envelope before spending an API call on a retry.
|
|
750
|
+
const envelopeNormalized = normalizeCompletionSummary(result);
|
|
751
|
+
if (envelopeNormalized.summary) {
|
|
752
|
+
summary = envelopeNormalized.summary;
|
|
753
|
+
summarySource = "envelope";
|
|
754
|
+
console.error(
|
|
755
|
+
`[lcm] recovered summary from response envelope; provider=${provider}; model=${model}; ` +
|
|
756
|
+
`block_types=${formatBlockTypes(envelopeNormalized.blockTypes)}; source=envelope`,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (!summary) {
|
|
762
|
+
const responseDiag = extractResponseDiagnostics(result);
|
|
763
|
+
const diagParts = [
|
|
764
|
+
`[lcm] empty normalized summary on first attempt`,
|
|
765
|
+
`provider=${provider}`,
|
|
766
|
+
`model=${model}`,
|
|
767
|
+
`block_types=${formatBlockTypes(normalized.blockTypes)}`,
|
|
768
|
+
`response_blocks=${result.content.length}`,
|
|
769
|
+
];
|
|
770
|
+
if (responseDiag) {
|
|
771
|
+
diagParts.push(responseDiag);
|
|
772
|
+
}
|
|
773
|
+
console.error(`${diagParts.join("; ")}; retrying with conservative settings`);
|
|
774
|
+
|
|
775
|
+
// Single retry with conservative parameters: low temperature and low
|
|
776
|
+
// reasoning budget to coax a textual response from providers that
|
|
777
|
+
// sometimes return reasoning-only or empty blocks on the first pass.
|
|
778
|
+
try {
|
|
779
|
+
const retryResult = await params.deps.complete({
|
|
780
|
+
provider,
|
|
781
|
+
model,
|
|
782
|
+
apiKey,
|
|
783
|
+
providerApi,
|
|
784
|
+
authProfileId,
|
|
785
|
+
agentDir,
|
|
786
|
+
runtimeConfig: params.legacyParams.config,
|
|
787
|
+
system: LCM_SUMMARIZER_SYSTEM_PROMPT,
|
|
788
|
+
messages: [
|
|
789
|
+
{
|
|
790
|
+
role: "user",
|
|
791
|
+
content: prompt,
|
|
792
|
+
},
|
|
793
|
+
],
|
|
794
|
+
maxTokens: targetTokens,
|
|
795
|
+
temperature: 0.05,
|
|
796
|
+
reasoning: "low",
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const retryNormalized = normalizeCompletionSummary(retryResult.content);
|
|
800
|
+
summary = retryNormalized.summary;
|
|
801
|
+
|
|
802
|
+
if (summary) {
|
|
803
|
+
summarySource = "retry";
|
|
804
|
+
console.error(
|
|
805
|
+
`[lcm] retry succeeded; provider=${provider}; model=${model}; ` +
|
|
806
|
+
`block_types=${formatBlockTypes(retryNormalized.blockTypes)}; source=retry`,
|
|
807
|
+
);
|
|
808
|
+
} else {
|
|
809
|
+
const retryDiag = extractResponseDiagnostics(retryResult);
|
|
810
|
+
const retryParts = [
|
|
811
|
+
`[lcm] retry also returned empty summary`,
|
|
812
|
+
`provider=${provider}`,
|
|
813
|
+
`model=${model}`,
|
|
814
|
+
`block_types=${formatBlockTypes(retryNormalized.blockTypes)}`,
|
|
815
|
+
`response_blocks=${retryResult.content.length}`,
|
|
816
|
+
];
|
|
817
|
+
if (retryDiag) {
|
|
818
|
+
retryParts.push(retryDiag);
|
|
819
|
+
}
|
|
820
|
+
console.error(`${retryParts.join("; ")}; falling back to truncation`);
|
|
821
|
+
}
|
|
822
|
+
} catch (retryErr) {
|
|
823
|
+
// Retry is best-effort; log and proceed to deterministic fallback.
|
|
824
|
+
console.error(
|
|
825
|
+
`[lcm] retry failed; provider=${provider} model=${model}; error=${
|
|
826
|
+
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
|
827
|
+
}; falling back to truncation`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (!summary) {
|
|
833
|
+
summarySource = "fallback";
|
|
539
834
|
console.error(
|
|
540
|
-
`[lcm]
|
|
541
|
-
normalized.blockTypes,
|
|
542
|
-
)}; response_blocks=${result.content.length}; falling back to truncation`,
|
|
835
|
+
`[lcm] all extraction attempts exhausted; provider=${provider}; model=${model}; source=fallback`,
|
|
543
836
|
);
|
|
544
837
|
return buildDeterministicFallbackSummary(text, targetTokens);
|
|
545
838
|
}
|
|
546
839
|
|
|
840
|
+
if (summarySource !== "content") {
|
|
841
|
+
console.error(
|
|
842
|
+
`[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
547
846
|
return summary;
|
|
548
847
|
};
|
|
549
848
|
}
|