@martian-engineering/lossless-claw 0.1.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.
@@ -0,0 +1,141 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { LcmContextEngine } from "../engine.js";
3
+ import type { LcmDependencies } from "../types.js";
4
+ import type { AnyAgentTool } from "./common.js";
5
+ import { jsonResult } from "./common.js";
6
+ import { resolveLcmConversationScope } from "./lcm-conversation-scope.js";
7
+
8
+ const LcmDescribeSchema = Type.Object({
9
+ id: Type.String({
10
+ description: "The LCM ID to look up. Use sum_xxx for summaries, file_xxx for files.",
11
+ }),
12
+ conversationId: Type.Optional(
13
+ Type.Number({
14
+ description:
15
+ "Conversation ID to scope describe lookups to. If omitted, uses the current session conversation.",
16
+ }),
17
+ ),
18
+ allConversations: Type.Optional(
19
+ Type.Boolean({
20
+ description:
21
+ "Set true to explicitly allow lookups across all conversations. Ignored when conversationId is provided.",
22
+ }),
23
+ ),
24
+ });
25
+
26
+ export function createLcmDescribeTool(input: {
27
+ deps: LcmDependencies;
28
+ lcm: LcmContextEngine;
29
+ sessionId?: string;
30
+ sessionKey?: string;
31
+ }): AnyAgentTool {
32
+ return {
33
+ name: "lcm_describe",
34
+ label: "LCM Describe",
35
+ description:
36
+ "Look up metadata and content for an LCM item by ID. " +
37
+ "Use this to inspect summaries (sum_xxx) or stored files (file_xxx) " +
38
+ "from compacted conversation history. Returns summary content, lineage, " +
39
+ "token counts, and file exploration results.",
40
+ parameters: LcmDescribeSchema,
41
+ async execute(_toolCallId, params) {
42
+ const retrieval = input.lcm.getRetrieval();
43
+ const p = params as Record<string, unknown>;
44
+ const id = (p.id as string).trim();
45
+ const conversationScope = await resolveLcmConversationScope({
46
+ lcm: input.lcm,
47
+ deps: input.deps,
48
+ sessionId: input.sessionId,
49
+ sessionKey: input.sessionKey,
50
+ params: p,
51
+ });
52
+ if (!conversationScope.allConversations && conversationScope.conversationId == null) {
53
+ return jsonResult({
54
+ error:
55
+ "No LCM conversation found for this session. Provide conversationId or set allConversations=true.",
56
+ });
57
+ }
58
+
59
+ const result = await retrieval.describe(id);
60
+
61
+ if (!result) {
62
+ return jsonResult({
63
+ error: `Not found: ${id}`,
64
+ hint: "Check the ID format (sum_xxx for summaries, file_xxx for files).",
65
+ });
66
+ }
67
+ if (conversationScope.conversationId != null) {
68
+ const itemConversationId =
69
+ result.type === "summary" ? result.summary?.conversationId : result.file?.conversationId;
70
+ if (itemConversationId != null && itemConversationId !== conversationScope.conversationId) {
71
+ return jsonResult({
72
+ error: `Not found in conversation ${conversationScope.conversationId}: ${id}`,
73
+ hint: "Use allConversations=true for cross-conversation lookup.",
74
+ });
75
+ }
76
+ }
77
+
78
+ if (result.type === "summary" && result.summary) {
79
+ const s = result.summary;
80
+ const lines: string[] = [];
81
+ lines.push(`## LCM Summary: ${id}`);
82
+ lines.push("");
83
+ lines.push(`**Conversation:** ${s.conversationId}`);
84
+ lines.push(`**Kind:** ${s.kind}`);
85
+ lines.push(`**Tokens:** ~${s.tokenCount.toLocaleString()}`);
86
+ lines.push(`**Created:** ${s.createdAt.toISOString()}`);
87
+ if (s.parentIds.length > 0) {
88
+ lines.push(`**Parents:** ${s.parentIds.join(", ")}`);
89
+ }
90
+ if (s.childIds.length > 0) {
91
+ lines.push(`**Children:** ${s.childIds.join(", ")}`);
92
+ }
93
+ if (s.messageIds.length > 0) {
94
+ lines.push(`**Messages:** ${s.messageIds.length} linked`);
95
+ }
96
+ if (s.fileIds.length > 0) {
97
+ lines.push(`**Files:** ${s.fileIds.join(", ")}`);
98
+ }
99
+ lines.push("");
100
+ lines.push("## Content");
101
+ lines.push("");
102
+ lines.push(s.content);
103
+
104
+ return {
105
+ content: [{ type: "text", text: lines.join("\n") }],
106
+ details: result,
107
+ };
108
+ }
109
+
110
+ if (result.type === "file" && result.file) {
111
+ const f = result.file;
112
+ const lines: string[] = [];
113
+ lines.push(`## LCM File: ${id}`);
114
+ lines.push("");
115
+ lines.push(`**Conversation:** ${f.conversationId}`);
116
+ lines.push(`**Name:** ${f.fileName ?? "(no name)"}`);
117
+ lines.push(`**Type:** ${f.mimeType ?? "unknown"}`);
118
+ if (f.byteSize != null) {
119
+ lines.push(`**Size:** ${f.byteSize.toLocaleString()} bytes`);
120
+ }
121
+ lines.push(`**Created:** ${f.createdAt.toISOString()}`);
122
+ if (f.explorationSummary) {
123
+ lines.push("");
124
+ lines.push("## Exploration Summary");
125
+ lines.push("");
126
+ lines.push(f.explorationSummary);
127
+ } else {
128
+ lines.push("");
129
+ lines.push("*No exploration summary available.*");
130
+ }
131
+
132
+ return {
133
+ content: [{ type: "text", text: lines.join("\n") }],
134
+ details: result,
135
+ };
136
+ }
137
+
138
+ return jsonResult(result);
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,482 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import crypto from "node:crypto";
3
+ import type { LcmContextEngine } from "../engine.js";
4
+ import {
5
+ createDelegatedExpansionGrant,
6
+ revokeDelegatedExpansionGrantForSession,
7
+ } from "../expansion-auth.js";
8
+ import type { LcmDependencies } from "../types.js";
9
+ import { jsonResult, type AnyAgentTool } from "./common.js";
10
+ import { resolveLcmConversationScope } from "./lcm-conversation-scope.js";
11
+ import {
12
+ normalizeSummaryIds,
13
+ resolveRequesterConversationScopeId,
14
+ } from "./lcm-expand-tool.delegation.js";
15
+
16
+ const DELEGATED_WAIT_TIMEOUT_MS = 120_000;
17
+ const GATEWAY_TIMEOUT_MS = 10_000;
18
+ const DEFAULT_MAX_ANSWER_TOKENS = 2_000;
19
+
20
+ const LcmExpandQuerySchema = Type.Object({
21
+ summaryIds: Type.Optional(
22
+ Type.Array(Type.String(), {
23
+ description: "Summary IDs to expand (sum_xxx). Required when query is not provided.",
24
+ }),
25
+ ),
26
+ query: Type.Optional(
27
+ Type.String({
28
+ description:
29
+ "Text query used to find summaries via grep before expansion. Required when summaryIds is not provided.",
30
+ }),
31
+ ),
32
+ prompt: Type.String({
33
+ description: "Question to answer using expanded context.",
34
+ }),
35
+ conversationId: Type.Optional(
36
+ Type.Number({
37
+ description:
38
+ "Conversation ID to scope expansion to. If omitted, uses the current session conversation.",
39
+ }),
40
+ ),
41
+ allConversations: Type.Optional(
42
+ Type.Boolean({
43
+ description:
44
+ "Set true to explicitly allow cross-conversation lookup. Ignored when conversationId is provided.",
45
+ }),
46
+ ),
47
+ maxTokens: Type.Optional(
48
+ Type.Number({
49
+ description: `Maximum answer tokens to target (default: ${DEFAULT_MAX_ANSWER_TOKENS}).`,
50
+ minimum: 1,
51
+ }),
52
+ ),
53
+ });
54
+
55
+ type ExpandQueryReply = {
56
+ answer: string;
57
+ citedIds: string[];
58
+ expandedSummaryCount: number;
59
+ totalSourceTokens: number;
60
+ truncated: boolean;
61
+ };
62
+
63
+ type SummaryCandidate = {
64
+ summaryId: string;
65
+ conversationId: number;
66
+ };
67
+
68
+ /**
69
+ * Build the sub-agent task message for delegated expansion and prompt answering.
70
+ */
71
+ function buildDelegatedExpandQueryTask(params: {
72
+ summaryIds: string[];
73
+ conversationId: number;
74
+ prompt: string;
75
+ maxTokens: number;
76
+ }) {
77
+ const payload = {
78
+ summaryIds: params.summaryIds,
79
+ conversationId: params.conversationId,
80
+ includeMessages: false,
81
+ };
82
+ return [
83
+ "Run LCM expansion, then answer the user's prompt from the expanded context.",
84
+ "",
85
+ "Step 1: Call `lcm_expand` using exactly this JSON payload:",
86
+ JSON.stringify(payload, null, 2),
87
+ "",
88
+ "Step 2: Use the `lcm_expand` result as source context and answer this prompt:",
89
+ params.prompt,
90
+ "",
91
+ "Return ONLY JSON with this shape:",
92
+ "{",
93
+ ' "answer": "string",',
94
+ ' "citedIds": ["sum_xxx"],',
95
+ ' "expandedSummaryCount": 0,',
96
+ ' "totalSourceTokens": 0,',
97
+ ' "truncated": false',
98
+ "}",
99
+ "",
100
+ "Rules:",
101
+ `- Keep answer concise and focused (target <= ${params.maxTokens} tokens).`,
102
+ "- citedIds must be unique summary IDs.",
103
+ "- expandedSummaryCount should reflect how many summaries were expanded/used.",
104
+ "- totalSourceTokens should be the estimated source token volume from expansion.",
105
+ "- truncated should indicate whether source expansion appears truncated.",
106
+ ].join("\n");
107
+ }
108
+
109
+ /**
110
+ * Parse the child reply; accepts plain JSON or fenced JSON.
111
+ */
112
+ function parseDelegatedExpandQueryReply(
113
+ rawReply: string | undefined,
114
+ fallbackExpandedSummaryCount: number,
115
+ ): ExpandQueryReply {
116
+ const fallback: ExpandQueryReply = {
117
+ answer: (rawReply ?? "").trim(),
118
+ citedIds: [],
119
+ expandedSummaryCount: fallbackExpandedSummaryCount,
120
+ totalSourceTokens: 0,
121
+ truncated: false,
122
+ };
123
+
124
+ const reply = rawReply?.trim();
125
+ if (!reply) {
126
+ return fallback;
127
+ }
128
+
129
+ const candidates: string[] = [reply];
130
+ const fenced = reply.match(/```(?:json)?\s*([\s\S]*?)```/i);
131
+ if (fenced?.[1]) {
132
+ candidates.unshift(fenced[1].trim());
133
+ }
134
+
135
+ for (const candidate of candidates) {
136
+ try {
137
+ const parsed = JSON.parse(candidate) as {
138
+ answer?: unknown;
139
+ citedIds?: unknown;
140
+ expandedSummaryCount?: unknown;
141
+ totalSourceTokens?: unknown;
142
+ truncated?: unknown;
143
+ };
144
+ const answer = typeof parsed.answer === "string" ? parsed.answer.trim() : "";
145
+ const citedIds = normalizeSummaryIds(
146
+ Array.isArray(parsed.citedIds)
147
+ ? parsed.citedIds.filter((value): value is string => typeof value === "string")
148
+ : undefined,
149
+ );
150
+ const expandedSummaryCount =
151
+ typeof parsed.expandedSummaryCount === "number" &&
152
+ Number.isFinite(parsed.expandedSummaryCount)
153
+ ? Math.max(0, Math.floor(parsed.expandedSummaryCount))
154
+ : fallbackExpandedSummaryCount;
155
+ const totalSourceTokens =
156
+ typeof parsed.totalSourceTokens === "number" && Number.isFinite(parsed.totalSourceTokens)
157
+ ? Math.max(0, Math.floor(parsed.totalSourceTokens))
158
+ : 0;
159
+ const truncated = parsed.truncated === true;
160
+
161
+ return {
162
+ answer: answer || fallback.answer,
163
+ citedIds,
164
+ expandedSummaryCount,
165
+ totalSourceTokens,
166
+ truncated,
167
+ };
168
+ } catch {
169
+ // Try next candidate.
170
+ }
171
+ }
172
+
173
+ return fallback;
174
+ }
175
+
176
+ /**
177
+ * Resolve a single source conversation for delegated expansion.
178
+ */
179
+ function resolveSourceConversationId(params: {
180
+ scopedConversationId?: number;
181
+ allConversations: boolean;
182
+ candidates: SummaryCandidate[];
183
+ }): number {
184
+ if (typeof params.scopedConversationId === "number") {
185
+ const mismatched = params.candidates
186
+ .filter((candidate) => candidate.conversationId !== params.scopedConversationId)
187
+ .map((candidate) => candidate.summaryId);
188
+ if (mismatched.length > 0) {
189
+ throw new Error(
190
+ `Some summaryIds are outside conversation ${params.scopedConversationId}: ${mismatched.join(", ")}`,
191
+ );
192
+ }
193
+ return params.scopedConversationId;
194
+ }
195
+
196
+ const conversationIds = Array.from(
197
+ new Set(params.candidates.map((candidate) => candidate.conversationId)),
198
+ );
199
+ if (conversationIds.length === 1 && typeof conversationIds[0] === "number") {
200
+ return conversationIds[0];
201
+ }
202
+
203
+ if (params.allConversations && conversationIds.length > 1) {
204
+ throw new Error(
205
+ "Query matched summaries from multiple conversations. Provide conversationId or narrow the query.",
206
+ );
207
+ }
208
+
209
+ throw new Error(
210
+ "Unable to resolve a single conversation scope. Provide conversationId or set a narrower summary scope.",
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Resolve summary candidates from explicit IDs and/or query matches.
216
+ */
217
+ async function resolveSummaryCandidates(params: {
218
+ lcm: LcmContextEngine;
219
+ explicitSummaryIds: string[];
220
+ query?: string;
221
+ conversationId?: number;
222
+ }): Promise<SummaryCandidate[]> {
223
+ const retrieval = params.lcm.getRetrieval();
224
+ const candidates = new Map<string, SummaryCandidate>();
225
+
226
+ for (const summaryId of params.explicitSummaryIds) {
227
+ const described = await retrieval.describe(summaryId);
228
+ if (!described || described.type !== "summary" || !described.summary) {
229
+ throw new Error(`Summary not found: ${summaryId}`);
230
+ }
231
+ candidates.set(summaryId, {
232
+ summaryId,
233
+ conversationId: described.summary.conversationId,
234
+ });
235
+ }
236
+
237
+ if (params.query) {
238
+ const grepResult = await retrieval.grep({
239
+ query: params.query,
240
+ mode: "full_text",
241
+ scope: "summaries",
242
+ conversationId: params.conversationId,
243
+ });
244
+ for (const summary of grepResult.summaries) {
245
+ candidates.set(summary.summaryId, {
246
+ summaryId: summary.summaryId,
247
+ conversationId: summary.conversationId,
248
+ });
249
+ }
250
+ }
251
+
252
+ return Array.from(candidates.values());
253
+ }
254
+
255
+ export function createLcmExpandQueryTool(input: {
256
+ deps: LcmDependencies;
257
+ lcm: LcmContextEngine;
258
+ /** Session id used for LCM conversation scoping. */
259
+ sessionId?: string;
260
+ /** Requester agent session key used for delegated child session/auth scoping. */
261
+ requesterSessionKey?: string;
262
+ /** Session key for scope fallback when sessionId is unavailable. */
263
+ sessionKey?: string;
264
+ }): AnyAgentTool {
265
+ return {
266
+ name: "lcm_expand_query",
267
+ label: "LCM Expand Query",
268
+ description:
269
+ "Answer a focused question using delegated LCM expansion. " +
270
+ "Find candidate summaries (by IDs or query), expand them in a delegated sub-agent, " +
271
+ "and return a compact prompt-focused answer with cited summary IDs.",
272
+ parameters: LcmExpandQuerySchema,
273
+ async execute(_toolCallId, params) {
274
+ const p = params as Record<string, unknown>;
275
+ const explicitSummaryIds = normalizeSummaryIds(p.summaryIds as string[] | undefined);
276
+ const query = typeof p.query === "string" ? p.query.trim() : "";
277
+ const prompt = typeof p.prompt === "string" ? p.prompt.trim() : "";
278
+ const requestedMaxTokens =
279
+ typeof p.maxTokens === "number" ? Math.trunc(p.maxTokens) : undefined;
280
+ const maxTokens =
281
+ typeof requestedMaxTokens === "number" && Number.isFinite(requestedMaxTokens)
282
+ ? Math.max(1, requestedMaxTokens)
283
+ : DEFAULT_MAX_ANSWER_TOKENS;
284
+
285
+ if (!prompt) {
286
+ return jsonResult({
287
+ error: "prompt is required.",
288
+ });
289
+ }
290
+
291
+ if (explicitSummaryIds.length === 0 && !query) {
292
+ return jsonResult({
293
+ error: "Either summaryIds or query must be provided.",
294
+ });
295
+ }
296
+
297
+ const requesterSessionKey =
298
+ (typeof input.requesterSessionKey === "string"
299
+ ? input.requesterSessionKey
300
+ : input.sessionId
301
+ )?.trim() ?? "";
302
+ const conversationScope = await resolveLcmConversationScope({
303
+ lcm: input.lcm,
304
+ deps: input.deps,
305
+ sessionId: input.sessionId,
306
+ sessionKey: input.sessionKey,
307
+ params: p,
308
+ });
309
+ let scopedConversationId = conversationScope.conversationId;
310
+ if (
311
+ !conversationScope.allConversations &&
312
+ scopedConversationId == null &&
313
+ requesterSessionKey
314
+ ) {
315
+ scopedConversationId = await resolveRequesterConversationScopeId({
316
+ deps: input.deps,
317
+ requesterSessionKey,
318
+ lcm: input.lcm,
319
+ });
320
+ }
321
+
322
+ if (!conversationScope.allConversations && scopedConversationId == null) {
323
+ return jsonResult({
324
+ error:
325
+ "No LCM conversation found for this session. Provide conversationId or set allConversations=true.",
326
+ });
327
+ }
328
+
329
+ let childSessionKey = "";
330
+ let grantCreated = false;
331
+
332
+ try {
333
+ const candidates = await resolveSummaryCandidates({
334
+ lcm: input.lcm,
335
+ explicitSummaryIds,
336
+ query: query || undefined,
337
+ conversationId: scopedConversationId,
338
+ });
339
+
340
+ if (candidates.length === 0) {
341
+ if (typeof scopedConversationId !== "number") {
342
+ return jsonResult({
343
+ error: "No matching summaries found.",
344
+ });
345
+ }
346
+ return jsonResult({
347
+ answer: "No matching summaries found for this scope.",
348
+ citedIds: [],
349
+ sourceConversationId: scopedConversationId,
350
+ expandedSummaryCount: 0,
351
+ totalSourceTokens: 0,
352
+ truncated: false,
353
+ });
354
+ }
355
+
356
+ const sourceConversationId = resolveSourceConversationId({
357
+ scopedConversationId,
358
+ allConversations: conversationScope.allConversations,
359
+ candidates,
360
+ });
361
+ const summaryIds = normalizeSummaryIds(
362
+ candidates
363
+ .filter((candidate) => candidate.conversationId === sourceConversationId)
364
+ .map((candidate) => candidate.summaryId),
365
+ );
366
+
367
+ if (summaryIds.length === 0) {
368
+ return jsonResult({
369
+ error: "No summaryIds available after applying conversation scope.",
370
+ });
371
+ }
372
+
373
+ const requesterAgentId = input.deps.normalizeAgentId(
374
+ input.deps.parseAgentSessionKey(requesterSessionKey)?.agentId,
375
+ );
376
+ childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`;
377
+
378
+ createDelegatedExpansionGrant({
379
+ delegatedSessionKey: childSessionKey,
380
+ issuerSessionId: requesterSessionKey || "main",
381
+ allowedConversationIds: [sourceConversationId],
382
+ tokenCap: input.deps.config.maxExpandTokens,
383
+ ttlMs: DELEGATED_WAIT_TIMEOUT_MS + 30_000,
384
+ });
385
+ grantCreated = true;
386
+
387
+ const task = buildDelegatedExpandQueryTask({
388
+ summaryIds,
389
+ conversationId: sourceConversationId,
390
+ prompt,
391
+ maxTokens,
392
+ });
393
+
394
+ const childIdem = crypto.randomUUID();
395
+ const response = (await input.deps.callGateway({
396
+ method: "agent",
397
+ params: {
398
+ message: task,
399
+ sessionKey: childSessionKey,
400
+ deliver: false,
401
+ lane: input.deps.agentLaneSubagent,
402
+ idempotencyKey: childIdem,
403
+ extraSystemPrompt: input.deps.buildSubagentSystemPrompt({
404
+ depth: 1,
405
+ maxDepth: 8,
406
+ taskSummary: "Run lcm_expand and return prompt-focused JSON answer",
407
+ }),
408
+ },
409
+ timeoutMs: GATEWAY_TIMEOUT_MS,
410
+ })) as { runId?: string };
411
+
412
+ const runId = typeof response?.runId === "string" ? response.runId.trim() : "";
413
+ if (!runId) {
414
+ return jsonResult({
415
+ error: "Delegated expansion did not return a runId.",
416
+ });
417
+ }
418
+
419
+ const wait = (await input.deps.callGateway({
420
+ method: "agent.wait",
421
+ params: {
422
+ runId,
423
+ timeoutMs: DELEGATED_WAIT_TIMEOUT_MS,
424
+ },
425
+ timeoutMs: DELEGATED_WAIT_TIMEOUT_MS,
426
+ })) as { status?: string; error?: string };
427
+ const status = typeof wait?.status === "string" ? wait.status : "error";
428
+ if (status === "timeout") {
429
+ return jsonResult({
430
+ error: "lcm_expand_query timed out waiting for delegated expansion (120s).",
431
+ });
432
+ }
433
+ if (status !== "ok") {
434
+ return jsonResult({
435
+ error:
436
+ typeof wait?.error === "string" && wait.error.trim()
437
+ ? wait.error
438
+ : "Delegated expansion query failed.",
439
+ });
440
+ }
441
+
442
+ const replyPayload = (await input.deps.callGateway({
443
+ method: "sessions.get",
444
+ params: { key: childSessionKey, limit: 80 },
445
+ timeoutMs: GATEWAY_TIMEOUT_MS,
446
+ })) as { messages?: unknown[] };
447
+ const reply = input.deps.readLatestAssistantReply(
448
+ Array.isArray(replyPayload.messages) ? replyPayload.messages : [],
449
+ );
450
+ const parsed = parseDelegatedExpandQueryReply(reply, summaryIds.length);
451
+
452
+ return jsonResult({
453
+ answer: parsed.answer,
454
+ citedIds: parsed.citedIds,
455
+ sourceConversationId,
456
+ expandedSummaryCount: parsed.expandedSummaryCount,
457
+ totalSourceTokens: parsed.totalSourceTokens,
458
+ truncated: parsed.truncated,
459
+ });
460
+ } catch (error) {
461
+ return jsonResult({
462
+ error: error instanceof Error ? error.message : String(error),
463
+ });
464
+ } finally {
465
+ if (childSessionKey) {
466
+ try {
467
+ await input.deps.callGateway({
468
+ method: "sessions.delete",
469
+ params: { key: childSessionKey, deleteTranscript: true },
470
+ timeoutMs: GATEWAY_TIMEOUT_MS,
471
+ });
472
+ } catch {
473
+ // Cleanup is best-effort.
474
+ }
475
+ }
476
+ if (grantCreated && childSessionKey) {
477
+ revokeDelegatedExpansionGrantForSession(childSessionKey, { removeBinding: true });
478
+ }
479
+ }
480
+ },
481
+ };
482
+ }