@martian-engineering/lossless-claw 0.8.0 → 0.8.2

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.
Files changed (52) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +971 -0
  3. package/docs/configuration.md +15 -5
  4. package/openclaw.plugin.json +27 -3
  5. package/package.json +7 -6
  6. package/skills/lossless-claw/references/config.md +37 -0
  7. package/index.ts +0 -2
  8. package/src/assembler.ts +0 -1196
  9. package/src/compaction.ts +0 -1753
  10. package/src/db/config.ts +0 -345
  11. package/src/db/connection.ts +0 -151
  12. package/src/db/features.ts +0 -61
  13. package/src/db/migration.ts +0 -868
  14. package/src/engine.ts +0 -4486
  15. package/src/estimate-tokens.ts +0 -80
  16. package/src/expansion-auth.ts +0 -365
  17. package/src/expansion-policy.ts +0 -303
  18. package/src/expansion.ts +0 -383
  19. package/src/integrity.ts +0 -600
  20. package/src/large-files.ts +0 -546
  21. package/src/lcm-log.ts +0 -37
  22. package/src/openclaw-bridge.ts +0 -22
  23. package/src/plugin/index.ts +0 -2037
  24. package/src/plugin/lcm-command.ts +0 -1040
  25. package/src/plugin/lcm-doctor-apply.ts +0 -540
  26. package/src/plugin/lcm-doctor-cleaners.ts +0 -655
  27. package/src/plugin/lcm-doctor-shared.ts +0 -210
  28. package/src/plugin/shared-init.ts +0 -59
  29. package/src/prune.ts +0 -391
  30. package/src/retrieval.ts +0 -360
  31. package/src/session-patterns.ts +0 -23
  32. package/src/startup-banner-log.ts +0 -49
  33. package/src/store/compaction-telemetry-store.ts +0 -156
  34. package/src/store/conversation-store.ts +0 -929
  35. package/src/store/fts5-sanitize.ts +0 -50
  36. package/src/store/full-text-fallback.ts +0 -83
  37. package/src/store/full-text-sort.ts +0 -21
  38. package/src/store/index.ts +0 -39
  39. package/src/store/parse-utc-timestamp.ts +0 -25
  40. package/src/store/summary-store.ts +0 -1519
  41. package/src/summarize.ts +0 -1508
  42. package/src/tools/common.ts +0 -53
  43. package/src/tools/lcm-conversation-scope.ts +0 -127
  44. package/src/tools/lcm-describe-tool.ts +0 -245
  45. package/src/tools/lcm-expand-query-tool.ts +0 -1235
  46. package/src/tools/lcm-expand-tool.delegation.ts +0 -580
  47. package/src/tools/lcm-expand-tool.ts +0 -453
  48. package/src/tools/lcm-expansion-recursion-guard.ts +0 -373
  49. package/src/tools/lcm-grep-tool.ts +0 -228
  50. package/src/transaction-mutex.ts +0 -136
  51. package/src/transcript-repair.ts +0 -301
  52. package/src/types.ts +0 -165
