@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.
@@ -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, created_at
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
- const summary = normalized.summary;
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] summarize empty normalized summary; provider=${provider} model=${model} block_types=${formatBlockTypes(
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
  }