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.
@@ -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
+ }