@@ -1,303 +0,0 @@
1
- export type LcmExpansionRoutingIntent = "query_probe" | "explicit_expand";
2
-
3
- export type LcmExpansionRoutingAction = "answer_directly" | "expand_shallow" | "delegate_traversal";
4
-
5
- export type LcmExpansionTokenRiskLevel = "low" | "moderate" | "high";
6
-
7
- export type LcmExpansionRoutingInput = {
8
- intent: LcmExpansionRoutingIntent;
9
- query?: string;
10
- requestedMaxDepth?: number;
11
- candidateSummaryCount: number;
12
- tokenCap: number;
13
- includeMessages?: boolean;
14
- };
15
-
16
- export type LcmExpansionRoutingDecision = {
17
- action: LcmExpansionRoutingAction;
18
- normalizedMaxDepth: number;
19
- candidateSummaryCount: number;
20
- estimatedTokens: number;
21
- tokenCap: number;
22
- tokenRiskRatio: number;
23
- tokenRiskLevel: LcmExpansionTokenRiskLevel;
24
- indicators: {
25
- broadTimeRange: boolean;
26
- multiHopRetrieval: boolean;
27
- };
28
- triggers: {
29
- directByNoCandidates: boolean;
30
- directByLowComplexityProbe: boolean;
31
- delegateByDepth: boolean;
32
- delegateByCandidateCount: boolean;
33
- delegateByTokenRisk: boolean;
34
- delegateByBroadTimeRangeAndMultiHop: boolean;
35
- };
36
- reasons: string[];
37
- };
38
-
39
- export const EXPANSION_ROUTING_THRESHOLDS = {
40
- defaultDepth: 3,
41
- minDepth: 1,
42
- maxDepth: 10,
43
- directMaxDepth: 2,
44
- directMaxCandidates: 1,
45
- moderateTokenRiskRatio: 0.35,
46
- highTokenRiskRatio: 0.7,
47
- baseTokensPerSummary: 220,
48
- includeMessagesTokenMultiplier: 1.9,
49
- perDepthTokenGrowth: 0.65,
50
- broadTimeRangeTokenMultiplier: 1.35,
51
- multiHopTokenMultiplier: 1.25,
52
- multiHopDepthThreshold: 3,
53
- multiHopCandidateThreshold: 5,
54
- } as const;
55
-
56
- const BROAD_TIME_RANGE_PATTERNS = [
57
- /\b(last|past)\s+(month|months|quarter|quarters|year|years)\b/i,
58
- /\b(over|across|throughout)\s+(time|months|quarters|years)\b/i,
59
- /\b(timeline|chronology|history|long[-\s]?term)\b/i,
60
- /\bbetween\s+[^.]{0,40}\s+and\s+[^.]{0,40}\b/i,
61
- ];
62
-
63
- const MULTI_HOP_QUERY_PATTERNS = [
64
- /\b(root\s+cause|causal\s+chain|chain\s+of\s+events)\b/i,
65
- /\b(multi[-\s]?hop|multi[-\s]?step|cross[-\s]?summary)\b/i,
66
- /\bhow\s+did\b.+\blead\s+to\b/i,
67
- ];
68
-
69
- /** Normalize a requested depth to a deterministic bounded value. */
70
- function normalizeDepth(requestedMaxDepth?: number): number {
71
- if (typeof requestedMaxDepth !== "number" || !Number.isFinite(requestedMaxDepth)) {
72
- return EXPANSION_ROUTING_THRESHOLDS.defaultDepth;
73
- }
74
- const rounded = Math.trunc(requestedMaxDepth);
75
- return Math.max(
76
- EXPANSION_ROUTING_THRESHOLDS.minDepth,
77
- Math.min(EXPANSION_ROUTING_THRESHOLDS.maxDepth, rounded),
78
- );
79
- }
80
-
81
- /** Normalize token cap to a positive bounded value for risk computation. */
82
- function normalizeTokenCap(tokenCap: number): number {
83
- if (!Number.isFinite(tokenCap)) {
84
- return Number.MAX_SAFE_INTEGER;
85
- }
86
- return Math.max(1, Math.trunc(tokenCap));
87
- }
88
-
89
- /**
90
- * Detect broad time-range intent from the user query.
91
- *
92
- * This is a deterministic text heuristic used by orchestration policy only;
93
- * it does not perform retrieval.
94
- */
95
- export function detectBroadTimeRangeIndicator(query?: string): boolean {
96
- if (!query) {
97
- return false;
98
- }
99
- const trimmed = query.trim();
100
- if (!trimmed) {
101
- return false;
102
- }
103
-
104
- if (BROAD_TIME_RANGE_PATTERNS.some((pattern) => pattern.test(trimmed))) {
105
- return true;
106
- }
107
-
108
- const years = Array.from(trimmed.matchAll(/\b(?:19|20)\d{2}\b/g), (match) => Number(match[0]));
109
- if (years.length < 2) {
110
- return false;
111
- }
112
-
113
- const earliest = Math.min(...years);
114
- const latest = Math.max(...years);
115
- return latest - earliest >= 2;
116
- }
117
-
118
- /**
119
- * Detect whether traversal likely requires multi-hop expansion.
120
- *
121
- * Multi-hop is inferred from depth, breadth, and explicit language in the query.
122
- */
123
- export function detectMultiHopIndicator(input: {
124
- query?: string;
125
- requestedMaxDepth?: number;
126
- candidateSummaryCount: number;
127
- }): boolean {
128
- const normalizedMaxDepth = normalizeDepth(input.requestedMaxDepth);
129
- const candidateSummaryCount = Math.max(0, Math.trunc(input.candidateSummaryCount));
130
-
131
- if (normalizedMaxDepth >= EXPANSION_ROUTING_THRESHOLDS.multiHopDepthThreshold) {
132
- return true;
133
- }
134
- if (candidateSummaryCount >= EXPANSION_ROUTING_THRESHOLDS.multiHopCandidateThreshold) {
135
- return true;
136
- }
137
- if (!input.query) {
138
- return false;
139
- }
140
-
141
- const trimmed = input.query.trim();
142
- if (!trimmed) {
143
- return false;
144
- }
145
- return MULTI_HOP_QUERY_PATTERNS.some((pattern) => pattern.test(trimmed));
146
- }
147
-
148
- /**
149
- * Estimate expansion token volume from traversal characteristics.
150
- *
151
- * This deterministic estimate intentionally over-approximates near deep/broad
152
- * traversals so delegation triggers before hitting hard runtime caps.
153
- */
154
- export function estimateExpansionTokens(input: {
155
- requestedMaxDepth?: number;
156
- candidateSummaryCount: number;
157
- includeMessages?: boolean;
158
- broadTimeRangeIndicator?: boolean;
159
- multiHopIndicator?: boolean;
160
- }): number {
161
- const normalizedMaxDepth = normalizeDepth(input.requestedMaxDepth);
162
- const candidateSummaryCount = Math.max(0, Math.trunc(input.candidateSummaryCount));
163
- if (candidateSummaryCount === 0) {
164
- return 0;
165
- }
166
-
167
- const includeMessagesMultiplier = input.includeMessages
168
- ? EXPANSION_ROUTING_THRESHOLDS.includeMessagesTokenMultiplier
169
- : 1;
170
- const depthMultiplier =
171
- 1 + (normalizedMaxDepth - 1) * EXPANSION_ROUTING_THRESHOLDS.perDepthTokenGrowth;
172
- const timeRangeMultiplier = input.broadTimeRangeIndicator
173
- ? EXPANSION_ROUTING_THRESHOLDS.broadTimeRangeTokenMultiplier
174
- : 1;
175
- const multiHopMultiplier = input.multiHopIndicator
176
- ? EXPANSION_ROUTING_THRESHOLDS.multiHopTokenMultiplier
177
- : 1;
178
-
179
- const perSummaryEstimate =
180
- EXPANSION_ROUTING_THRESHOLDS.baseTokensPerSummary *
181
- includeMessagesMultiplier *
182
- depthMultiplier *
183
- timeRangeMultiplier *
184
- multiHopMultiplier;
185
-
186
- return Math.max(0, Math.ceil(perSummaryEstimate * candidateSummaryCount));
187
- }
188
-
189
- /** Classify token risk level relative to a provided cap. */
190
- export function classifyExpansionTokenRisk(input: { estimatedTokens: number; tokenCap: number }): {
191
- ratio: number;
192
- level: LcmExpansionTokenRiskLevel;
193
- } {
194
- const estimatedTokens = Math.max(0, Math.trunc(input.estimatedTokens));
195
- const tokenCap = normalizeTokenCap(input.tokenCap);
196
- const ratio = estimatedTokens / tokenCap;
197
-
198
- if (ratio >= EXPANSION_ROUTING_THRESHOLDS.highTokenRiskRatio) {
199
- return { ratio, level: "high" };
200
- }
201
- if (ratio >= EXPANSION_ROUTING_THRESHOLDS.moderateTokenRiskRatio) {
202
- return { ratio, level: "moderate" };
203
- }
204
- return { ratio, level: "low" };
205
- }
206
-
207
- /**
208
- * Decide deterministic route-vs-delegate policy for LCM expansion orchestration.
209
- *
210
- * The decision matrix supports three outcomes:
211
- * - answer directly (skip expansion)
212
- * - do shallow/direct expansion
213
- * - delegate deep traversal to a sub-agent
214
- */
215
- export function decideLcmExpansionRouting(
216
- input: LcmExpansionRoutingInput,
217
- ): LcmExpansionRoutingDecision {
218
- const normalizedMaxDepth = normalizeDepth(input.requestedMaxDepth);
219
- const candidateSummaryCount = Math.max(0, Math.trunc(input.candidateSummaryCount));
220
- const tokenCap = normalizeTokenCap(input.tokenCap);
221
- const broadTimeRange = detectBroadTimeRangeIndicator(input.query);
222
- const multiHopRetrieval = detectMultiHopIndicator({
223
- query: input.query,
224
- requestedMaxDepth: normalizedMaxDepth,
225
- candidateSummaryCount,
226
- });
227
- const estimatedTokens = estimateExpansionTokens({
228
- requestedMaxDepth: normalizedMaxDepth,
229
- candidateSummaryCount,
230
- includeMessages: input.includeMessages,
231
- broadTimeRangeIndicator: broadTimeRange,
232
- multiHopIndicator: multiHopRetrieval,
233
- });
234
- const tokenRisk = classifyExpansionTokenRisk({ estimatedTokens, tokenCap });
235
-
236
- const directByNoCandidates = candidateSummaryCount === 0;
237
- const directByLowComplexityProbe =
238
- input.intent === "query_probe" &&
239
- !directByNoCandidates &&
240
- normalizedMaxDepth <= EXPANSION_ROUTING_THRESHOLDS.directMaxDepth &&
241
- candidateSummaryCount <= EXPANSION_ROUTING_THRESHOLDS.directMaxCandidates &&
242
- tokenRisk.level === "low" &&
243
- !broadTimeRange &&
244
- !multiHopRetrieval;
245
-
246
- const delegateByDepth = false;
247
- const delegateByCandidateCount = false;
248
- const delegateByTokenRisk = tokenRisk.level === "high";
249
- const delegateByBroadTimeRangeAndMultiHop = broadTimeRange && multiHopRetrieval;
250
-
251
- const shouldDirect = directByNoCandidates || directByLowComplexityProbe;
252
- const shouldDelegate =
253
- !shouldDirect && (delegateByTokenRisk || delegateByBroadTimeRangeAndMultiHop);
254
-
255
- const action: LcmExpansionRoutingAction = shouldDirect
256
- ? "answer_directly"
257
- : shouldDelegate
258
- ? "delegate_traversal"
259
- : "expand_shallow";
260
-
261
- const reasons: string[] = [];
262
- if (directByNoCandidates) {
263
- reasons.push("No candidate summary IDs are available.");
264
- }
265
- if (directByLowComplexityProbe) {
266
- reasons.push("Query probe is low complexity and below retrieval-risk thresholds.");
267
- }
268
- if (delegateByTokenRisk) {
269
- reasons.push(
270
- `Estimated token risk ratio ${tokenRisk.ratio.toFixed(2)} meets delegate threshold ` +
271
- `${EXPANSION_ROUTING_THRESHOLDS.highTokenRiskRatio.toFixed(2)}.`,
272
- );
273
- }
274
- if (delegateByBroadTimeRangeAndMultiHop) {
275
- reasons.push("Broad time-range request combined with multi-hop retrieval indicators.");
276
- }
277
- if (action === "expand_shallow") {
278
- reasons.push("Complexity is bounded; use direct/shallow expansion.");
279
- }
280
-
281
- return {
282
- action,
283
- normalizedMaxDepth,
284
- candidateSummaryCount,
285
- estimatedTokens,
286
- tokenCap,
287
- tokenRiskRatio: tokenRisk.ratio,
288
- tokenRiskLevel: tokenRisk.level,
289
- indicators: {
290
- broadTimeRange,
291
- multiHopRetrieval,
292
- },
293
- triggers: {
294
- directByNoCandidates,
295
- directByLowComplexityProbe,
296
- delegateByDepth,
297
- delegateByCandidateCount,
298
- delegateByTokenRisk,
299
- delegateByBroadTimeRangeAndMultiHop,
300
- },
301
- reasons,
302
- };
303
- }
package/src/expansion.ts DELETED
@@ -1,383 +0,0 @@
1
- import { Type } from "@sinclair/typebox";
2
- import type { LcmConfig } from "./db/config.js";
3
- import type { RetrievalEngine, ExpandResult, GrepResult } from "./retrieval.js";
4
-
5
- // ── Types ────────────────────────────────────────────────────────────────────
6
-
7
- export type ExpansionRequest = {
8
- /** Summary IDs to expand */
9
- summaryIds: string[];
10
- /** Max traversal depth per summary (default: 3) */
11
- maxDepth?: number;
12
- /** Max tokens across the entire expansion (default: config.maxExpandTokens) */
13
- tokenCap?: number;
14
- /** Whether to include raw source messages at leaf level */
15
- includeMessages?: boolean;
16
- /** Conversation ID scope */
17
- conversationId: number;
18
- };
19
-
20
- export type ExpansionResult = {
21
- /** Expanded summaries with their children/messages */
22
- expansions: Array<{
23
- summaryId: string;
24
- children: Array<{
25
- summaryId: string;
26
- kind: string;
27
- snippet: string;
28
- tokenCount: number;
29
- }>;
30
- messages: Array<{
31
- messageId: number;
32
- role: string;
33
- snippet: string;
34
- tokenCount: number;
35
- }>;
36
- }>;
37
- /** Cited IDs for follow-up traversal */
38
- citedIds: string[];
39
- /** Total tokens in the result */
40
- totalTokens: number;
41
- /** Whether any expansion was truncated */
42
- truncated: boolean;
43
- };
44
-
45
- // ── Helpers ──────────────────────────────────────────────────────────────────
46
-
47
- const SNIPPET_MAX_CHARS = 200;
48
-
49
- /** Truncate content to a short snippet for display. */
50
- function truncateSnippet(content: string, maxChars: number = SNIPPET_MAX_CHARS): string {
51
- if (content.length <= maxChars) {
52
- return content;
53
- }
54
- return content.slice(0, maxChars) + "...";
55
- }
56
-
57
- /**
58
- * Resolve the effective expansion token cap by applying a configured default
59
- * and an explicit upper bound.
60
- */
61
- export function resolveExpansionTokenCap(input: {
62
- requestedTokenCap?: number;
63
- maxExpandTokens: number;
64
- }): number {
65
- const maxExpandTokens = Math.max(1, Math.trunc(input.maxExpandTokens));
66
- const requestedTokenCap = input.requestedTokenCap;
67
- if (typeof requestedTokenCap !== "number" || !Number.isFinite(requestedTokenCap)) {
68
- return maxExpandTokens;
69
- }
70
- return Math.min(Math.max(1, Math.trunc(requestedTokenCap)), maxExpandTokens);
71
- }
72
-
73
- /**
74
- * Convert a single RetrievalEngine.expand() result into the ExpansionResult
75
- * entry format, truncating content to short snippets.
76
- */
77
- function toExpansionEntry(
78
- summaryId: string,
79
- raw: ExpandResult,
80
- ): ExpansionResult["expansions"][number] {
81
- return {
82
- summaryId,
83
- children: raw.children.map((c) => ({
84
- summaryId: c.summaryId,
85
- kind: c.kind,
86
- snippet: truncateSnippet(c.content),
87
- tokenCount: c.tokenCount,
88
- })),
89
- messages: raw.messages.map((m) => ({
90
- messageId: m.messageId,
91
- role: m.role,
92
- snippet: truncateSnippet(m.content),
93
- tokenCount: m.tokenCount,
94
- })),
95
- };
96
- }
97
-
98
- /** Collect all referenced summary IDs from an expansion entry. */
99
- function collectCitedIds(entry: ExpansionResult["expansions"][number]): string[] {
100
- const ids: string[] = [entry.summaryId];
101
- for (const child of entry.children) {
102
- ids.push(child.summaryId);
103
- }
104
- return ids;
105
- }
106
-
107
- // ── ExpansionOrchestrator ────────────────────────────────────────────────────
108
-
109
- export class ExpansionOrchestrator {
110
- constructor(private retrieval: RetrievalEngine) {}
111
-
112
- /**
113
- * Expand each summary ID using the RetrievalEngine, collecting results and
114
- * enforcing a global token cap across all expansions.
115
- */
116
- async expand(request: ExpansionRequest): Promise<ExpansionResult> {
117
- const maxDepth = request.maxDepth ?? 3;
118
- const tokenCap = request.tokenCap ?? Infinity;
119
- const includeMessages = request.includeMessages ?? false;
120
-
121
- const result: ExpansionResult = {
122
- expansions: [],
123
- citedIds: [],
124
- totalTokens: 0,
125
- truncated: false,
126
- };
127
-
128
- const citedSet = new Set<string>();
129
-
130
- for (const summaryId of request.summaryIds) {
131
- if (result.truncated) {
132
- break;
133
- }
134
-
135
- // Calculate remaining budget for this expansion
136
- const remainingBudget = tokenCap - result.totalTokens;
137
- if (remainingBudget <= 0) {
138
- result.truncated = true;
139
- break;
140
- }
141
-
142
- const raw = await this.retrieval.expand({
143
- summaryId,
144
- depth: maxDepth,
145
- includeMessages,
146
- tokenCap: remainingBudget,
147
- });
148
-
149
- const entry = toExpansionEntry(summaryId, raw);
150
- result.expansions.push(entry);
151
- result.totalTokens += raw.estimatedTokens;
152
-
153
- // Track cited IDs
154
- for (const id of collectCitedIds(entry)) {
155
- citedSet.add(id);
156
- }
157
-
158
- if (raw.truncated) {
159
- result.truncated = true;
160
- }
161
- }
162
-
163
- result.citedIds = [...citedSet];
164
- return result;
165
- }
166
-
167
- /**
168
- * Convenience method: grep for matching summaries, then expand the top results.
169
- * Combines the routing pass (grep) with the deep expansion pass.
170
- */
171
- async describeAndExpand(input: {
172
- query: string;
173
- mode: "regex" | "full_text";
174
- conversationId?: number;
175
- maxDepth?: number;
176
- tokenCap?: number;
177
- }): Promise<ExpansionResult> {
178
- const grepResult: GrepResult = await this.retrieval.grep({
179
- query: input.query,
180
- mode: input.mode,
181
- scope: "summaries",
182
- conversationId: input.conversationId,
183
- });
184
-
185
- const summaryIds = [...grepResult.summaries]
186
- .sort((a, b) => {
187
- const recencyDelta = b.createdAt.getTime() - a.createdAt.getTime();
188
- if (recencyDelta !== 0) {
189
- return recencyDelta;
190
- }
191
- const aRank = a.rank ?? Number.POSITIVE_INFINITY;
192
- const bRank = b.rank ?? Number.POSITIVE_INFINITY;
193
- return aRank - bRank;
194
- })
195
- .map((s) => s.summaryId);
196
- if (summaryIds.length === 0) {
197
- return {
198
- expansions: [],
199
- citedIds: [],
200
- totalTokens: 0,
201
- truncated: false,
202
- };
203
- }
204
-
205
- return this.expand({
206
- summaryIds,
207
- maxDepth: input.maxDepth,
208
- tokenCap: input.tokenCap,
209
- includeMessages: false,
210
- conversationId: input.conversationId ?? 0,
211
- });
212
- }
213
- }
214
-
215
- // ── Distill for subagent ─────────────────────────────────────────────────────
216
-
217
- /**
218
- * Format an ExpansionResult into a compact text payload suitable for passing
219
- * to a subagent or returning to the main agent.
220
- */
221
- export function distillForSubagent(result: ExpansionResult): string {
222
- const lines: string[] = [];
223
-
224
- lines.push(
225
- `## Expansion Results (${result.expansions.length} summaries, ${result.totalTokens} total tokens)`,
226
- );
227
- lines.push("");
228
-
229
- for (const entry of result.expansions) {
230
- // Determine kind from children presence: if it has children it was a condensed node
231
- const kind = entry.children.length > 0 ? "condensed" : "leaf";
232
- const tokenSum =
233
- entry.children.reduce((sum, c) => sum + c.tokenCount, 0) +
234
- entry.messages.reduce((sum, m) => sum + m.tokenCount, 0);
235
-
236
- lines.push(`### ${entry.summaryId} (${kind}, ${tokenSum} tokens)`);
237
-
238
- if (entry.children.length > 0) {
239
- lines.push(`Children: ${entry.children.map((c) => c.summaryId).join(", ")}`);
240
- }
241
-
242
- if (entry.messages.length > 0) {
243
- const msgParts = entry.messages.map(
244
- (m) => `msg#${m.messageId} (${m.role}, ${m.tokenCount} tokens)`,
245
- );
246
- lines.push(`Messages: ${msgParts.join(", ")}`);
247
- }
248
-
249
- // Show a snippet for children that have content
250
- for (const child of entry.children) {
251
- if (child.snippet) {
252
- lines.push(`[Snippet: ${truncateSnippet(child.snippet)}]`);
253
- break; // Only show one snippet per entry to keep it compact
254
- }
255
- }
256
-
257
- lines.push("");
258
- }
259
-
260
- if (result.citedIds.length > 0) {
261
- lines.push(`Cited IDs for follow-up: ${result.citedIds.join(", ")}`);
262
- }
263
-
264
- lines.push(`[Truncated: ${result.truncated ? "yes" : "no"}]`);
265
-
266
- return lines.join("\n");
267
- }
268
-
269
- // ── Tool definition ──────────────────────────────────────────────────────────
270
-
271
- const LcmExpansionSchema = Type.Object({
272
- summaryIds: Type.Optional(
273
- Type.Array(Type.String(), {
274
- description: "Summary IDs to expand (e.g. sum_abc123). Required if query is not provided.",
275
- }),
276
- ),
277
- query: Type.Optional(
278
- Type.String({
279
- description:
280
- "Text query to grep for matching summaries before expanding. " +
281
- "If provided, summaryIds is ignored and the top grep results are expanded instead.",
282
- }),
283
- ),
284
- maxDepth: Type.Optional(
285
- Type.Number({
286
- description: "Max traversal depth per summary (default: 3).",
287
- minimum: 1,
288
- maximum: 10,
289
- }),
290
- ),
291
- tokenCap: Type.Optional(
292
- Type.Number({
293
- description: "Max tokens across the entire expansion result.",
294
- minimum: 1,
295
- }),
296
- ),
297
- includeMessages: Type.Optional(
298
- Type.Boolean({
299
- description: "Whether to include raw source messages at leaf level (default: false).",
300
- }),
301
- ),
302
- });
303
-
304
- /**
305
- * Build a tool definition object for LCM expansion that can be registered as
306
- * an agent tool. Follows the pattern used in `src/agents/tools/`.
307
- *
308
- * Requires an already-initialised ExpansionOrchestrator and an LcmConfig
309
- * (for the default tokenCap).
310
- */
311
- export function buildExpansionToolDefinition(options: {
312
- orchestrator: ExpansionOrchestrator;
313
- config: LcmConfig;
314
- conversationId: number;
315
- }) {
316
- const { orchestrator, config, conversationId } = options;
317
-
318
- return {
319
- name: "lcm_expand",
320
- description:
321
- "Expand compacted conversation summaries from LCM (Lossless Context Management). " +
322
- "Traverses the summary DAG to retrieve children and source messages. " +
323
- "Use this to drill into previously-compacted context when you need detail " +
324
- "that was summarised away. Returns a compact text payload with cited IDs for follow-up.",
325
- parameters: LcmExpansionSchema,
326
- execute: async (
327
- _toolCallId: string,
328
- params: Record<string, unknown>,
329
- ): Promise<{ content: Array<{ type: "text"; text: string }>; details: unknown }> => {
330
- const summaryIds = params.summaryIds as string[] | undefined;
331
- const query = typeof params.query === "string" ? params.query.trim() : undefined;
332
- const maxDepth =
333
- typeof params.maxDepth === "number" ? Math.trunc(params.maxDepth) : undefined;
334
- const requestedTokenCap =
335
- typeof params.tokenCap === "number" ? Math.trunc(params.tokenCap) : undefined;
336
- const tokenCap = resolveExpansionTokenCap({
337
- requestedTokenCap,
338
- maxExpandTokens: config.maxExpandTokens,
339
- });
340
- const includeMessages =
341
- typeof params.includeMessages === "boolean" ? params.includeMessages : false;
342
-
343
- let result: ExpansionResult;
344
-
345
- if (query) {
346
- // Grep-first path: find summaries matching the query, then expand
347
- result = await orchestrator.describeAndExpand({
348
- query,
349
- mode: "full_text",
350
- conversationId,
351
- maxDepth,
352
- tokenCap,
353
- });
354
- } else if (summaryIds && summaryIds.length > 0) {
355
- // Direct expansion of specific summary IDs
356
- result = await orchestrator.expand({
357
- summaryIds,
358
- maxDepth,
359
- tokenCap,
360
- includeMessages,
361
- conversationId,
362
- });
363
- } else {
364
- const text = "Error: either summaryIds or query must be provided.";
365
- return {
366
- content: [{ type: "text", text }],
367
- details: { error: text },
368
- };
369
- }
370
-
371
- const distilled = distillForSubagent(result);
372
- return {
373
- content: [{ type: "text", text: distilled }],
374
- details: {
375
- expansionCount: result.expansions.length,
376
- citedIds: result.citedIds,
377
- totalTokens: result.totalTokens,
378
- truncated: result.truncated,
379
- },
380
- };
381
- },
382
- };
383
- }