@martian-engineering/lossless-claw 0.8.0 → 0.8.1
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/README.md +8 -0
- package/dist/index.js +19240 -0
- package/docs/configuration.md +15 -5
- package/openclaw.plugin.json +27 -3
- package/package.json +7 -6
- package/skills/lossless-claw/references/config.md +37 -0
- package/index.ts +0 -2
- package/src/assembler.ts +0 -1196
- package/src/compaction.ts +0 -1753
- package/src/db/config.ts +0 -345
- package/src/db/connection.ts +0 -151
- package/src/db/features.ts +0 -61
- package/src/db/migration.ts +0 -868
- package/src/engine.ts +0 -4486
- package/src/estimate-tokens.ts +0 -80
- package/src/expansion-auth.ts +0 -365
- package/src/expansion-policy.ts +0 -303
- package/src/expansion.ts +0 -383
- package/src/integrity.ts +0 -600
- package/src/large-files.ts +0 -546
- package/src/lcm-log.ts +0 -37
- package/src/openclaw-bridge.ts +0 -22
- package/src/plugin/index.ts +0 -2037
- package/src/plugin/lcm-command.ts +0 -1040
- package/src/plugin/lcm-doctor-apply.ts +0 -540
- package/src/plugin/lcm-doctor-cleaners.ts +0 -655
- package/src/plugin/lcm-doctor-shared.ts +0 -210
- package/src/plugin/shared-init.ts +0 -59
- package/src/prune.ts +0 -391
- package/src/retrieval.ts +0 -360
- package/src/session-patterns.ts +0 -23
- package/src/startup-banner-log.ts +0 -49
- package/src/store/compaction-telemetry-store.ts +0 -156
- package/src/store/conversation-store.ts +0 -929
- package/src/store/fts5-sanitize.ts +0 -50
- package/src/store/full-text-fallback.ts +0 -83
- package/src/store/full-text-sort.ts +0 -21
- package/src/store/index.ts +0 -39
- package/src/store/parse-utc-timestamp.ts +0 -25
- package/src/store/summary-store.ts +0 -1519
- package/src/summarize.ts +0 -1508
- package/src/tools/common.ts +0 -53
- package/src/tools/lcm-conversation-scope.ts +0 -127
- package/src/tools/lcm-describe-tool.ts +0 -245
- package/src/tools/lcm-expand-query-tool.ts +0 -1235
- package/src/tools/lcm-expand-tool.delegation.ts +0 -580
- package/src/tools/lcm-expand-tool.ts +0 -453
- package/src/tools/lcm-expansion-recursion-guard.ts +0 -373
- package/src/tools/lcm-grep-tool.ts +0 -228
- package/src/transaction-mutex.ts +0 -136
- package/src/transcript-repair.ts +0 -301
- package/src/types.ts +0 -165
package/src/expansion-policy.ts
DELETED
|
@@ -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
|
-
}
|