opencode-engram 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.
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/README.zh.md +257 -0
- package/README.zht.md +255 -0
- package/package.json +48 -0
- package/src/common/charting.ts +107 -0
- package/src/common/common.ts +74 -0
- package/src/common/config.ts +963 -0
- package/src/common/history-prompt.ts +72 -0
- package/src/common/plugin.ts +593 -0
- package/src/common/upstream-navigator-prompt.ts +131 -0
- package/src/core/index.ts +33 -0
- package/src/core/sdk-bridge.ts +73 -0
- package/src/core/session.ts +196 -0
- package/src/core/turn-index.ts +219 -0
- package/src/domain/adapter.ts +386 -0
- package/src/domain/clip-text.ts +86 -0
- package/src/domain/domain.ts +618 -0
- package/src/domain/preview.ts +132 -0
- package/src/domain/serialize.ts +409 -0
- package/src/domain/types.ts +321 -0
- package/src/runtime/charting.ts +73 -0
- package/src/runtime/debug.ts +155 -0
- package/src/runtime/logger.ts +34 -0
- package/src/runtime/message-io.ts +224 -0
- package/src/runtime/runtime.ts +1033 -0
- package/src/runtime/search.ts +1280 -0
- package/src/runtime/turn-resolve.ts +111 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
|
|
4
|
+
import { composeContentWithToolInputSignature, json, type PluginInput, type ToolContext } from "../common/common.ts";
|
|
5
|
+
import {
|
|
6
|
+
loadEngramConfig,
|
|
7
|
+
resolveVisibleToolNames,
|
|
8
|
+
type EngramConfig,
|
|
9
|
+
} from "../common/config.ts";
|
|
10
|
+
import { normalizeParts, summarizePreviewFallbackHints } from "../domain/adapter.ts";
|
|
11
|
+
import { computeTurns, buildMessageMeta, buildSections, computeOutcome, computeModifiedFiles, formatToolCallSummaries, computeToolCalls, computeAttachments, type TurnComputeItem } from "../domain/domain.ts";
|
|
12
|
+
import { computePreview, computeLastPreview, computePreviewFallback } from "../domain/preview.ts";
|
|
13
|
+
import { clearTurnCache, getTurnMapWithCache } from "../core/turn-index.ts";
|
|
14
|
+
import {
|
|
15
|
+
serializeBrowseItem,
|
|
16
|
+
serializeBrowse,
|
|
17
|
+
serializeOverviewTurn,
|
|
18
|
+
serializeOverview,
|
|
19
|
+
serializeMessageRead,
|
|
20
|
+
serializePartRead,
|
|
21
|
+
serializeSearch,
|
|
22
|
+
serializeSearchMessage,
|
|
23
|
+
serializeSearchHit,
|
|
24
|
+
} from "../domain/serialize.ts";
|
|
25
|
+
import {
|
|
26
|
+
type SearchInput,
|
|
27
|
+
type SearchCacheEntry,
|
|
28
|
+
type SearchMessageInput,
|
|
29
|
+
type ToolSearchVisibility,
|
|
30
|
+
getSearchCacheEntry,
|
|
31
|
+
setSearchCacheEntry,
|
|
32
|
+
buildSearchCacheEntry,
|
|
33
|
+
getSearchCacheInflight,
|
|
34
|
+
setSearchCacheInflight,
|
|
35
|
+
clearSearchCacheInflight,
|
|
36
|
+
executeSearch,
|
|
37
|
+
} from "./search.ts";
|
|
38
|
+
import type { BrowseItemOutput, SectionConvertContext, NormalizedToolPart, OverviewOutput, OverviewTurnOutput, OverviewAssistantOutput, SearchOutput } from "../domain/types.ts";
|
|
39
|
+
import {
|
|
40
|
+
type BrowseContext,
|
|
41
|
+
type SessionTarget,
|
|
42
|
+
createBrowseContext,
|
|
43
|
+
computeCacheFingerprint,
|
|
44
|
+
resolveSessionTarget,
|
|
45
|
+
} from "../core/index.ts";
|
|
46
|
+
import {
|
|
47
|
+
transientSearchErrorMessage,
|
|
48
|
+
isToolCallLoggingEnabled,
|
|
49
|
+
isDebugDirectoryNeeded,
|
|
50
|
+
estimateCallDurationMs,
|
|
51
|
+
recordToolCall,
|
|
52
|
+
ensureDebugGitIgnore,
|
|
53
|
+
} from "./debug.ts";
|
|
54
|
+
import { type MessageBundle, type MessagePage, toNormalizedMessage, sortMessagesChronological, sortMessagesNewestFirst, getMessagePage, getMessage, getAllMessages, internalScanPageSize } from "./message-io.ts";
|
|
55
|
+
import { getSessionFingerprint, fetchTurnItems, getTurnMapWithFallback } from "./turn-resolve.ts";
|
|
56
|
+
import { type Logger, log } from "./logger.ts";
|
|
57
|
+
|
|
58
|
+
const internalSearchCacheTtlMs = 60000;
|
|
59
|
+
|
|
60
|
+
export interface OverviewRequest {
|
|
61
|
+
turnIndex?: number;
|
|
62
|
+
numBefore: number;
|
|
63
|
+
numAfter: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OverviewStateTurn {
|
|
67
|
+
turn: number;
|
|
68
|
+
output: OverviewTurnOutput;
|
|
69
|
+
lastVisibleMessageId: string;
|
|
70
|
+
visibleMessageCount: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OverviewState {
|
|
74
|
+
allTurns: number[];
|
|
75
|
+
turns: OverviewStateTurn[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface BrowseRequest {
|
|
79
|
+
messageID?: string;
|
|
80
|
+
numBefore: number;
|
|
81
|
+
numAfter: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getSessionCacheFingerprint(root: SessionTarget["session"]): string | undefined {
|
|
85
|
+
return computeCacheFingerprint(root);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isCompactionTriggerMessage(msg: MessageBundle) {
|
|
89
|
+
return msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function filterSelfSessionVisibleMessages(msgs: MessageBundle[]) {
|
|
93
|
+
return msgs.filter((msg) => msg.info.summary !== true && !isCompactionTriggerMessage(msg));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Filter messages to only include those before the most recent compaction summary.
|
|
98
|
+
*
|
|
99
|
+
* Used when the target session is the caller's own session. This avoids mixing
|
|
100
|
+
* compacted summaries with the raw history that they already cover.
|
|
101
|
+
*/
|
|
102
|
+
function filterPreCompactionMessages(
|
|
103
|
+
msgs: MessageBundle[],
|
|
104
|
+
hideSummaries = true,
|
|
105
|
+
hideCompactionTrigger = true,
|
|
106
|
+
): MessageBundle[] {
|
|
107
|
+
const newestFirst = sortMessagesNewestFirst([...msgs]);
|
|
108
|
+
const summaryIndex = newestFirst.findIndex((msg) => msg.info.summary === true);
|
|
109
|
+
const preCompactionMessages = summaryIndex < 0
|
|
110
|
+
? msgs
|
|
111
|
+
: msgs.filter((msg) => {
|
|
112
|
+
const preCompactionIds = new Set(
|
|
113
|
+
newestFirst.slice(summaryIndex + 1).map((entry) => entry.info.id),
|
|
114
|
+
);
|
|
115
|
+
return preCompactionIds.has(msg.info.id);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!hideSummaries) {
|
|
119
|
+
return preCompactionMessages;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const withoutSummaries = preCompactionMessages.filter((msg) => msg.info.summary !== true);
|
|
123
|
+
if (!hideCompactionTrigger || summaryIndex < 0) {
|
|
124
|
+
return withoutSummaries;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return withoutSummaries.filter((msg) => !isCompactionTriggerMessage(msg));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runCall<TOutput extends object>(
|
|
131
|
+
input: PluginInput,
|
|
132
|
+
ctx: ToolContext,
|
|
133
|
+
tool: string,
|
|
134
|
+
sessionId: string,
|
|
135
|
+
args: Record<string, unknown>,
|
|
136
|
+
execute: (
|
|
137
|
+
browse: BrowseContext,
|
|
138
|
+
config: EngramConfig,
|
|
139
|
+
journal: Logger,
|
|
140
|
+
) => Promise<TOutput>,
|
|
141
|
+
) {
|
|
142
|
+
const journal = log(input.client, ctx.sessionID);
|
|
143
|
+
const startedAt = performance.now();
|
|
144
|
+
const projectRoot = input.directory;
|
|
145
|
+
let targetSessionID: string | undefined;
|
|
146
|
+
let config: EngramConfig | undefined;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
config = await loadEngramConfig(projectRoot, (message) => {
|
|
150
|
+
journal.error("engram config issue", {
|
|
151
|
+
tool,
|
|
152
|
+
error: message,
|
|
153
|
+
});
|
|
154
|
+
}, input.client);
|
|
155
|
+
if (isDebugDirectoryNeeded(config.debug_mode)) {
|
|
156
|
+
await ensureDebugGitIgnore(projectRoot);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const target = await resolveSessionTarget(input.client, sessionId, input.directory);
|
|
160
|
+
|
|
161
|
+
targetSessionID = target.session.id;
|
|
162
|
+
const isSelf = sessionId === ctx.sessionID;
|
|
163
|
+
const browse = createBrowseContext(target, isSelf);
|
|
164
|
+
|
|
165
|
+
journal.debug(`${tool} target resolved`, {
|
|
166
|
+
targetSessionID,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
ctx.metadata({
|
|
170
|
+
title: tool,
|
|
171
|
+
metadata: {
|
|
172
|
+
targetSessionId: targetSessionID,
|
|
173
|
+
tool,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
journal.debug(`${tool} request`, {
|
|
178
|
+
targetSessionID,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const output = await execute(browse, config, journal);
|
|
182
|
+
await recordToolCall(
|
|
183
|
+
{
|
|
184
|
+
tool,
|
|
185
|
+
sessionID: ctx.sessionID,
|
|
186
|
+
messageID: ctx.messageID,
|
|
187
|
+
targetSessionID,
|
|
188
|
+
args,
|
|
189
|
+
output,
|
|
190
|
+
time: new Date().toISOString(),
|
|
191
|
+
},
|
|
192
|
+
isToolCallLoggingEnabled(config.debug_mode),
|
|
193
|
+
estimateCallDurationMs(startedAt),
|
|
194
|
+
projectRoot,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return json(output);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
200
|
+
journal.error(`${tool} failed`, {
|
|
201
|
+
targetSessionID,
|
|
202
|
+
error: message,
|
|
203
|
+
});
|
|
204
|
+
await recordToolCall(
|
|
205
|
+
{
|
|
206
|
+
tool,
|
|
207
|
+
sessionID: ctx.sessionID,
|
|
208
|
+
messageID: ctx.messageID,
|
|
209
|
+
targetSessionID,
|
|
210
|
+
args,
|
|
211
|
+
error: message,
|
|
212
|
+
time: new Date().toISOString(),
|
|
213
|
+
},
|
|
214
|
+
config !== undefined && isToolCallLoggingEnabled(config.debug_mode),
|
|
215
|
+
estimateCallDurationMs(startedAt),
|
|
216
|
+
projectRoot,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (err instanceof Error) {
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
throw new Error(message);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildBrowseItems(
|
|
227
|
+
msgs: MessageBundle[],
|
|
228
|
+
previewLengths: {
|
|
229
|
+
user: number;
|
|
230
|
+
assistant: number;
|
|
231
|
+
},
|
|
232
|
+
turnMap: Map<string, number>,
|
|
233
|
+
logger?: {
|
|
234
|
+
log: (msg: string, extra?: Record<string, unknown>) => void;
|
|
235
|
+
},
|
|
236
|
+
): BrowseItemOutput[] {
|
|
237
|
+
return msgs.map((msg) => {
|
|
238
|
+
const previewLength = msg.info.role === "user"
|
|
239
|
+
? previewLengths.user
|
|
240
|
+
: previewLengths.assistant;
|
|
241
|
+
const previewInfo = computeMessagePreview(msg, previewLength);
|
|
242
|
+
const turn = turnMap.get(msg.info.id);
|
|
243
|
+
|
|
244
|
+
if (turn === undefined) {
|
|
245
|
+
logger?.log("Internal error: turn not found in turnMap", {
|
|
246
|
+
messageId: msg.info.id,
|
|
247
|
+
});
|
|
248
|
+
throw new Error("Internal error (do not retry).");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const meta = buildMessageMeta(previewInfo.normalizedMsg, turn, previewInfo.normalizedParts);
|
|
252
|
+
return serializeBrowseItem(meta, previewInfo.preview);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function computeMessagePreview(
|
|
257
|
+
msg: MessageBundle,
|
|
258
|
+
previewLength: number,
|
|
259
|
+
) {
|
|
260
|
+
const normalizedMsg = toNormalizedMessage(msg.info);
|
|
261
|
+
const normalizedParts = normalizeParts(msg.parts);
|
|
262
|
+
const textPreview = computePreview(normalizedParts, previewLength);
|
|
263
|
+
const fallback = textPreview === undefined
|
|
264
|
+
? computePreviewFallback(
|
|
265
|
+
normalizedMsg,
|
|
266
|
+
normalizedParts,
|
|
267
|
+
summarizePreviewFallbackHints(msg.parts),
|
|
268
|
+
previewLength,
|
|
269
|
+
)
|
|
270
|
+
: undefined;
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
normalizedMsg,
|
|
274
|
+
normalizedParts,
|
|
275
|
+
textPreview,
|
|
276
|
+
fallbackPreview: fallback?.preview,
|
|
277
|
+
fallbackPriority: fallback?.priority ?? 0,
|
|
278
|
+
preview: textPreview ?? fallback?.preview,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function browseData(
|
|
283
|
+
input: PluginInput,
|
|
284
|
+
browse: BrowseContext,
|
|
285
|
+
config: EngramConfig,
|
|
286
|
+
journal: Logger,
|
|
287
|
+
request: BrowseRequest,
|
|
288
|
+
) {
|
|
289
|
+
const target = browse.target;
|
|
290
|
+
const targetSession = target.session;
|
|
291
|
+
const allRaw = await getAllMessages(input, targetSession.id, internalScanPageSize);
|
|
292
|
+
const visibleMessages = browse.selfSession
|
|
293
|
+
? filterPreCompactionMessages(allRaw)
|
|
294
|
+
: allRaw;
|
|
295
|
+
const ordered = sortMessagesChronological(visibleMessages);
|
|
296
|
+
const anchorMessageID = request.messageID ?? ordered.at(-1)?.info.id;
|
|
297
|
+
|
|
298
|
+
if (request.messageID !== undefined && anchorMessageID === undefined) {
|
|
299
|
+
throw new Error(`Message '${request.messageID}' not found in history. It may be an invalid message_id.`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const anchorIndex = anchorMessageID === undefined
|
|
303
|
+
? -1
|
|
304
|
+
: ordered.findIndex((msg) => msg.info.id === anchorMessageID);
|
|
305
|
+
|
|
306
|
+
if (anchorMessageID !== undefined && anchorIndex < 0) {
|
|
307
|
+
if (browse.selfSession && allRaw.some((msg) => msg.info.id === anchorMessageID)) {
|
|
308
|
+
throw new Error(`Message '${anchorMessageID}' is hidden in this session view. Try a nearby visible message instead.`);
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`Message '${anchorMessageID}' not found in history. It may be an invalid message_id.`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const startIndex = anchorIndex < 0 ? 0 : Math.max(0, anchorIndex - request.numBefore);
|
|
314
|
+
const endIndex = anchorIndex < 0 ? -1 : Math.min(ordered.length - 1, anchorIndex + request.numAfter);
|
|
315
|
+
const windowMessages = anchorIndex < 0 ? [] : ordered.slice(startIndex, endIndex + 1);
|
|
316
|
+
const beforeMessageID = startIndex > 0 ? ordered[startIndex - 1]!.info.id : null;
|
|
317
|
+
const afterMessageID = endIndex >= 0 && endIndex < ordered.length - 1 ? ordered[endIndex + 1]!.info.id : null;
|
|
318
|
+
let seedPage: MessagePage | undefined;
|
|
319
|
+
|
|
320
|
+
const fingerprint = getSessionFingerprint(targetSession);
|
|
321
|
+
const requiredIds = windowMessages.map((msg) => msg.info.id);
|
|
322
|
+
const { turnMap } = await getTurnMapWithCache(
|
|
323
|
+
targetSession.id,
|
|
324
|
+
fingerprint,
|
|
325
|
+
() => fetchTurnItems(input, targetSession.id, internalScanPageSize, seedPage),
|
|
326
|
+
computeTurns,
|
|
327
|
+
journal,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const missingIds = requiredIds.filter((id) => turnMap.get(id) === undefined);
|
|
331
|
+
if (missingIds.length > 0) {
|
|
332
|
+
journal.debug("turn map missing required ids in browse, rebuilding", {
|
|
333
|
+
targetSessionID: targetSession.id,
|
|
334
|
+
missingCount: missingIds.length,
|
|
335
|
+
});
|
|
336
|
+
clearTurnCache(targetSession.id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const finalTurnMap = missingIds.length > 0
|
|
340
|
+
? await getTurnMapWithFallback(input, target, seedPage, requiredIds, journal)
|
|
341
|
+
: turnMap;
|
|
342
|
+
|
|
343
|
+
const logger = {
|
|
344
|
+
log: (msg: string, extra?: Record<string, unknown>) => {
|
|
345
|
+
journal.error(msg, extra);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const messages = buildBrowseItems(
|
|
350
|
+
windowMessages,
|
|
351
|
+
{
|
|
352
|
+
user: config.browse_messages.user_preview_length,
|
|
353
|
+
assistant: config.browse_messages.assistant_preview_length,
|
|
354
|
+
},
|
|
355
|
+
finalTurnMap,
|
|
356
|
+
logger,
|
|
357
|
+
);
|
|
358
|
+
return serializeBrowse(
|
|
359
|
+
beforeMessageID,
|
|
360
|
+
messages,
|
|
361
|
+
afterMessageID,
|
|
362
|
+
request.numBefore > 0,
|
|
363
|
+
request.numAfter > 0,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
interface TurnAggregation {
|
|
368
|
+
turn: number;
|
|
369
|
+
visibleMessageCount: number;
|
|
370
|
+
lastVisibleMessageId: string;
|
|
371
|
+
userMessageId: string | null;
|
|
372
|
+
assistantMessageCount: number;
|
|
373
|
+
userSeen: boolean;
|
|
374
|
+
assistantSeen: boolean;
|
|
375
|
+
userPreview: string | null;
|
|
376
|
+
assistantPreview: string | null;
|
|
377
|
+
userFallback?: string;
|
|
378
|
+
assistantFallback?: string;
|
|
379
|
+
userAttachments: string[];
|
|
380
|
+
userFallbackPriority: number;
|
|
381
|
+
assistantFallbackPriority: number;
|
|
382
|
+
assistantToolParts: NormalizedToolPart[];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function computeTurnAggregations(
|
|
386
|
+
msgsWithTurns: Array<{ msg: MessageBundle; turn: number }>,
|
|
387
|
+
previewLengths: {
|
|
388
|
+
user: number;
|
|
389
|
+
assistant: number;
|
|
390
|
+
},
|
|
391
|
+
): TurnAggregation[] {
|
|
392
|
+
const turnMap = new Map<number, TurnAggregation>();
|
|
393
|
+
|
|
394
|
+
for (const { msg, turn } of msgsWithTurns) {
|
|
395
|
+
const existing = turnMap.get(turn);
|
|
396
|
+
if (existing) {
|
|
397
|
+
existing.visibleMessageCount += 1;
|
|
398
|
+
existing.lastVisibleMessageId = msg.info.id;
|
|
399
|
+
} else {
|
|
400
|
+
turnMap.set(turn, {
|
|
401
|
+
turn,
|
|
402
|
+
visibleMessageCount: 1,
|
|
403
|
+
lastVisibleMessageId: msg.info.id,
|
|
404
|
+
userMessageId: null,
|
|
405
|
+
assistantMessageCount: 0,
|
|
406
|
+
userSeen: false,
|
|
407
|
+
assistantSeen: false,
|
|
408
|
+
userPreview: null,
|
|
409
|
+
assistantPreview: null,
|
|
410
|
+
userFallback: undefined,
|
|
411
|
+
assistantFallback: undefined,
|
|
412
|
+
userAttachments: [],
|
|
413
|
+
userFallbackPriority: 0,
|
|
414
|
+
assistantFallbackPriority: 0,
|
|
415
|
+
assistantToolParts: [],
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (msg.info.role === "user") {
|
|
420
|
+
const agg = turnMap.get(turn)!;
|
|
421
|
+
agg.userSeen = true;
|
|
422
|
+
agg.userMessageId = msg.info.id;
|
|
423
|
+
const previewInfo = computeMessagePreview(msg, previewLengths.user);
|
|
424
|
+
if (agg.userPreview === null && previewInfo.textPreview !== undefined) {
|
|
425
|
+
agg.userPreview = previewInfo.textPreview;
|
|
426
|
+
}
|
|
427
|
+
if (
|
|
428
|
+
agg.userPreview === null
|
|
429
|
+
&& previewInfo.fallbackPreview !== undefined
|
|
430
|
+
&& previewInfo.fallbackPriority > agg.userFallbackPriority
|
|
431
|
+
) {
|
|
432
|
+
agg.userFallback = previewInfo.fallbackPreview;
|
|
433
|
+
agg.userFallbackPriority = previewInfo.fallbackPriority;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
agg.userAttachments = computeAttachments(previewInfo.normalizedParts);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (msg.info.role === "assistant") {
|
|
440
|
+
const agg = turnMap.get(turn)!;
|
|
441
|
+
agg.assistantSeen = true;
|
|
442
|
+
agg.assistantMessageCount += 1;
|
|
443
|
+
|
|
444
|
+
const normalizedParts = normalizeParts(msg.parts);
|
|
445
|
+
|
|
446
|
+
// Collect tool parts for outcome and modified files computation
|
|
447
|
+
for (const part of normalizedParts) {
|
|
448
|
+
if (part.type === "tool") {
|
|
449
|
+
agg.assistantToolParts.push(part);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Last preview: overwrite so last message's last text part wins
|
|
454
|
+
const lastPreview = computeLastPreview(normalizedParts, previewLengths.assistant);
|
|
455
|
+
if (lastPreview !== undefined) {
|
|
456
|
+
agg.assistantPreview = lastPreview;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Fallback when no text preview found
|
|
460
|
+
if (lastPreview === undefined) {
|
|
461
|
+
const normalizedMsg = toNormalizedMessage(msg.info);
|
|
462
|
+
const fallback = computePreviewFallback(
|
|
463
|
+
normalizedMsg,
|
|
464
|
+
normalizedParts,
|
|
465
|
+
summarizePreviewFallbackHints(msg.parts),
|
|
466
|
+
previewLengths.assistant,
|
|
467
|
+
);
|
|
468
|
+
if (
|
|
469
|
+
fallback !== undefined
|
|
470
|
+
&& fallback.priority > agg.assistantFallbackPriority
|
|
471
|
+
) {
|
|
472
|
+
agg.assistantFallback = fallback.preview;
|
|
473
|
+
agg.assistantFallbackPriority = fallback.priority;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
for (const agg of turnMap.values()) {
|
|
480
|
+
if (agg.userPreview === null && agg.userSeen) {
|
|
481
|
+
agg.userPreview = agg.userFallback ?? null;
|
|
482
|
+
}
|
|
483
|
+
if (agg.assistantPreview === null && agg.assistantSeen) {
|
|
484
|
+
agg.assistantPreview = agg.assistantFallback ?? null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return Array.from(turnMap.values()).sort((a, b) => a.turn - b.turn);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function buildTurnItems(msgs: MessageBundle[]): TurnComputeItem[] {
|
|
492
|
+
return msgs.map((msg) => ({
|
|
493
|
+
id: msg.info.id,
|
|
494
|
+
role: msg.info.role as "user" | "assistant",
|
|
495
|
+
time: msg.info.time.created,
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function relativizeModifiedPath(filePath: string, workspaceDirectory: string | undefined): string {
|
|
500
|
+
if (!workspaceDirectory || !filePath.startsWith("/")) {
|
|
501
|
+
return filePath;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const relativePath = relative(workspaceDirectory, filePath);
|
|
505
|
+
if (
|
|
506
|
+
relativePath.length === 0
|
|
507
|
+
|| relativePath === ""
|
|
508
|
+
|| relativePath.startsWith("../")
|
|
509
|
+
|| relativePath === ".."
|
|
510
|
+
) {
|
|
511
|
+
return filePath;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return relativePath;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function buildOverviewTurns(
|
|
518
|
+
aggregations: TurnAggregation[],
|
|
519
|
+
workspaceDirectory: string | undefined,
|
|
520
|
+
): OverviewStateTurn[] {
|
|
521
|
+
return aggregations.map((agg) => {
|
|
522
|
+
// Build assistant output with tool block and modified files
|
|
523
|
+
let assistantOutput: OverviewAssistantOutput | null = null;
|
|
524
|
+
if (agg.assistantSeen) {
|
|
525
|
+
const toolCalls = computeToolCalls(agg.assistantToolParts);
|
|
526
|
+
const formattedCalls = formatToolCallSummaries(toolCalls);
|
|
527
|
+
const modifiedFiles = computeModifiedFiles(agg.assistantToolParts)
|
|
528
|
+
.map((filePath) => relativizeModifiedPath(filePath, workspaceDirectory));
|
|
529
|
+
|
|
530
|
+
assistantOutput = {
|
|
531
|
+
total_messages: agg.assistantMessageCount,
|
|
532
|
+
preview: agg.assistantPreview,
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
if (modifiedFiles.length > 0) {
|
|
536
|
+
assistantOutput.modified = modifiedFiles;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (formattedCalls.length > 0) {
|
|
540
|
+
assistantOutput.tool = {
|
|
541
|
+
calls: formattedCalls,
|
|
542
|
+
outcome: computeOutcome(agg.assistantToolParts),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const userOutput = agg.userSeen && agg.userMessageId !== null
|
|
548
|
+
? {
|
|
549
|
+
message_id: agg.userMessageId,
|
|
550
|
+
preview: agg.userPreview,
|
|
551
|
+
...(agg.userAttachments.length > 0 ? { attachment: agg.userAttachments } : {}),
|
|
552
|
+
}
|
|
553
|
+
: null;
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
turn: agg.turn,
|
|
557
|
+
output: serializeOverviewTurn(
|
|
558
|
+
agg.turn,
|
|
559
|
+
userOutput,
|
|
560
|
+
assistantOutput,
|
|
561
|
+
),
|
|
562
|
+
lastVisibleMessageId: agg.lastVisibleMessageId,
|
|
563
|
+
visibleMessageCount: agg.visibleMessageCount,
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export async function loadOverviewState(
|
|
569
|
+
input: PluginInput,
|
|
570
|
+
browse: BrowseContext,
|
|
571
|
+
config: EngramConfig,
|
|
572
|
+
journal: Logger,
|
|
573
|
+
): Promise<OverviewState> {
|
|
574
|
+
const target = browse.target;
|
|
575
|
+
const targetSession = target.session;
|
|
576
|
+
const allRaw = await getAllMessages(input, targetSession.id, internalScanPageSize);
|
|
577
|
+
|
|
578
|
+
const turnSourceMessages = browse.selfSession
|
|
579
|
+
? filterPreCompactionMessages(allRaw, false, false)
|
|
580
|
+
: allRaw;
|
|
581
|
+
const visibleMessages = browse.selfSession
|
|
582
|
+
? filterSelfSessionVisibleMessages(turnSourceMessages)
|
|
583
|
+
: turnSourceMessages;
|
|
584
|
+
const stableMessages = sortMessagesChronological(turnSourceMessages);
|
|
585
|
+
const stableTurnMap = computeTurns(buildTurnItems(stableMessages));
|
|
586
|
+
const visibleTurns = Array.from(new Set(stableTurnMap.values())).sort((a, b) => a - b);
|
|
587
|
+
const msgsWithTurns = sortMessagesChronological(visibleMessages).map((msg) => ({
|
|
588
|
+
msg,
|
|
589
|
+
turn: stableTurnMap.get(msg.info.id)!,
|
|
590
|
+
}));
|
|
591
|
+
const aggregations = computeTurnAggregations(msgsWithTurns, {
|
|
592
|
+
user: config.browse_turns.user_preview_length,
|
|
593
|
+
assistant: config.browse_turns.assistant_preview_length,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
journal.debug("overview state built", {
|
|
597
|
+
targetSessionID: targetSession.id,
|
|
598
|
+
stableTurnCount: visibleTurns.length,
|
|
599
|
+
visibleTurnCount: aggregations.length,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
allTurns: visibleTurns,
|
|
604
|
+
turns: buildOverviewTurns(aggregations, input.directory),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function buildOverviewTurnWindow(
|
|
609
|
+
state: OverviewState,
|
|
610
|
+
request: OverviewRequest,
|
|
611
|
+
): OverviewStateTurn[] {
|
|
612
|
+
if (state.turns.length === 0) {
|
|
613
|
+
if (request.turnIndex !== undefined) {
|
|
614
|
+
if (state.allTurns.includes(request.turnIndex)) {
|
|
615
|
+
throw new Error(`Turn ${request.turnIndex} is hidden in this session view. Try a nearby visible turn instead.`);
|
|
616
|
+
}
|
|
617
|
+
throw new Error(`Turn ${request.turnIndex} not found in history.`);
|
|
618
|
+
}
|
|
619
|
+
return [];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const targetTurn = request.turnIndex ?? state.turns.at(-1)!.turn;
|
|
623
|
+
const visibleTurn = state.turns.find((turn) => turn.turn === targetTurn);
|
|
624
|
+
if (!visibleTurn) {
|
|
625
|
+
if (state.allTurns.includes(targetTurn)) {
|
|
626
|
+
throw new Error(`Turn ${targetTurn} is hidden in this session view. Try a nearby visible turn instead.`);
|
|
627
|
+
}
|
|
628
|
+
throw new Error(`Turn ${targetTurn} not found in history.`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const minTurn = targetTurn - request.numBefore;
|
|
632
|
+
const maxTurn = targetTurn + request.numAfter;
|
|
633
|
+
return state.turns.filter((turn) => turn.turn >= minTurn && turn.turn <= maxTurn);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export async function overviewData(
|
|
637
|
+
input: PluginInput,
|
|
638
|
+
browse: BrowseContext,
|
|
639
|
+
config: EngramConfig,
|
|
640
|
+
journal: Logger,
|
|
641
|
+
request: OverviewRequest,
|
|
642
|
+
): Promise<OverviewOutput> {
|
|
643
|
+
const state = await loadOverviewState(input, browse, config, journal);
|
|
644
|
+
const turns = buildOverviewTurnWindow(state, request).map((turn) => turn.output);
|
|
645
|
+
return serializeOverview(turns);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getSectionContext(
|
|
649
|
+
config: EngramConfig,
|
|
650
|
+
toolNames: Iterable<string>,
|
|
651
|
+
): SectionConvertContext {
|
|
652
|
+
return {
|
|
653
|
+
maxTextLength: config.pull_message.text_length,
|
|
654
|
+
maxReasoningLength: config.pull_message.reasoning_length,
|
|
655
|
+
maxToolOutputLength: config.pull_message.tool_output_length,
|
|
656
|
+
maxToolInputLength: config.pull_message.tool_input_length,
|
|
657
|
+
visibleToolInputs: new Set(resolveVisibleToolNames(toolNames, config.show_tool_input)),
|
|
658
|
+
visibleToolOutputs: new Set(resolveVisibleToolNames(toolNames, config.show_tool_output)),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function shouldShowToolInput(tool: string, config: EngramConfig) {
|
|
663
|
+
return resolveVisibleToolNames([tool], config.show_tool_input).includes(tool);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function shouldShowToolOutput(tool: string, config: EngramConfig) {
|
|
667
|
+
return resolveVisibleToolNames([tool], config.show_tool_output).includes(tool);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function readMessageDetail(
|
|
671
|
+
input: PluginInput,
|
|
672
|
+
target: SessionTarget,
|
|
673
|
+
config: EngramConfig,
|
|
674
|
+
messageID: string,
|
|
675
|
+
journal: Logger,
|
|
676
|
+
): Promise<Record<string, unknown>> {
|
|
677
|
+
const targetSession = target.session;
|
|
678
|
+
const msg = await getMessage(input, targetSession.id, messageID);
|
|
679
|
+
const normalizedMsg = toNormalizedMessage(msg.info);
|
|
680
|
+
const normalizedParts = normalizeParts(msg.parts);
|
|
681
|
+
const turnMap = await getTurnMapWithFallback(input, target, undefined, [messageID], journal);
|
|
682
|
+
const turn = turnMap.get(messageID);
|
|
683
|
+
|
|
684
|
+
if (turn === undefined) {
|
|
685
|
+
journal.error("Internal error: turn not found in turnMap", { messageID });
|
|
686
|
+
throw new Error("Internal error (do not retry).");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const meta = buildMessageMeta(normalizedMsg, turn, normalizedParts);
|
|
690
|
+
const sections = buildSections(
|
|
691
|
+
normalizedParts,
|
|
692
|
+
getSectionContext(
|
|
693
|
+
config,
|
|
694
|
+
normalizedParts
|
|
695
|
+
.filter((part) => part.type === "tool")
|
|
696
|
+
.map((part) => part.tool),
|
|
697
|
+
),
|
|
698
|
+
);
|
|
699
|
+
return serializeMessageRead(meta, sections);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function readPartDetail(
|
|
703
|
+
input: PluginInput,
|
|
704
|
+
target: SessionTarget,
|
|
705
|
+
config: EngramConfig,
|
|
706
|
+
messageID: string,
|
|
707
|
+
partID: string,
|
|
708
|
+
): Promise<Record<string, unknown>> {
|
|
709
|
+
const targetSession = target.session;
|
|
710
|
+
const msg = await getMessage(input, targetSession.id, messageID);
|
|
711
|
+
const normalizedParts = normalizeParts(msg.parts);
|
|
712
|
+
const targetPart = normalizedParts.find((p) => p.partId === partID);
|
|
713
|
+
|
|
714
|
+
if (!targetPart) {
|
|
715
|
+
throw new Error("Requested part not found. Please ensure the part_id is correct.");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (targetPart.type === "text" && (targetPart.ignored || !targetPart.text.trim())) {
|
|
719
|
+
throw new Error("Requested part has no readable text content. It may be empty or ignored.");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let content: string;
|
|
723
|
+
let type: "text" | "reasoning" | "tool";
|
|
724
|
+
switch (targetPart.type) {
|
|
725
|
+
case "text":
|
|
726
|
+
type = "text";
|
|
727
|
+
content = targetPart.text;
|
|
728
|
+
break;
|
|
729
|
+
case "reasoning":
|
|
730
|
+
type = "reasoning";
|
|
731
|
+
content = targetPart.text;
|
|
732
|
+
break;
|
|
733
|
+
case "tool": {
|
|
734
|
+
type = "tool";
|
|
735
|
+
const toolInput = shouldShowToolInput(targetPart.tool, config) ? targetPart.input : undefined;
|
|
736
|
+
const toolContent = shouldShowToolOutput(targetPart.tool, config) ? targetPart.content : undefined;
|
|
737
|
+
const contentWithHeader = composeContentWithToolInputSignature(targetPart.tool, toolInput, toolContent);
|
|
738
|
+
|
|
739
|
+
if (contentWithHeader === undefined) {
|
|
740
|
+
if (!shouldShowToolOutput(targetPart.tool, config)) {
|
|
741
|
+
throw new Error(`Section '${partID}' content is hidden by show_tool_output.`);
|
|
742
|
+
}
|
|
743
|
+
if (targetPart.status === "running" || targetPart.status === "pending") {
|
|
744
|
+
throw new Error(`Section '${partID}' has no content yet (status: ${targetPart.status}).`);
|
|
745
|
+
}
|
|
746
|
+
throw new Error(`Section '${partID}' has no content.`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return serializePartRead(type, contentWithHeader);
|
|
750
|
+
}
|
|
751
|
+
default:
|
|
752
|
+
throw new Error("Requested part not found. Please ensure the part_id is correct.");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return serializePartRead(type, content);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export async function readData(
|
|
759
|
+
input: PluginInput,
|
|
760
|
+
browse: BrowseContext,
|
|
761
|
+
config: EngramConfig,
|
|
762
|
+
messageID: string,
|
|
763
|
+
partID: string | undefined,
|
|
764
|
+
journal: Logger,
|
|
765
|
+
): Promise<Record<string, unknown>> {
|
|
766
|
+
const target = browse.target;
|
|
767
|
+
if (!partID) {
|
|
768
|
+
return readMessageDetail(input, target, config, messageID, journal);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return readPartDetail(input, target, config, messageID, partID);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function buildSearchMessageInputs(
|
|
775
|
+
msgs: MessageBundle[],
|
|
776
|
+
turnMap: Map<string, number>,
|
|
777
|
+
logger?: {
|
|
778
|
+
log: (msg: string, extra?: Record<string, unknown>) => void;
|
|
779
|
+
},
|
|
780
|
+
): SearchMessageInput[] {
|
|
781
|
+
return msgs.map((msg) => {
|
|
782
|
+
const turn = turnMap.get(msg.info.id);
|
|
783
|
+
if (turn === undefined) {
|
|
784
|
+
logger?.log("Internal error: turn not found in turnMap", {
|
|
785
|
+
messageId: msg.info.id,
|
|
786
|
+
});
|
|
787
|
+
throw new Error("Internal error (do not retry).");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
id: msg.info.id,
|
|
792
|
+
role: msg.info.role as "user" | "assistant",
|
|
793
|
+
time: msg.info.time.created,
|
|
794
|
+
parts: normalizeParts(msg.parts),
|
|
795
|
+
turn,
|
|
796
|
+
};
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function collectToolNames(msgs: MessageBundle[]): string[] {
|
|
801
|
+
const toolNames: string[] = [];
|
|
802
|
+
for (const msg of msgs) {
|
|
803
|
+
for (const part of msg.parts) {
|
|
804
|
+
if (part.type !== "tool") {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
toolNames.push(part.tool);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return toolNames;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function getOrBuildSearchCache(
|
|
814
|
+
input: PluginInput,
|
|
815
|
+
target: SessionTarget,
|
|
816
|
+
selfSession: boolean,
|
|
817
|
+
config: EngramConfig,
|
|
818
|
+
journal: Logger,
|
|
819
|
+
): Promise<SearchCacheEntry> {
|
|
820
|
+
const targetSession = target.session;
|
|
821
|
+
const fingerprint = getSessionCacheFingerprint(targetSession);
|
|
822
|
+
const visibilitySignature = JSON.stringify([
|
|
823
|
+
config.show_tool_input,
|
|
824
|
+
config.show_tool_output,
|
|
825
|
+
]);
|
|
826
|
+
const cacheKey = selfSession
|
|
827
|
+
? `self:${targetSession.id}:${visibilitySignature}`
|
|
828
|
+
: `${targetSession.id}:${visibilitySignature}`;
|
|
829
|
+
const cached = getSearchCacheEntry(cacheKey, fingerprint, internalSearchCacheTtlMs);
|
|
830
|
+
|
|
831
|
+
if (cached) {
|
|
832
|
+
journal.debug("search cache hit", {
|
|
833
|
+
targetSessionID: targetSession.id,
|
|
834
|
+
documentCount: cached.documents.length,
|
|
835
|
+
cacheAge: Date.now() - cached.createdAt,
|
|
836
|
+
});
|
|
837
|
+
return cached;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const inflight = getSearchCacheInflight(cacheKey, fingerprint);
|
|
841
|
+
if (inflight) {
|
|
842
|
+
journal.debug("search cache joining in-flight build", {
|
|
843
|
+
targetSessionID: targetSession.id,
|
|
844
|
+
});
|
|
845
|
+
return inflight;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
journal.debug("search cache miss, building index", {
|
|
849
|
+
targetSessionID: targetSession.id,
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
const buildPromise = (async (): Promise<SearchCacheEntry> => {
|
|
853
|
+
const allRaw = await getAllMessages(input, targetSession.id, internalScanPageSize);
|
|
854
|
+
const allMessages = selfSession ? filterPreCompactionMessages(allRaw) : allRaw;
|
|
855
|
+
const sortedMessages = sortMessagesChronological(allMessages);
|
|
856
|
+
const turnItems: TurnComputeItem[] = sortedMessages.map((msg) => ({
|
|
857
|
+
id: msg.info.id,
|
|
858
|
+
role: msg.info.role as "user" | "assistant",
|
|
859
|
+
time: msg.info.time.created,
|
|
860
|
+
}));
|
|
861
|
+
const turnMap = computeTurns(turnItems);
|
|
862
|
+
const logger = {
|
|
863
|
+
log: (msg: string, extra?: Record<string, unknown>) => {
|
|
864
|
+
journal.error(msg, { targetSessionID: targetSession.id, ...extra });
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
const toolNames = collectToolNames(allMessages);
|
|
868
|
+
const toolSearchVisibility: ToolSearchVisibility = {
|
|
869
|
+
visibleToolInputs: new Set(resolveVisibleToolNames(toolNames, config.show_tool_input)),
|
|
870
|
+
visibleToolOutputs: new Set(resolveVisibleToolNames(toolNames, config.show_tool_output)),
|
|
871
|
+
};
|
|
872
|
+
const searchInputs = buildSearchMessageInputs(allMessages, turnMap, logger);
|
|
873
|
+
const entry = await buildSearchCacheEntry(cacheKey, fingerprint, searchInputs, toolSearchVisibility);
|
|
874
|
+
setSearchCacheEntry(entry);
|
|
875
|
+
|
|
876
|
+
journal.debug("search index built", {
|
|
877
|
+
targetSessionID: targetSession.id,
|
|
878
|
+
documentCount: entry.documents.length,
|
|
879
|
+
messageCount: entry.messageMeta.size,
|
|
880
|
+
});
|
|
881
|
+
return entry;
|
|
882
|
+
})();
|
|
883
|
+
|
|
884
|
+
setSearchCacheInflight(cacheKey, fingerprint, buildPromise);
|
|
885
|
+
try {
|
|
886
|
+
return await buildPromise;
|
|
887
|
+
} finally {
|
|
888
|
+
clearSearchCacheInflight(cacheKey, fingerprint, buildPromise);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
export async function searchData(
|
|
893
|
+
input: PluginInput,
|
|
894
|
+
browse: BrowseContext,
|
|
895
|
+
config: EngramConfig,
|
|
896
|
+
searchInput: SearchInput,
|
|
897
|
+
journal: Logger,
|
|
898
|
+
): Promise<SearchOutput> {
|
|
899
|
+
const target = browse.target;
|
|
900
|
+
|
|
901
|
+
const toError = (error: unknown): Error => {
|
|
902
|
+
if (error instanceof Error) {
|
|
903
|
+
return error;
|
|
904
|
+
}
|
|
905
|
+
return new Error(String(error));
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const isKnownTransientSearchError = (error: unknown): boolean => {
|
|
909
|
+
const message = toError(error).message.toLowerCase();
|
|
910
|
+
return message.includes("temporary issue") || message.includes("try again");
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
let cache: SearchCacheEntry;
|
|
914
|
+
try {
|
|
915
|
+
cache = await getOrBuildSearchCache(input, target, browse.selfSession, config, journal);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
if (isKnownTransientSearchError(error)) {
|
|
918
|
+
throw new Error(transientSearchErrorMessage);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const original = toError(error);
|
|
922
|
+
journal.error("search cache build failed", {
|
|
923
|
+
targetSessionID: target.session.id,
|
|
924
|
+
error: original.message,
|
|
925
|
+
});
|
|
926
|
+
throw original;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
journal.debug("search executing query", {
|
|
930
|
+
targetSessionID: target.session.id,
|
|
931
|
+
documentCount: cache.documents.length,
|
|
932
|
+
query: searchInput.query,
|
|
933
|
+
literal: searchInput.literal,
|
|
934
|
+
limit: searchInput.limit,
|
|
935
|
+
types: searchInput.types,
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
let result: Awaited<ReturnType<typeof executeSearch>>;
|
|
939
|
+
try {
|
|
940
|
+
result = await executeSearch(
|
|
941
|
+
cache,
|
|
942
|
+
searchInput,
|
|
943
|
+
config.search.snippet_length,
|
|
944
|
+
config.search.max_snippets_per_hit,
|
|
945
|
+
);
|
|
946
|
+
} catch (error) {
|
|
947
|
+
if (isKnownTransientSearchError(error)) {
|
|
948
|
+
throw new Error(transientSearchErrorMessage);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const original = toError(error);
|
|
952
|
+
journal.error("search execution failed", {
|
|
953
|
+
targetSessionID: target.session.id,
|
|
954
|
+
error: original.message,
|
|
955
|
+
});
|
|
956
|
+
throw original;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
journal.debug("search completed", {
|
|
960
|
+
targetSessionID: target.session.id,
|
|
961
|
+
totalHits: result.totalHits,
|
|
962
|
+
hitsReturned: result.hits.length,
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
if (result.totalHits === 0) {
|
|
966
|
+
return serializeSearch(undefined);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const messageGroups = new Map<
|
|
970
|
+
string,
|
|
971
|
+
{
|
|
972
|
+
meta: { messageId: string; role: "user" | "assistant"; turn: number };
|
|
973
|
+
hits: Array<{
|
|
974
|
+
type: "text" | "reasoning" | "tool";
|
|
975
|
+
partId: string;
|
|
976
|
+
toolName?: string;
|
|
977
|
+
snippets: string[];
|
|
978
|
+
}>;
|
|
979
|
+
}
|
|
980
|
+
>();
|
|
981
|
+
|
|
982
|
+
for (const hit of result.hits) {
|
|
983
|
+
const meta = cache.messageMeta.get(hit.messageId);
|
|
984
|
+
if (!meta) {
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const existing = messageGroups.get(hit.messageId);
|
|
989
|
+
if (existing) {
|
|
990
|
+
existing.hits.push({
|
|
991
|
+
type: hit.type,
|
|
992
|
+
partId: hit.documentId,
|
|
993
|
+
toolName: hit.toolName,
|
|
994
|
+
snippets: hit.snippets,
|
|
995
|
+
});
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
messageGroups.set(hit.messageId, {
|
|
1000
|
+
meta: {
|
|
1001
|
+
messageId: meta.id,
|
|
1002
|
+
role: meta.role,
|
|
1003
|
+
turn: meta.turn,
|
|
1004
|
+
},
|
|
1005
|
+
hits: [
|
|
1006
|
+
{
|
|
1007
|
+
type: hit.type,
|
|
1008
|
+
partId: hit.documentId,
|
|
1009
|
+
toolName: hit.toolName,
|
|
1010
|
+
snippets: hit.snippets,
|
|
1011
|
+
},
|
|
1012
|
+
],
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const messages = Array.from(messageGroups.values())
|
|
1017
|
+
.slice(0, searchInput.limit)
|
|
1018
|
+
.map((group) => {
|
|
1019
|
+
const totalHits = group.hits.length;
|
|
1020
|
+
const selectedHits = group.hits.slice(0, config.search.max_hits_per_message);
|
|
1021
|
+
const remainHits = totalHits - selectedHits.length;
|
|
1022
|
+
|
|
1023
|
+
return serializeSearchMessage(
|
|
1024
|
+
group.meta.messageId,
|
|
1025
|
+
group.meta.role,
|
|
1026
|
+
group.meta.turn,
|
|
1027
|
+
selectedHits.map((h) => serializeSearchHit(h.type, h.partId, h.snippets, h.toolName)),
|
|
1028
|
+
remainHits,
|
|
1029
|
+
);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
return serializeSearch(messages);
|
|
1033
|
+
}
|