@martian-engineering/lossless-claw 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/configuration.md +7 -0
- package/docs/tui.md +5 -0
- package/index.ts +93 -9
- package/package.json +1 -1
- package/src/compaction.ts +36 -1
- package/src/db/migration.ts +58 -6
- package/src/expansion-auth.ts +53 -1
- package/src/retrieval.ts +44 -1
- package/src/store/summary-store.ts +122 -3
- package/src/summarize.ts +303 -4
- package/src/tools/lcm-describe-tool.ts +104 -17
- package/src/tools/lcm-expand-query-tool.ts +128 -16
- package/src/tools/lcm-expand-tool.delegation.ts +96 -0
- package/src/tools/lcm-expansion-recursion-guard.ts +286 -0
- package/src/types.ts +12 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { resolveDelegatedExpansionGrantId } from "../expansion-auth.js";
|
|
3
|
+
import type { LcmDependencies } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export const EXPANSION_RECURSION_ERROR_CODE = "EXPANSION_RECURSION_BLOCKED";
|
|
6
|
+
const EXPANSION_DELEGATION_DEPTH_CAP = 1;
|
|
7
|
+
|
|
8
|
+
type TelemetryEvent = "start" | "block" | "timeout" | "success";
|
|
9
|
+
|
|
10
|
+
const telemetryCounters: Record<TelemetryEvent, number> = {
|
|
11
|
+
start: 0,
|
|
12
|
+
block: 0,
|
|
13
|
+
timeout: 0,
|
|
14
|
+
success: 0,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DelegatedExpansionContext = {
|
|
18
|
+
requestId: string;
|
|
19
|
+
expansionDepth: number;
|
|
20
|
+
originSessionKey: string;
|
|
21
|
+
stampedBy: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ExpansionRecursionBlockReason = "depth_cap" | "idempotent_reentry";
|
|
26
|
+
|
|
27
|
+
export type ExpansionRecursionGuardDecision =
|
|
28
|
+
| {
|
|
29
|
+
blocked: false;
|
|
30
|
+
requestId: string;
|
|
31
|
+
expansionDepth: number;
|
|
32
|
+
originSessionKey: string;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
blocked: true;
|
|
36
|
+
code: typeof EXPANSION_RECURSION_ERROR_CODE;
|
|
37
|
+
reason: ExpansionRecursionBlockReason;
|
|
38
|
+
message: string;
|
|
39
|
+
requestId: string;
|
|
40
|
+
expansionDepth: number;
|
|
41
|
+
originSessionKey: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const delegatedContextBySessionKey = new Map<string, DelegatedExpansionContext>();
|
|
45
|
+
const blockedRequestIdsBySessionKey = new Map<string, Set<string>>();
|
|
46
|
+
|
|
47
|
+
function normalizeSessionKey(sessionKey?: string): string {
|
|
48
|
+
return typeof sessionKey === "string" ? sessionKey.trim() : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getOrInitBlockedRequestIds(sessionKey: string): Set<string> {
|
|
52
|
+
const existing = blockedRequestIdsBySessionKey.get(sessionKey);
|
|
53
|
+
if (existing) {
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
const created = new Set<string>();
|
|
57
|
+
blockedRequestIdsBySessionKey.set(sessionKey, created);
|
|
58
|
+
return created;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveFallbackDelegatedContext(
|
|
62
|
+
sessionKey: string,
|
|
63
|
+
requestId: string,
|
|
64
|
+
): DelegatedExpansionContext | undefined {
|
|
65
|
+
if (!sessionKey) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const grantId = resolveDelegatedExpansionGrantId(sessionKey);
|
|
69
|
+
if (!grantId) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
requestId,
|
|
74
|
+
expansionDepth: EXPANSION_DELEGATION_DEPTH_CAP,
|
|
75
|
+
originSessionKey: sessionKey,
|
|
76
|
+
stampedBy: "delegated_grant",
|
|
77
|
+
createdAt: new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build actionable recovery guidance for recursion-blocked delegated calls.
|
|
83
|
+
*/
|
|
84
|
+
function buildExpansionRecursionRecoveryGuidance(originSessionKey: string): string {
|
|
85
|
+
return (
|
|
86
|
+
"Recovery: In delegated sub-agent sessions, call `lcm_expand` directly and synthesize " +
|
|
87
|
+
"your answer from that result. Do NOT call `lcm_expand_query` from delegated context. " +
|
|
88
|
+
`If deeper delegation is required, return to the origin session (${originSessionKey}) ` +
|
|
89
|
+
"and call `lcm_expand_query` there."
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a stable request identifier for delegated expansion orchestration.
|
|
95
|
+
*/
|
|
96
|
+
export function createExpansionRequestId(): string {
|
|
97
|
+
return crypto.randomUUID();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the active expansion request id for a session, inheriting from any
|
|
102
|
+
* stamped delegated context when present.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveExpansionRequestId(sessionKey?: string): string {
|
|
105
|
+
const key = normalizeSessionKey(sessionKey);
|
|
106
|
+
return delegatedContextBySessionKey.get(key)?.requestId ?? createExpansionRequestId();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the next delegated expansion depth to stamp onto a child session.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveNextExpansionDepth(sessionKey?: string): number {
|
|
113
|
+
const key = normalizeSessionKey(sessionKey);
|
|
114
|
+
if (!key) {
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const existing = delegatedContextBySessionKey.get(key);
|
|
118
|
+
if (existing) {
|
|
119
|
+
return existing.expansionDepth + 1;
|
|
120
|
+
}
|
|
121
|
+
return resolveDelegatedExpansionGrantId(key) ? EXPANSION_DELEGATION_DEPTH_CAP + 1 : 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Stamp delegated expansion metadata for a child session so re-entry checks can
|
|
126
|
+
* enforce recursion and depth policies deterministically.
|
|
127
|
+
*/
|
|
128
|
+
export function stampDelegatedExpansionContext(params: {
|
|
129
|
+
sessionKey: string;
|
|
130
|
+
requestId: string;
|
|
131
|
+
expansionDepth: number;
|
|
132
|
+
originSessionKey: string;
|
|
133
|
+
stampedBy: string;
|
|
134
|
+
}): DelegatedExpansionContext {
|
|
135
|
+
const sessionKey = normalizeSessionKey(params.sessionKey);
|
|
136
|
+
const context: DelegatedExpansionContext = {
|
|
137
|
+
requestId: params.requestId,
|
|
138
|
+
expansionDepth: Math.max(0, Math.trunc(params.expansionDepth)),
|
|
139
|
+
originSessionKey: params.originSessionKey.trim() || "main",
|
|
140
|
+
stampedBy: params.stampedBy,
|
|
141
|
+
createdAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
if (sessionKey) {
|
|
144
|
+
delegatedContextBySessionKey.set(sessionKey, context);
|
|
145
|
+
}
|
|
146
|
+
return context;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Remove delegated expansion metadata for a child session after cleanup.
|
|
151
|
+
*/
|
|
152
|
+
export function clearDelegatedExpansionContext(sessionKey: string): void {
|
|
153
|
+
const key = normalizeSessionKey(sessionKey);
|
|
154
|
+
if (!key) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
delegatedContextBySessionKey.delete(key);
|
|
158
|
+
blockedRequestIdsBySessionKey.delete(key);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Evaluate whether a session is allowed to delegate expansion work.
|
|
163
|
+
* Delegated contexts are blocked at depth >= 1, with repeated request id
|
|
164
|
+
* re-entry mapped to an explicit idempotency block reason.
|
|
165
|
+
*/
|
|
166
|
+
export function evaluateExpansionRecursionGuard(params: {
|
|
167
|
+
sessionKey?: string;
|
|
168
|
+
requestId: string;
|
|
169
|
+
}): ExpansionRecursionGuardDecision {
|
|
170
|
+
const sessionKey = normalizeSessionKey(params.sessionKey);
|
|
171
|
+
const requestId = params.requestId.trim();
|
|
172
|
+
const delegatedContext =
|
|
173
|
+
delegatedContextBySessionKey.get(sessionKey) ??
|
|
174
|
+
resolveFallbackDelegatedContext(sessionKey, requestId || createExpansionRequestId());
|
|
175
|
+
|
|
176
|
+
if (!delegatedContext) {
|
|
177
|
+
return {
|
|
178
|
+
blocked: false,
|
|
179
|
+
requestId,
|
|
180
|
+
expansionDepth: 0,
|
|
181
|
+
originSessionKey: sessionKey || "main",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (delegatedContext.expansionDepth < EXPANSION_DELEGATION_DEPTH_CAP) {
|
|
186
|
+
return {
|
|
187
|
+
blocked: false,
|
|
188
|
+
requestId,
|
|
189
|
+
expansionDepth: delegatedContext.expansionDepth,
|
|
190
|
+
originSessionKey: delegatedContext.originSessionKey,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const seenRequestIds = getOrInitBlockedRequestIds(sessionKey);
|
|
195
|
+
const isIdempotentReentry = seenRequestIds.has(requestId);
|
|
196
|
+
seenRequestIds.add(requestId);
|
|
197
|
+
const reason: ExpansionRecursionBlockReason = isIdempotentReentry
|
|
198
|
+
? "idempotent_reentry"
|
|
199
|
+
: "depth_cap";
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
blocked: true,
|
|
203
|
+
code: EXPANSION_RECURSION_ERROR_CODE,
|
|
204
|
+
reason,
|
|
205
|
+
message:
|
|
206
|
+
`${EXPANSION_RECURSION_ERROR_CODE}: Expansion delegation blocked at depth ` +
|
|
207
|
+
`${delegatedContext.expansionDepth} (${reason}; requestId=${requestId}; ` +
|
|
208
|
+
`origin=${delegatedContext.originSessionKey}). ` +
|
|
209
|
+
buildExpansionRecursionRecoveryGuidance(delegatedContext.originSessionKey),
|
|
210
|
+
requestId,
|
|
211
|
+
expansionDepth: delegatedContext.expansionDepth,
|
|
212
|
+
originSessionKey: delegatedContext.originSessionKey,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Emit structured delegated expansion telemetry with monotonic counters.
|
|
218
|
+
*/
|
|
219
|
+
export function recordExpansionDelegationTelemetry(params: {
|
|
220
|
+
deps: Pick<LcmDependencies, "log">;
|
|
221
|
+
component: string;
|
|
222
|
+
event: TelemetryEvent;
|
|
223
|
+
requestId: string;
|
|
224
|
+
sessionKey?: string;
|
|
225
|
+
expansionDepth: number;
|
|
226
|
+
originSessionKey: string;
|
|
227
|
+
reason?: string;
|
|
228
|
+
runId?: string;
|
|
229
|
+
}): void {
|
|
230
|
+
telemetryCounters[params.event] += 1;
|
|
231
|
+
const payload = {
|
|
232
|
+
component: params.component,
|
|
233
|
+
event: params.event,
|
|
234
|
+
requestId: params.requestId,
|
|
235
|
+
sessionKey: normalizeSessionKey(params.sessionKey) || undefined,
|
|
236
|
+
expansionDepth: params.expansionDepth,
|
|
237
|
+
originSessionKey: params.originSessionKey,
|
|
238
|
+
reason: params.reason,
|
|
239
|
+
runId: params.runId,
|
|
240
|
+
counters: {
|
|
241
|
+
start: telemetryCounters.start,
|
|
242
|
+
block: telemetryCounters.block,
|
|
243
|
+
timeout: telemetryCounters.timeout,
|
|
244
|
+
success: telemetryCounters.success,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
const line = `[lcm][expansion_delegation] ${JSON.stringify(payload)}`;
|
|
248
|
+
if (params.event === "start" || params.event === "success") {
|
|
249
|
+
params.deps.log.info(line);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
params.deps.log.warn(line);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Return the currently stamped delegated expansion context for test assertions.
|
|
257
|
+
*/
|
|
258
|
+
export function getDelegatedExpansionContextForTests(
|
|
259
|
+
sessionKey: string,
|
|
260
|
+
): DelegatedExpansionContext | undefined {
|
|
261
|
+
return delegatedContextBySessionKey.get(normalizeSessionKey(sessionKey));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Return the delegated expansion telemetry counters for tests.
|
|
266
|
+
*/
|
|
267
|
+
export function getExpansionDelegationTelemetrySnapshotForTests(): Record<TelemetryEvent, number> {
|
|
268
|
+
return {
|
|
269
|
+
start: telemetryCounters.start,
|
|
270
|
+
block: telemetryCounters.block,
|
|
271
|
+
timeout: telemetryCounters.timeout,
|
|
272
|
+
success: telemetryCounters.success,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Reset delegated expansion context and telemetry state between tests.
|
|
278
|
+
*/
|
|
279
|
+
export function resetExpansionDelegationGuardForTests(): void {
|
|
280
|
+
delegatedContextBySessionKey.clear();
|
|
281
|
+
blockedRequestIdsBySessionKey.clear();
|
|
282
|
+
telemetryCounters.start = 0;
|
|
283
|
+
telemetryCounters.block = 0;
|
|
284
|
+
telemetryCounters.timeout = 0;
|
|
285
|
+
telemetryCounters.success = 0;
|
|
286
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -11,6 +11,17 @@ import type { LcmConfig } from "./db/config.js";
|
|
|
11
11
|
* Minimal LLM completion interface needed by LCM for summarization.
|
|
12
12
|
* Matches the signature of completeSimple from @mariozechner/pi-ai.
|
|
13
13
|
*/
|
|
14
|
+
export type CompletionContentBlock = {
|
|
15
|
+
type: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CompletionResult = {
|
|
21
|
+
content: CompletionContentBlock[];
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
14
25
|
export type CompleteFn = (params: {
|
|
15
26
|
provider?: string;
|
|
16
27
|
model: string;
|
|
@@ -24,7 +35,7 @@ export type CompleteFn = (params: {
|
|
|
24
35
|
maxTokens: number;
|
|
25
36
|
temperature?: number;
|
|
26
37
|
reasoning?: string;
|
|
27
|
-
}) => Promise<
|
|
38
|
+
}) => Promise<CompletionResult>;
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
41
|
* Gateway RPC call interface.
|