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,386 @@
1
+ /**
2
+ * adapter.ts - SDK Adaptation Layer
3
+ *
4
+ * This module handles the conversion from SDK types to normalized domain types.
5
+ * It extracts facts from SDK structures without deciding presentation logic.
6
+ *
7
+ * Responsibilities:
8
+ * - Type guards for SDK Part union
9
+ * - ToolState status branch extraction
10
+ * - Tool attachments extraction for completed tool states
11
+ * - File path extraction from various source types
12
+ * - SDK Part -> NormalizedPart conversion
13
+ */
14
+
15
+ import type {
16
+ Part,
17
+ TextPart,
18
+ ReasoningPart,
19
+ ToolPart,
20
+ FilePart,
21
+ ToolState,
22
+ ToolStatePending,
23
+ ToolStateRunning,
24
+ ToolStateCompleted,
25
+ ToolStateError,
26
+ } from "@opencode-ai/sdk";
27
+
28
+ import type {
29
+ ToolStatus,
30
+ NormalizedPart,
31
+ NormalizedTextPart,
32
+ NormalizedReasoningPart,
33
+ NormalizedToolPart,
34
+ NormalizedImagePart,
35
+ NormalizedFilePart,
36
+ PreviewFallbackHints,
37
+ } from "./types.ts";
38
+
39
+ // =============================================================================
40
+ // Type Guards
41
+ // =============================================================================
42
+
43
+ export function isTextPart(part: Part): part is TextPart {
44
+ return part.type === "text";
45
+ }
46
+
47
+ export function isReasoningPart(part: Part): part is ReasoningPart {
48
+ return part.type === "reasoning";
49
+ }
50
+
51
+ export function isToolPart(part: Part): part is ToolPart {
52
+ return part.type === "tool";
53
+ }
54
+
55
+ export function isFilePart(part: Part): part is FilePart {
56
+ return part.type === "file";
57
+ }
58
+
59
+ /**
60
+ * Check if a file part represents an image based on MIME type.
61
+ */
62
+ export function isImageFilePart(part: Part): part is FilePart {
63
+ return part.type === "file" && part.mime.startsWith("image/");
64
+ }
65
+
66
+ // =============================================================================
67
+ // ToolState Extraction
68
+ // =============================================================================
69
+
70
+ export interface ToolStateExtract {
71
+ status: ToolStatus;
72
+ title: string | null;
73
+ input: Record<string, unknown>;
74
+ content: string | undefined;
75
+ }
76
+
77
+ function isPending(state: ToolState): state is ToolStatePending {
78
+ return state.status === "pending";
79
+ }
80
+
81
+ function isRunning(state: ToolState): state is ToolStateRunning {
82
+ return state.status === "running";
83
+ }
84
+
85
+ function isCompleted(state: ToolState): state is ToolStateCompleted {
86
+ return state.status === "completed";
87
+ }
88
+
89
+ function isError(state: ToolState): state is ToolStateError {
90
+ return state.status === "error";
91
+ }
92
+
93
+ /**
94
+ * Extract normalized state information from a ToolPart's state.
95
+ *
96
+ * SDK ToolState branches:
97
+ * - pending: no title, no content
98
+ * - running: optional title, no content
99
+ * - completed: required title, output as content
100
+ * - error: no title, error as content
101
+ */
102
+ export function extractToolState(part: ToolPart): ToolStateExtract {
103
+ const state = part.state;
104
+
105
+ if (isCompleted(state)) {
106
+ return {
107
+ status: "completed",
108
+ title: state.title,
109
+ input: state.input,
110
+ content: state.output,
111
+ };
112
+ }
113
+
114
+ if (isError(state)) {
115
+ return {
116
+ status: "error",
117
+ title: null,
118
+ input: state.input,
119
+ content: state.error,
120
+ };
121
+ }
122
+
123
+ if (isRunning(state)) {
124
+ return {
125
+ status: "running",
126
+ title: state.title ?? null,
127
+ input: state.input,
128
+ content: undefined,
129
+ };
130
+ }
131
+
132
+ // pending state
133
+ if (isPending(state)) {
134
+ return {
135
+ status: "pending",
136
+ title: null,
137
+ input: state.input,
138
+ content: undefined,
139
+ };
140
+ }
141
+
142
+ const unexpectedStatus =
143
+ state && typeof state === "object" && "status" in state
144
+ ? String((state as { status: unknown }).status)
145
+ : typeof state;
146
+ throw new Error(`Unsupported tool state '${unexpectedStatus}'`);
147
+ }
148
+
149
+ // =============================================================================
150
+ // File Path Extraction
151
+ // =============================================================================
152
+
153
+ /**
154
+ * Extract the display path from a FilePart.
155
+ *
156
+ * Priority:
157
+ * 1. source.path (for file/symbol sources)
158
+ * 2. filename property
159
+ * 3. normalized url path
160
+ * 4. "unknown-file" fallback
161
+ */
162
+ export function extractFilePath(part: FilePart): string {
163
+ if (part.source) {
164
+ // Both FileSource and SymbolSource have a `path` property
165
+ return part.source.path;
166
+ }
167
+
168
+ if (part.filename) {
169
+ return part.filename;
170
+ }
171
+
172
+ if (part.url) {
173
+ return normalizeUrlPath(part.url);
174
+ }
175
+
176
+ return "unknown-file";
177
+ }
178
+
179
+ function normalizeUrlPath(value: string): string {
180
+ const input = value.trim();
181
+ if (!input) {
182
+ return "unknown-file";
183
+ }
184
+
185
+ try {
186
+ const parsed = new URL(input);
187
+ const pathname = decodeURIComponent(parsed.pathname);
188
+ if (!pathname) {
189
+ return input;
190
+ }
191
+
192
+ if (/^\/[A-Za-z]:[\\/]/.test(pathname)) {
193
+ return pathname.slice(1);
194
+ }
195
+
196
+ return pathname;
197
+ } catch {
198
+ return input;
199
+ }
200
+ }
201
+
202
+ // =============================================================================
203
+ // Part Normalization
204
+ // =============================================================================
205
+
206
+ function normalizeTextPart(part: TextPart): NormalizedTextPart {
207
+ return {
208
+ type: "text",
209
+ partId: part.id,
210
+ messageId: part.messageID,
211
+ text: part.text,
212
+ ignored: part.ignored === true,
213
+ };
214
+ }
215
+
216
+ function normalizeReasoningPart(part: ReasoningPart): NormalizedReasoningPart {
217
+ return {
218
+ type: "reasoning",
219
+ partId: part.id,
220
+ messageId: part.messageID,
221
+ text: part.text,
222
+ };
223
+ }
224
+
225
+ function normalizeToolPart(part: ToolPart): NormalizedToolPart {
226
+ const extract = extractToolState(part);
227
+ return {
228
+ type: "tool",
229
+ partId: part.id,
230
+ messageId: part.messageID,
231
+ tool: part.tool,
232
+ title: extract.title,
233
+ status: extract.status,
234
+ input: extract.input,
235
+ content: extract.content,
236
+ };
237
+ }
238
+
239
+ function toolAttachmentPartId(
240
+ toolPartId: string,
241
+ attachmentId: string,
242
+ index: number,
243
+ ): string {
244
+ const suffix = attachmentId.trim() ? attachmentId : String(index);
245
+ return `${toolPartId}#attachment-${suffix}`;
246
+ }
247
+
248
+ function normalizeToolAttachment(
249
+ toolPart: ToolPart,
250
+ attachment: FilePart,
251
+ index: number,
252
+ ): NormalizedImagePart | NormalizedFilePart {
253
+ const partId = toolAttachmentPartId(toolPart.id, attachment.id, index);
254
+ if (attachment.mime.startsWith("image/")) {
255
+ return {
256
+ type: "image",
257
+ partId,
258
+ messageId: toolPart.messageID,
259
+ mime: attachment.mime,
260
+ };
261
+ }
262
+
263
+ return {
264
+ type: "file",
265
+ partId,
266
+ messageId: toolPart.messageID,
267
+ path: extractFilePath(attachment),
268
+ mime: attachment.mime,
269
+ };
270
+ }
271
+
272
+ function normalizeToolAttachments(part: ToolPart): Array<NormalizedImagePart | NormalizedFilePart> {
273
+ const state = part.state;
274
+ if (!isCompleted(state) || !state.attachments || state.attachments.length === 0) {
275
+ return [];
276
+ }
277
+
278
+ return state.attachments.map((attachment, index) =>
279
+ normalizeToolAttachment(part, attachment, index),
280
+ );
281
+ }
282
+
283
+ function normalizeImagePart(part: FilePart): NormalizedImagePart {
284
+ return {
285
+ type: "image",
286
+ partId: part.id,
287
+ messageId: part.messageID,
288
+ mime: part.mime,
289
+ };
290
+ }
291
+
292
+ function normalizeFilePart(part: FilePart): NormalizedFilePart {
293
+ return {
294
+ type: "file",
295
+ partId: part.id,
296
+ messageId: part.messageID,
297
+ path: extractFilePath(part),
298
+ mime: part.mime,
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Convert an SDK Part to a NormalizedPart.
304
+ *
305
+ * Returns null for part types that should not be included in output
306
+ * (e.g., step-start, step-finish, snapshot, patch, agent, subtask, retry, other internal parts).
307
+ */
308
+ export function normalizePart(part: Part): NormalizedPart | null {
309
+ if (isTextPart(part)) {
310
+ return normalizeTextPart(part);
311
+ }
312
+
313
+ if (isReasoningPart(part)) {
314
+ return normalizeReasoningPart(part);
315
+ }
316
+
317
+ if (isToolPart(part)) {
318
+ return normalizeToolPart(part);
319
+ }
320
+
321
+ if (isFilePart(part)) {
322
+ // Distinguish image files from other files
323
+ if (isImageFilePart(part)) {
324
+ return normalizeImagePart(part);
325
+ }
326
+ return normalizeFilePart(part);
327
+ }
328
+
329
+ // Ignore other unsupported internal part types.
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * Normalize all parts from a message, filtering out unsupported types.
335
+ */
336
+ export function normalizeParts(parts: Part[]): NormalizedPart[] {
337
+ const result: NormalizedPart[] = [];
338
+ for (const part of parts) {
339
+ if (isToolPart(part)) {
340
+ result.push(normalizeToolPart(part));
341
+ result.push(...normalizeToolAttachments(part));
342
+ continue;
343
+ }
344
+
345
+ const normalized = normalizePart(part);
346
+ if (normalized !== null) {
347
+ result.push(normalized);
348
+ }
349
+ }
350
+ return result;
351
+ }
352
+
353
+ /**
354
+ * Summarize raw part kinds that may need semantic preview fallbacks.
355
+ */
356
+ export function summarizePreviewFallbackHints(parts: Part[]): PreviewFallbackHints {
357
+ const hints: PreviewFallbackHints = {
358
+ hasCompaction: false,
359
+ hasSubtask: false,
360
+ hasUnsupported: false,
361
+ };
362
+
363
+ for (const part of parts) {
364
+ switch (part.type) {
365
+ case "compaction":
366
+ hints.hasCompaction = true;
367
+ break;
368
+
369
+ case "subtask":
370
+ hints.hasSubtask = true;
371
+ break;
372
+
373
+ case "text":
374
+ case "reasoning":
375
+ case "tool":
376
+ case "file":
377
+ break;
378
+
379
+ default:
380
+ hints.hasUnsupported = true;
381
+ break;
382
+ }
383
+ }
384
+
385
+ return hints;
386
+ }
@@ -0,0 +1,86 @@
1
+ const sentenceSegmenter = new Intl.Segmenter(undefined, { granularity: "sentence" });
2
+
3
+ function normalizeBody(text: string) {
4
+ return text.trim();
5
+ }
6
+
7
+ function finalizeBoundary(text: string, end: number) {
8
+ return Math.min(text.length, text.slice(0, end).trimEnd().length);
9
+ }
10
+
11
+ export function findLastSentenceBoundary(text: string, maxPos: number, minPos: number) {
12
+ let lastBoundary: number | undefined;
13
+
14
+ for (const segment of sentenceSegmenter.segment(text)) {
15
+ const boundary = finalizeBoundary(text, segment.index + segment.segment.length);
16
+ if (boundary < minPos) {
17
+ continue;
18
+ }
19
+ if (boundary > maxPos) {
20
+ break;
21
+ }
22
+ lastBoundary = boundary;
23
+ }
24
+
25
+ return lastBoundary;
26
+ }
27
+
28
+ export function findLastWordBreak(text: string, maxPos: number, minPos: number) {
29
+ const boundary = text.lastIndexOf(" ", maxPos - 1);
30
+ if (boundary < minPos) {
31
+ return undefined;
32
+ }
33
+ return finalizeBoundary(text, boundary);
34
+ }
35
+
36
+ function findClipBoundary(text: string, maxPos: number, minSentencePos: number, minWordPos: number) {
37
+ const sentenceBoundary = findLastSentenceBoundary(text, maxPos, minSentencePos);
38
+ if (sentenceBoundary !== undefined) {
39
+ return sentenceBoundary;
40
+ }
41
+
42
+ const wordBoundary = findLastWordBreak(text, maxPos, minWordPos);
43
+ if (wordBoundary !== undefined) {
44
+ return wordBoundary;
45
+ }
46
+
47
+ return maxPos;
48
+ }
49
+
50
+ /**
51
+ * Clip text to a maximum length while preserving a truncation marker.
52
+ */
53
+ export function clipText(text: string, size: number) {
54
+ const body = normalizeBody(text);
55
+ if (body.length <= size) {
56
+ return body;
57
+ }
58
+
59
+ const boundary = findClipBoundary(
60
+ body,
61
+ size,
62
+ Math.floor(size * 0.5),
63
+ Math.floor(size * 0.7),
64
+ );
65
+
66
+ return `${body.slice(0, boundary)}\n[${body.length - boundary} chars more]`;
67
+ }
68
+
69
+ /**
70
+ * Clip preview text to a maximum length using sentence-aware boundaries.
71
+ */
72
+ export function clipPreviewText(text: string, maxLength: number) {
73
+ const body = normalizeBody(text);
74
+ if (body.length <= maxLength) {
75
+ return body;
76
+ }
77
+
78
+ const boundary = findClipBoundary(
79
+ body,
80
+ maxLength,
81
+ Math.floor(maxLength * 0.5),
82
+ Math.floor(maxLength * 0.7),
83
+ );
84
+
85
+ return `${body.slice(0, boundary)}...`;
86
+ }