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,321 @@
1
+ /**
2
+ * types.ts - Shared domain types and output contracts
3
+ *
4
+ * This module defines all domain model types for the upstream history refactor.
5
+ * It serves as the stable boundary between adapter, domain, and serialize layers.
6
+ */
7
+
8
+ // =============================================================================
9
+ // Basic Types
10
+ // =============================================================================
11
+
12
+ export type MessageRole = "user" | "assistant";
13
+
14
+ export type ToolStatus = "pending" | "running" | "completed" | "error";
15
+
16
+ export type ToolOutcome = "completed" | "recovered" | "error" | "running";
17
+
18
+ export type ReadablePartType = "text" | "reasoning" | "tool";
19
+
20
+ // =============================================================================
21
+ // Normalized Message (Adapter Output -> Domain Input)
22
+ // =============================================================================
23
+
24
+ export interface NormalizedMessage {
25
+ id: string;
26
+ role: MessageRole;
27
+ time: number | undefined;
28
+ summary: boolean;
29
+ }
30
+
31
+ // =============================================================================
32
+ // Tool Call Summary
33
+ // =============================================================================
34
+
35
+ /**
36
+ * Aggregated statistics for a single tool name within a message.
37
+ */
38
+ export interface ToolCallSummary {
39
+ tool: string;
40
+ total: number;
41
+ errors: number;
42
+ }
43
+
44
+ // =============================================================================
45
+ // Message Metadata
46
+ // =============================================================================
47
+
48
+ export interface MessageMetaBase {
49
+ id: string;
50
+ role: MessageRole;
51
+ turn: number;
52
+ time: number | undefined;
53
+ notes: string[];
54
+ }
55
+
56
+ export interface UserMessageMeta extends MessageMetaBase {
57
+ role: "user";
58
+ attachments: string[];
59
+ }
60
+
61
+ export interface AssistantMessageMeta extends MessageMetaBase {
62
+ role: "assistant";
63
+ toolCalls: ToolCallSummary[];
64
+ toolOutcome?: ToolOutcome;
65
+ }
66
+
67
+ export type AnyMessageMeta = UserMessageMeta | AssistantMessageMeta;
68
+
69
+ // =============================================================================
70
+ // Section Types (Domain Model)
71
+ // =============================================================================
72
+
73
+ export interface TextSection {
74
+ type: "text";
75
+ partId: string;
76
+ content: string;
77
+ truncated: boolean;
78
+ }
79
+
80
+ export interface ReasoningSection {
81
+ type: "reasoning";
82
+ partId: string;
83
+ content: string;
84
+ truncated: boolean;
85
+ }
86
+
87
+ export interface ToolSection {
88
+ type: "tool";
89
+ partId: string;
90
+ tool: string;
91
+ title: string | null;
92
+ status: ToolStatus;
93
+ input?: Record<string, unknown>;
94
+ content?: string;
95
+ truncated: boolean;
96
+ }
97
+
98
+ export interface ImageSection {
99
+ type: "image";
100
+ partId: string;
101
+ mime: string;
102
+ }
103
+
104
+ export interface FileSection {
105
+ type: "file";
106
+ partId: string;
107
+ path: string;
108
+ mime: string;
109
+ }
110
+
111
+ export type Section =
112
+ | TextSection
113
+ | ReasoningSection
114
+ | ToolSection
115
+ | ImageSection
116
+ | FileSection;
117
+
118
+ // =============================================================================
119
+ // Normalized Part (Adapter Output -> Domain Input)
120
+ // =============================================================================
121
+
122
+ export type NormalizedTextPart = {
123
+ type: "text";
124
+ partId: string;
125
+ messageId: string;
126
+ text: string;
127
+ ignored: boolean;
128
+ };
129
+
130
+ export type NormalizedReasoningPart = {
131
+ type: "reasoning";
132
+ partId: string;
133
+ messageId: string;
134
+ text: string;
135
+ };
136
+
137
+ export type NormalizedToolPart = {
138
+ type: "tool";
139
+ partId: string;
140
+ messageId: string;
141
+ tool: string;
142
+ title: string | null;
143
+ status: ToolStatus;
144
+ input: Record<string, unknown>;
145
+ content?: string;
146
+ };
147
+
148
+ export type NormalizedImagePart = {
149
+ type: "image";
150
+ partId: string;
151
+ messageId: string;
152
+ mime: string;
153
+ };
154
+
155
+ export type NormalizedFilePart = {
156
+ type: "file";
157
+ partId: string;
158
+ messageId: string;
159
+ path: string;
160
+ mime: string;
161
+ };
162
+
163
+ export type NormalizedPart =
164
+ | NormalizedTextPart
165
+ | NormalizedReasoningPart
166
+ | NormalizedToolPart
167
+ | NormalizedImagePart
168
+ | NormalizedFilePart;
169
+
170
+ // =============================================================================
171
+ // Section Conversion Context
172
+ // =============================================================================
173
+
174
+ export interface SectionConvertContext {
175
+ maxTextLength: number;
176
+ maxReasoningLength: number;
177
+ maxToolOutputLength: number;
178
+ maxToolInputLength: number;
179
+ visibleToolInputs: ReadonlySet<string>;
180
+ visibleToolOutputs: ReadonlySet<string>;
181
+ }
182
+
183
+ export interface PreviewFallbackHints {
184
+ hasCompaction: boolean;
185
+ hasSubtask: boolean;
186
+ hasUnsupported: boolean;
187
+ }
188
+
189
+ // =============================================================================
190
+ // Serialized Output Types (Final JSON Contracts)
191
+ // =============================================================================
192
+
193
+ export interface BrowseUserItemOutput {
194
+ role: "user";
195
+ turn_index: number;
196
+ message_id: string;
197
+ preview?: string;
198
+ attachment?: string[];
199
+ }
200
+
201
+ export interface BrowseAssistantItemOutput {
202
+ role: "assistant";
203
+ turn_index: number;
204
+ message_id: string;
205
+ preview?: string;
206
+ tool?: ToolBlockOutput;
207
+ }
208
+
209
+ export type BrowseItemOutput =
210
+ | BrowseUserItemOutput
211
+ | BrowseAssistantItemOutput;
212
+
213
+ export interface OverviewUserOutput {
214
+ message_id: string;
215
+ preview: string | null;
216
+ attachment?: string[];
217
+ }
218
+
219
+ /** Tool activity summary block for assistant outputs. */
220
+ export interface ToolBlockOutput {
221
+ calls: string[];
222
+ outcome: ToolOutcome;
223
+ }
224
+
225
+ export interface OverviewAssistantOutput {
226
+ total_messages: number;
227
+ preview: string | null;
228
+ modified?: string[];
229
+ tool?: ToolBlockOutput;
230
+ }
231
+
232
+ /**
233
+ * Turn summary for overview output.
234
+ */
235
+ export interface OverviewTurnOutput {
236
+ turn_index: number;
237
+ user: OverviewUserOutput | null;
238
+ assistant: OverviewAssistantOutput | null;
239
+ }
240
+
241
+ export interface OverviewOutput {
242
+ turns: OverviewTurnOutput[];
243
+ }
244
+
245
+ export interface BrowseOutput {
246
+ before_message_id?: string | null;
247
+ messages: BrowseItemOutput[];
248
+ after_message_id?: string | null;
249
+ }
250
+
251
+ export type SectionOutput =
252
+ | { type: "text"; part_id?: string; content: string | null }
253
+ | { type: "reasoning"; part_id?: string; content: string | null }
254
+ | {
255
+ type: "tool";
256
+ part_id?: string;
257
+ tool: string;
258
+ status: ToolStatus;
259
+ input?: Record<string, unknown>;
260
+ content?: string | null;
261
+ }
262
+ | { type: "image"; mime: string }
263
+ | { type: "file"; path: string; mime: string };
264
+
265
+ // =============================================================================
266
+ // Search Output Types (Phase 1: Contracts Only)
267
+ // =============================================================================
268
+
269
+ /**
270
+ * Searchable part type.
271
+ *
272
+ * - "text": user or assistant text content
273
+ * - "reasoning": assistant reasoning content
274
+ * - "tool": tool call output content
275
+ */
276
+ export type SearchPartType = "text" | "reasoning" | "tool";
277
+
278
+ /**
279
+ * A single search hit within a message.
280
+ *
281
+ * - type: part type that matched
282
+ * - part_id: identifier for reading full content
283
+ * - tool_name: present only for tool hits (optional)
284
+ * - snippets: ordered array of highest-priority text contexts around matches
285
+ */
286
+ export interface SearchHitOutput {
287
+ type: SearchPartType;
288
+ part_id: string;
289
+ tool_name?: string;
290
+ snippets: string[];
291
+ }
292
+
293
+ /**
294
+ * Search result grouped by message.
295
+ *
296
+ * - role: message role
297
+ * - turn_index: message turn number
298
+ * - message_id: identifier for reading full message
299
+ * - hits: array of hits within this message
300
+ * - remain_hits: omitted when 0; number of omitted low-priority hits
301
+ */
302
+ export interface SearchMessageOutput {
303
+ role: MessageRole;
304
+ turn_index: number;
305
+ message_id: string;
306
+ hits: SearchHitOutput[];
307
+ remain_hits?: number;
308
+ }
309
+
310
+ /**
311
+ * Full search response output.
312
+ *
313
+ * - messages: results grouped by message (omitted when no message has hits)
314
+ */
315
+ export type SearchNoHitsOutput = Record<string, never>;
316
+
317
+ export interface SearchHitsOutput {
318
+ messages: SearchMessageOutput[];
319
+ }
320
+
321
+ export type SearchOutput = SearchNoHitsOutput | SearchHitsOutput;
@@ -0,0 +1,73 @@
1
+ import type { PluginInput } from "../common/common.ts";
2
+ import type { EngramConfig } from "../common/config.ts";
3
+ import type { BrowseOutput, OverviewOutput } from "../domain/types.ts";
4
+
5
+ import { createBrowseContext, resolveSessionTarget } from "../core/index.ts";
6
+ import { log } from "./logger.ts";
7
+ import { browseData, loadOverviewState } from "./runtime.ts";
8
+
9
+ export interface ChartingData {
10
+ overview: OverviewOutput;
11
+ latestTurnDetail: BrowseOutput;
12
+ }
13
+
14
+ // =============================================================================
15
+ // Output Assembly Helpers
16
+ // =============================================================================
17
+
18
+ function emptyLatestTurnDetail(): BrowseOutput {
19
+ return {
20
+ before_message_id: null,
21
+ messages: [],
22
+ after_message_id: null,
23
+ };
24
+ }
25
+
26
+ // =============================================================================
27
+ // Charting Data Loading
28
+ // =============================================================================
29
+
30
+ /**
31
+ * Load the structured data used to assemble the chart block for the current session.
32
+ */
33
+ export async function loadChartingData(
34
+ input: PluginInput,
35
+ sessionID: string,
36
+ config: EngramConfig,
37
+ ): Promise<ChartingData> {
38
+ const journal = log(input.client, sessionID);
39
+ const target = await resolveSessionTarget(input.client, sessionID, input.directory);
40
+ const overviewBrowse = createBrowseContext(target, true);
41
+ const overviewState = await loadOverviewState(input, overviewBrowse, config, journal);
42
+ const latestTurn = overviewState.turns.at(-1);
43
+ const minTurn = latestTurn
44
+ ? latestTurn.turn - config.context_charting.recent_turns
45
+ : Number.POSITIVE_INFINITY;
46
+ const overview: OverviewOutput = {
47
+ turns: overviewState.turns
48
+ .filter((turn) => turn.turn >= minTurn)
49
+ .map((turn) => turn.output),
50
+ };
51
+
52
+ if (!latestTurn) {
53
+ return {
54
+ overview,
55
+ latestTurnDetail: emptyLatestTurnDetail(),
56
+ };
57
+ }
58
+
59
+ return {
60
+ overview,
61
+ latestTurnDetail: await browseData(
62
+ input,
63
+ createBrowseContext(target, false),
64
+ config,
65
+ journal,
66
+ {
67
+ messageID: latestTurn.lastVisibleMessageId,
68
+ numBefore: config.context_charting.recent_messages,
69
+ numAfter: 0,
70
+ },
71
+ ),
72
+ };
73
+ }
@@ -0,0 +1,155 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { performance } from "node:perf_hooks";
5
+
6
+ import { json } from "../common/common.ts";
7
+ import type { ResolvedDebugModeConfig } from "../common/config.ts";
8
+
9
+ export const transientSearchErrorMessage =
10
+ "Failed to query messages. Try again or use history_browse_turns to lookup instead.";
11
+
12
+ export type ToolCallRecord = {
13
+ tool: string;
14
+ sessionID: string;
15
+ messageID: string;
16
+ targetSessionID?: string;
17
+ args: Record<string, unknown>;
18
+ output?: unknown;
19
+ error?: string;
20
+ time: string;
21
+ };
22
+
23
+ /**
24
+ * Check if tool call logging is enabled.
25
+ *
26
+ * Respects `enable` as the highest priority:
27
+ * - If `enable` is false, logging is disabled regardless of other settings.
28
+ * - Otherwise, returns the value of `log_tool_calls`.
29
+ */
30
+ export function isToolCallLoggingEnabled(debug: ResolvedDebugModeConfig): boolean {
31
+ if (!debug.enable) {
32
+ return false;
33
+ }
34
+ return debug.log_tool_calls;
35
+ }
36
+
37
+ /**
38
+ * Check if any debug feature is enabled that requires directory setup.
39
+ *
40
+ * This checks if any feature that writes to `.engram/` is active,
41
+ * respecting `enable` as the highest priority.
42
+ */
43
+ export function isDebugDirectoryNeeded(debug: ResolvedDebugModeConfig): boolean {
44
+ if (!debug.enable) {
45
+ return false;
46
+ }
47
+ return debug.log_tool_calls;
48
+ }
49
+
50
+ export function estimateSerializedTokens(value: unknown): number {
51
+ const bytes = Buffer.byteLength(json(value), "utf8");
52
+ return Math.max(1, Math.ceil(bytes / 3));
53
+ }
54
+
55
+ export function estimateCallDurationMs(startedAt: number): number {
56
+ return Math.max(0, Math.round(performance.now() - startedAt));
57
+ }
58
+
59
+ export function getLoggedResponsePayload(record: ToolCallRecord): unknown {
60
+ if (record.output !== undefined) {
61
+ return record.output;
62
+ }
63
+
64
+ if (record.error !== undefined) {
65
+ return { error: record.error };
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ export function formatDebugTimestamp(date = new Date()) {
72
+ const year = String(date.getUTCFullYear() % 100).padStart(2, "0");
73
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
74
+ const day = String(date.getUTCDate()).padStart(2, "0");
75
+ const hour = String(date.getUTCHours()).padStart(2, "0");
76
+ const minute = String(date.getUTCMinutes()).padStart(2, "0");
77
+ const second = String(date.getUTCSeconds()).padStart(2, "0");
78
+ const millisecond = String(date.getUTCMilliseconds()).padStart(3, "0");
79
+ return `${year}${month}${day}_${hour}${minute}${second}${millisecond}`;
80
+ }
81
+
82
+ export function debugFileName(tool: string) {
83
+ return `${formatDebugTimestamp()}-${tool}-${randomUUID().slice(0, 8)}.json`;
84
+ }
85
+
86
+ export function getToolCallLogDirectory(projectRoot: string, sessionID: string) {
87
+ return join(projectRoot, ".engram", "log_tool_calls", sessionID);
88
+ }
89
+
90
+ export async function recordToolCall(
91
+ record: ToolCallRecord,
92
+ enabled: boolean,
93
+ durationMs: number,
94
+ projectRoot: string,
95
+ ) {
96
+ if (!enabled) return;
97
+
98
+ const dir = getToolCallLogDirectory(projectRoot, record.sessionID);
99
+ const filePath = join(dir, debugFileName(record.tool));
100
+
101
+ try {
102
+ const loggedRecord = {
103
+ ...record,
104
+ estimated_tokens: estimateSerializedTokens(getLoggedResponsePayload(record)),
105
+ duration_ms: durationMs,
106
+ };
107
+ await mkdir(dir, { recursive: true });
108
+ await writeFile(filePath, JSON.stringify(loggedRecord, null, 2), "utf8");
109
+ } catch {
110
+ return;
111
+ }
112
+ }
113
+
114
+ export async function ensureDebugGitIgnore(projectRoot: string) {
115
+ const engramDir = join(projectRoot, ".engram");
116
+ const gitIgnorePath = join(engramDir, ".gitignore");
117
+ const requiredEntries = ["log_tool_calls", ".gitignore"];
118
+
119
+ try {
120
+ await mkdir(engramDir, { recursive: true });
121
+
122
+ let existing = "";
123
+ try {
124
+ existing = await readFile(gitIgnorePath, "utf8");
125
+ } catch (error) {
126
+ if (
127
+ !(
128
+ error &&
129
+ typeof error === "object" &&
130
+ "code" in error &&
131
+ error.code === "ENOENT"
132
+ )
133
+ ) {
134
+ return;
135
+ }
136
+ }
137
+
138
+ const existingLines = existing
139
+ .replace(/\r\n/g, "\n")
140
+ .split("\n")
141
+ .map((line) => line.trim())
142
+ .filter((line) => line.length > 0);
143
+ const lineSet = new Set(existingLines);
144
+ const missing = requiredEntries.filter((entry) => !lineSet.has(entry));
145
+
146
+ if (missing.length === 0) {
147
+ return;
148
+ }
149
+
150
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
151
+ await appendFile(gitIgnorePath, `${prefix}${missing.join("\n")}\n`, "utf8");
152
+ } catch {
153
+ // Ignore errors; best effort
154
+ }
155
+ }
@@ -0,0 +1,34 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+
3
+ export type Logger = ReturnType<typeof log>;
4
+
5
+ export function log(client: Parameters<Plugin>[0]["client"], sessionID: string) {
6
+ const write = (
7
+ level: "debug" | "info" | "warn" | "error",
8
+ message: string,
9
+ extra?: Record<string, unknown>,
10
+ ) =>
11
+ client.app
12
+ .log({
13
+ body: {
14
+ service: "engram-plugin",
15
+ level,
16
+ message,
17
+ extra: { sessionID, ...extra },
18
+ },
19
+ })
20
+ .catch(() => undefined);
21
+
22
+ return {
23
+ trace: (message: string, extra?: Record<string, unknown>) =>
24
+ write("debug", message, extra),
25
+ debug: (message: string, extra?: Record<string, unknown>) =>
26
+ write("debug", message, extra),
27
+ info: (message: string, extra?: Record<string, unknown>) =>
28
+ write("info", message, extra),
29
+ warn: (message: string, extra?: Record<string, unknown>) =>
30
+ write("warn", message, extra),
31
+ error: (message: string, extra?: Record<string, unknown>) =>
32
+ write("error", message, extra),
33
+ };
34
+ }