@martian-engineering/lossless-claw 0.5.2 → 0.6.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.
package/src/assembler.ts CHANGED
@@ -9,6 +9,15 @@ import type { SummaryStore, ContextItemRecord, SummaryRecord } from "./store/sum
9
9
 
10
10
  type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
11
11
 
12
+ const TOOL_CALL_TYPES = new Set([
13
+ "toolCall",
14
+ "toolUse",
15
+ "tool_use",
16
+ "tool-use",
17
+ "functionCall",
18
+ "function_call",
19
+ ]);
20
+
12
21
  // ── Public types ─────────────────────────────────────────────────────────────
13
22
 
14
23
  export interface AssembleContextInput {
@@ -16,6 +25,8 @@ export interface AssembleContextInput {
16
25
  tokenBudget: number;
17
26
  /** Number of most recent raw turns to always include (default: 8) */
18
27
  freshTailCount?: number;
28
+ /** Optional user query for relevance-based eviction scoring (BM25-lite). When absent or unsearchable, falls back to chronological eviction. */
29
+ prompt?: string;
19
30
  }
20
31
 
21
32
  export interface AssembleContextResult {
@@ -43,10 +54,11 @@ function estimateTokens(text: string): number {
43
54
  type SummaryPromptSignal = Pick<SummaryRecord, "kind" | "depth" | "descendantCount">;
44
55
 
45
56
  /**
46
- * Build LCM usage guidance for the runtime system prompt.
57
+ * Build dynamic prompt guidance for compacted session context.
47
58
  *
48
59
  * Guidance is emitted only when summaries are present in assembled context.
49
- * Depth-aware: minimal for shallow compaction, full guidance for deep trees.
60
+ * Static recall policy lives in the plugin prompt hook so this addition
61
+ * remains session-specific and reflects only the current compaction state.
50
62
  */
51
63
  function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): string | undefined {
52
64
  if (summarySignals.length === 0) {
@@ -59,32 +71,24 @@ function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): strin
59
71
 
60
72
  const sections: string[] = [];
61
73
 
62
- // Core recall workflow — always present when summaries exist
74
+ // Dynamic compaction reminder — always present when summaries exist.
63
75
  sections.push(
64
- "## LCM Recall",
65
- "",
66
- "Summaries above are compressed context — maps to details, not the details themselves.",
76
+ "## Compacted Conversation Context",
67
77
  "",
68
- "**Recall priority:** Use LCM tools first for compacted conversation history. If LCM does not cover the needed data, prefer any available memory/recall tool before falling back to raw text search.",
78
+ "Summaries above are compressed context, not full detail.",
69
79
  "",
70
- "**Tool escalation:**",
71
- "1. `lcm_grep` — search by regex or full-text across messages and summaries",
72
- "2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)",
73
- "3. `lcm_expand_query` — deep recall: spawns bounded sub-agent, expands DAG, returns answer with cited summary IDs (~120s, don't ration it)",
80
+ "Treat summaries as compressed recall cues rather than proof of exact wording or exact values.",
74
81
  "",
75
- "**`lcm_expand_query` usage** two patterns (always requires `prompt`):",
76
- "- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
77
- "- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
78
- "- Optional: `maxTokens` (default 2000), `conversationId`, `allConversations: true`",
79
- "",
80
- "**Summaries include \"Expand for details about:\" footers** listing compressed specifics. Use `lcm_expand_query` with that summary's ID to retrieve them.",
82
+ "If a summary includes an \"Expand for details about:\" footer, use it as a cue to expand before asserting specifics.",
81
83
  );
82
84
 
83
- // Precision/evidence rules — always present but stronger when heavily compacted
85
+ // Precision/evidence rules — always present but stronger when heavily compacted.
84
86
  if (heavilyCompacted) {
85
87
  sections.push(
86
88
  "",
87
- "**\u26a0 Deeply compacted context expand before asserting specifics.**",
89
+ "**Deeply compacted context: expand before asserting specifics.**",
90
+ "",
91
+ "Before answering with exact commands, SHAs, paths, timestamps, config values, or causal chains, expand for the missing detail.",
88
92
  "",
89
93
  "Default recall flow for precision work:",
90
94
  "1) `lcm_grep` to locate relevant summary/message IDs",
@@ -92,19 +96,20 @@ function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): strin
92
96
  "3) Answer with citations to summary IDs used",
93
97
  "",
94
98
  "**Uncertainty checklist (run before answering):**",
95
- "- Am I making exact factual claims from a condensed summary?",
99
+ "- Am I making an exact factual claim from a compressed or condensed summary?",
96
100
  "- Could compaction have omitted a crucial detail?",
97
- "- Would this answer fail if the user asks for proof?",
101
+ "- Would I need an expansion step if the user asks for proof or the exact text?",
102
+ "- Should I state uncertainty instead of asserting specifics until I expand?",
98
103
  "",
99
- "If yes to any \u2192 expand first.",
104
+ "If yes to any item, expand first or explicitly say that you need to expand.",
100
105
  "",
101
- "**Do not guess** exact commands, SHAs, file paths, timestamps, config values, or causal claims from condensed summaries. Expand first or state that you need to expand.",
106
+ "Do not guess exact commands, SHAs, file paths, timestamps, config values, or causal claims from condensed summaries. Expand first or explicitly say that you need to expand.",
102
107
  );
103
108
  } else {
104
109
  sections.push(
105
110
  "",
106
- "**For precision/evidence questions** (exact commands, SHAs, paths, timestamps, config values, root-cause chains): expand before answering.",
107
- "Do not guess from condensed summaries — expand first or state uncertainty.",
111
+ "For exact commands, SHAs, paths, timestamps, config values, or causal chains, expand for details before answering.",
112
+ "State uncertainty instead of guessing from compressed summaries.",
108
113
  );
109
114
  }
110
115
 
@@ -267,6 +272,20 @@ export function toolResultBlockFromPart(
267
272
  rawType?: string,
268
273
  raw?: Record<string, unknown>,
269
274
  ): unknown {
275
+ if (
276
+ raw &&
277
+ typeof raw.text === "string" &&
278
+ raw.output === undefined &&
279
+ raw.content === undefined &&
280
+ (part.toolOutput == null || part.toolOutput === "") &&
281
+ (part.textContent == null || part.textContent === raw.text)
282
+ ) {
283
+ return {
284
+ type: "text",
285
+ text: raw.text,
286
+ };
287
+ }
288
+
270
289
  const type =
271
290
  rawType === "function_call_output" || rawType === "toolResult" || rawType === "tool_result"
272
291
  ? rawType
@@ -454,7 +473,8 @@ export function blockFromPart(part: MessagePartRecord): unknown {
454
473
  return { type: "text", text: "" };
455
474
  }
456
475
 
457
- function contentFromParts(
476
+ /** @internal Exported for transcript-maintenance reconstruction. */
477
+ export function contentFromParts(
458
478
  parts: MessagePartRecord[],
459
479
  role: "user" | "assistant" | "toolResult",
460
480
  fallbackContent: string,
@@ -483,7 +503,8 @@ function contentFromParts(
483
503
  return blocks;
484
504
  }
485
505
 
486
- function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
506
+ /** @internal Exported for transcript-maintenance reconstruction. */
507
+ export function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
487
508
  for (const part of parts) {
488
509
  if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
489
510
  return part.toolCallId;
@@ -512,7 +533,8 @@ function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
512
533
  return undefined;
513
534
  }
514
535
 
515
- function pickToolName(parts: MessagePartRecord[]): string | undefined {
536
+ /** @internal Exported for transcript-maintenance reconstruction. */
537
+ export function pickToolName(parts: MessagePartRecord[]): string | undefined {
516
538
  for (const part of parts) {
517
539
  if (typeof part.toolName === "string" && part.toolName.length > 0) {
518
540
  return part.toolName;
@@ -541,7 +563,8 @@ function pickToolName(parts: MessagePartRecord[]): string | undefined {
541
563
  return undefined;
542
564
  }
543
565
 
544
- function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
566
+ /** @internal Exported for transcript-maintenance reconstruction. */
567
+ export function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
545
568
  for (const part of parts) {
546
569
  const decoded = parseJson(part.metadata);
547
570
  if (!decoded || typeof decoded !== "object") {
@@ -555,6 +578,174 @@ function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
555
578
  return undefined;
556
579
  }
557
580
 
581
+ function extractToolCallId(block: { id?: unknown; call_id?: unknown }): string | null {
582
+ if (typeof block.id === "string" && block.id.length > 0) {
583
+ return block.id;
584
+ }
585
+ if (typeof block.call_id === "string" && block.call_id.length > 0) {
586
+ return block.call_id;
587
+ }
588
+ return null;
589
+ }
590
+
591
+ function extractToolCallIdsFromAssistant(message: AgentMessage): string[] {
592
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) {
593
+ return [];
594
+ }
595
+
596
+ const ids: string[] = [];
597
+ for (const block of message.content) {
598
+ if (!block || typeof block !== "object") {
599
+ continue;
600
+ }
601
+ const record = block as { type?: unknown; id?: unknown; call_id?: unknown };
602
+ if (typeof record.type !== "string" || !TOOL_CALL_TYPES.has(record.type)) {
603
+ continue;
604
+ }
605
+ const id = extractToolCallId(record);
606
+ if (id) {
607
+ ids.push(id);
608
+ }
609
+ }
610
+ return ids;
611
+ }
612
+
613
+ function extractToolResultIdFromMessage(message: AgentMessage): string | null {
614
+ if (!message || typeof message !== "object") {
615
+ return null;
616
+ }
617
+ if (typeof message.toolCallId === "string" && message.toolCallId.length > 0) {
618
+ return message.toolCallId;
619
+ }
620
+ if (typeof message.toolUseId === "string" && message.toolUseId.length > 0) {
621
+ return message.toolUseId;
622
+ }
623
+ return null;
624
+ }
625
+
626
+ function collectAssistantToolCallIds(items: ResolvedItem[]): Set<string> {
627
+ const ids = new Set<string>();
628
+ for (const item of items) {
629
+ for (const id of extractToolCallIdsFromAssistant(item.message)) {
630
+ ids.add(id);
631
+ }
632
+ }
633
+ return ids;
634
+ }
635
+
636
+ function mergeFreshTailWithMatchingToolResults(
637
+ freshTail: ResolvedItem[],
638
+ matchingToolResults: ResolvedItem[],
639
+ ): ResolvedItem[] {
640
+ if (matchingToolResults.length === 0) {
641
+ return freshTail;
642
+ }
643
+
644
+ const resultsById = new Map<string, ResolvedItem[]>();
645
+ for (const item of matchingToolResults) {
646
+ const toolResultId = extractToolResultIdFromMessage(item.message);
647
+ if (!toolResultId) {
648
+ continue;
649
+ }
650
+ const existing = resultsById.get(toolResultId);
651
+ if (existing) {
652
+ existing.push(item);
653
+ } else {
654
+ resultsById.set(toolResultId, [item]);
655
+ }
656
+ }
657
+
658
+ const merged: ResolvedItem[] = [];
659
+ const usedOrdinals = new Set<number>();
660
+
661
+ for (const item of freshTail) {
662
+ merged.push(item);
663
+
664
+ const toolCallIds = extractToolCallIdsFromAssistant(item.message);
665
+ if (toolCallIds.length === 0) {
666
+ continue;
667
+ }
668
+
669
+ for (const toolCallId of toolCallIds) {
670
+ const matches = resultsById.get(toolCallId);
671
+ if (!matches) {
672
+ continue;
673
+ }
674
+ for (const match of matches) {
675
+ if (usedOrdinals.has(match.ordinal)) {
676
+ continue;
677
+ }
678
+ merged.push(match);
679
+ usedOrdinals.add(match.ordinal);
680
+ }
681
+ }
682
+ }
683
+
684
+ for (const item of matchingToolResults) {
685
+ if (!usedOrdinals.has(item.ordinal)) {
686
+ merged.push(item);
687
+ }
688
+ }
689
+
690
+ return merged;
691
+ }
692
+
693
+ function filterNonFreshAssistantToolCalls(
694
+ items: ResolvedItem[],
695
+ freshTailOrdinals: Set<number>,
696
+ ): AgentMessage[] {
697
+ const availableToolResultIds = new Set<string>();
698
+ for (const item of items) {
699
+ const toolResultId = extractToolResultIdFromMessage(item.message);
700
+ if (toolResultId) {
701
+ availableToolResultIds.add(toolResultId);
702
+ }
703
+ }
704
+
705
+ const filteredMessages: AgentMessage[] = [];
706
+ for (const item of items) {
707
+ if (item.message?.role !== "assistant" || freshTailOrdinals.has(item.ordinal)) {
708
+ filteredMessages.push(item.message);
709
+ continue;
710
+ }
711
+
712
+ if (!Array.isArray(item.message.content)) {
713
+ filteredMessages.push(item.message);
714
+ continue;
715
+ }
716
+
717
+ let removedAny = false;
718
+ const content = item.message.content.filter((block) => {
719
+ if (!block || typeof block !== "object") {
720
+ return true;
721
+ }
722
+ const record = block as { type?: unknown; id?: unknown; call_id?: unknown };
723
+ if (typeof record.type !== "string" || !TOOL_CALL_TYPES.has(record.type)) {
724
+ return true;
725
+ }
726
+ const toolCallId = extractToolCallId(record);
727
+ if (!toolCallId || availableToolResultIds.has(toolCallId)) {
728
+ return true;
729
+ }
730
+ removedAny = true;
731
+ return false;
732
+ });
733
+
734
+ if (content.length === 0) {
735
+ continue;
736
+ }
737
+ if (!removedAny) {
738
+ filteredMessages.push(item.message);
739
+ continue;
740
+ }
741
+ filteredMessages.push({
742
+ ...item.message,
743
+ content: content as typeof item.message.content,
744
+ } as AgentMessage);
745
+ }
746
+ return filteredMessages;
747
+ }
748
+
558
749
  /** Format a Date for XML attributes in the agent's timezone. */
559
750
  function formatDateForAttribute(date: Date, timezone?: string): string {
560
751
  const tz = timezone ?? "UTC";
@@ -632,10 +823,60 @@ interface ResolvedItem {
632
823
  tokens: number;
633
824
  /** Whether this came from a raw message (vs. a summary) */
634
825
  isMessage: boolean;
826
+ /** Pre-extracted plain text used for relevance scoring */
827
+ text: string;
635
828
  /** Summary metadata used for dynamic system prompt guidance */
636
829
  summarySignal?: SummaryPromptSignal;
637
830
  }
638
831
 
832
+ // ── BM25-lite relevance scorer ────────────────────────────────────────────────
833
+
834
+ /** @internal Exported for testing only. Tokenize text into lowercase alphanumeric terms. */
835
+ export function tokenizeText(text: string): string[] {
836
+ return text
837
+ .toLowerCase()
838
+ .split(/[^a-z0-9]+/)
839
+ .filter((t) => t.length > 1);
840
+ }
841
+
842
+ /**
843
+ * @internal Exported for testing only.
844
+ * Score an item's text against a prompt using BM25-lite (term-frequency overlap).
845
+ * Higher scores indicate stronger keyword overlap. Returns 0 when either input is empty.
846
+ */
847
+ export function scoreRelevance(itemText: string, prompt: string): number {
848
+ const promptTerms = tokenizeText(prompt);
849
+ if (promptTerms.length === 0) return 0;
850
+
851
+ const itemTerms = tokenizeText(itemText);
852
+ if (itemTerms.length === 0) return 0;
853
+
854
+ // Build term-frequency map for the item
855
+ const freq = new Map<string, number>();
856
+ for (const term of itemTerms) {
857
+ freq.set(term, (freq.get(term) ?? 0) + 1);
858
+ }
859
+
860
+ // Sum TF contribution for each unique prompt term
861
+ const seen = new Set<string>();
862
+ let score = 0;
863
+ for (const term of promptTerms) {
864
+ if (seen.has(term)) continue;
865
+ seen.add(term);
866
+ const tf = freq.get(term) ?? 0;
867
+ if (tf > 0) {
868
+ // Normalised TF: tf / itemLength (BM25-lite saturation skipped for simplicity)
869
+ score += tf / itemTerms.length;
870
+ }
871
+ }
872
+ return score;
873
+ }
874
+
875
+ /** Return true when a prompt contains at least one searchable term. */
876
+ function hasSearchablePrompt(prompt?: string): prompt is string {
877
+ return typeof prompt === "string" && tokenizeText(prompt).length > 0;
878
+ }
879
+
639
880
  // ── ContextAssembler ─────────────────────────────────────────────────────────
640
881
 
641
882
  export class ContextAssembler {
@@ -692,8 +933,17 @@ export class ContextAssembler {
692
933
 
693
934
  // Step 3: Split into evictable prefix and protected fresh tail
694
935
  const tailStart = Math.max(0, resolved.length - freshTailCount);
695
- const freshTail = resolved.slice(tailStart);
696
- const evictable = resolved.slice(0, tailStart);
936
+ const baseFreshTail = resolved.slice(tailStart);
937
+ const initialEvictable = resolved.slice(0, tailStart);
938
+ const freshTailOrdinals = new Set(baseFreshTail.map((item) => item.ordinal));
939
+ const tailToolCallIds = collectAssistantToolCallIds(baseFreshTail);
940
+ const tailPairToolResults = initialEvictable.filter((item) => {
941
+ const toolResultId = extractToolResultIdFromMessage(item.message);
942
+ return toolResultId !== null && tailToolCallIds.has(toolResultId);
943
+ });
944
+ const protectedEvictableOrdinals = new Set(tailPairToolResults.map((item) => item.ordinal));
945
+ const evictable = initialEvictable.filter((item) => !protectedEvictableOrdinals.has(item.ordinal));
946
+ const freshTail = mergeFreshTailWithMatchingToolResults(baseFreshTail, tailPairToolResults);
697
947
 
698
948
  // Step 4: Budget-aware selection
699
949
  // First, compute the token cost of the fresh tail (always included).
@@ -719,8 +969,32 @@ export class ContextAssembler {
719
969
  // Everything fits
720
970
  selected.push(...evictable);
721
971
  evictableTokens = evictableTotalTokens;
972
+ } else if (hasSearchablePrompt(input.prompt)) {
973
+ // Prompt-aware eviction: score each evictable item by relevance to the
974
+ // prompt, then greedily fill budget from highest-scoring items down.
975
+ // Re-sort selected items by ordinal to restore chronological order.
976
+ const scored = evictable.map((item, idx) => ({
977
+ item,
978
+ score: scoreRelevance(item.text, input.prompt),
979
+ idx, // original index — higher = more recent, used as tiebreaker
980
+ }));
981
+ // Sort: highest relevance first; most recent (higher idx) breaks ties
982
+ scored.sort((a, b) => b.score - a.score || b.idx - a.idx);
983
+
984
+ const kept: ResolvedItem[] = [];
985
+ let accum = 0;
986
+ for (const { item } of scored) {
987
+ if (accum + item.tokens <= remainingBudget) {
988
+ kept.push(item);
989
+ accum += item.tokens;
990
+ }
991
+ }
992
+ // Restore chronological order by ordinal before appending freshTail
993
+ kept.sort((a, b) => a.ordinal - b.ordinal);
994
+ selected.push(...kept);
995
+ evictableTokens = accum;
722
996
  } else {
723
- // Need to drop oldest items until we fit.
997
+ // Chronological eviction (default): drop oldest items until we fit.
724
998
  // Walk from the END of evictable (newest first) accumulating tokens,
725
999
  // then reverse to restore chronological order.
726
1000
  const kept: ResolvedItem[] = [];
@@ -747,7 +1021,7 @@ export class ContextAssembler {
747
1021
 
748
1022
  // Normalize assistant string content to array blocks (some providers return
749
1023
  // content as a plain string; Anthropic expects content block arrays).
750
- const rawMessages = selected.map((item) => item.message);
1024
+ const rawMessages = filterNonFreshAssistantToolCalls(selected, freshTailOrdinals);
751
1025
  for (let i = 0; i < rawMessages.length; i++) {
752
1026
  const msg = rawMessages[i];
753
1027
  if (msg?.role === "assistant" && typeof msg.content === "string") {
@@ -758,8 +1032,19 @@ export class ContextAssembler {
758
1032
  }
759
1033
  }
760
1034
 
1035
+ // Filter out assistant messages with empty content — these can occur when
1036
+ // tool-use-only turns are stored with content="" and zero message_parts,
1037
+ // or when filterNonFreshAssistantToolCalls strips all tool_use blocks.
1038
+ // Anthropic (and other providers) reject empty content arrays/strings.
1039
+ const cleaned = rawMessages.filter(
1040
+ (m) =>
1041
+ !(
1042
+ m?.role === "assistant" &&
1043
+ (Array.isArray(m.content) ? m.content.length === 0 : !m.content)
1044
+ ),
1045
+ );
761
1046
  return {
762
- messages: sanitizeToolUseResultPairing(rawMessages) as AgentMessage[],
1047
+ messages: sanitizeToolUseResultPairing(cleaned) as AgentMessage[],
763
1048
  estimatedTokens,
764
1049
  systemPromptAddition,
765
1050
  stats: {
@@ -865,6 +1150,7 @@ export class ContextAssembler {
865
1150
  } as AgentMessage),
866
1151
  tokens: tokenCount,
867
1152
  isMessage: true,
1153
+ text: contentText,
868
1154
  };
869
1155
  }
870
1156
 
@@ -887,6 +1173,7 @@ export class ContextAssembler {
887
1173
  message: { role: "user" as const, content } as AgentMessage,
888
1174
  tokens,
889
1175
  isMessage: false,
1176
+ text: summary.content,
890
1177
  summarySignal: {
891
1178
  kind: summary.kind,
892
1179
  depth: summary.depth,