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,132 @@
1
+ import { clipPreviewText } from "./clip-text.ts";
2
+ import { computeToolCalls } from "./domain.ts";
3
+
4
+ import type {
5
+ NormalizedMessage,
6
+ NormalizedPart,
7
+ PreviewFallbackHints,
8
+ } from "./types.ts";
9
+
10
+ function clipPreviewTextInfo(text: string, maxLength: number) {
11
+ const body = text.replace(/\r?\n/g, " ").trim();
12
+ return {
13
+ preview: clipPreviewText(body, maxLength),
14
+ };
15
+ }
16
+
17
+ /**
18
+ * Compute a preview string from normalized parts.
19
+ *
20
+ * Returns the first visible text part as a single-line clipped preview.
21
+ */
22
+ export function computePreview(
23
+ parts: NormalizedPart[],
24
+ maxLength: number,
25
+ ): string | undefined {
26
+ for (const part of parts) {
27
+ if (part.type === "text" && !part.ignored && part.text.trim()) {
28
+ return clipPreviewTextInfo(part.text, maxLength).preview;
29
+ }
30
+ }
31
+
32
+ return undefined;
33
+ }
34
+
35
+ /**
36
+ * Compute a preview string from the LAST visible text part.
37
+ *
38
+ * Used for assistant overview previews where the conclusion (last text)
39
+ * has higher orientation value than the intent (first text).
40
+ */
41
+ export function computeLastPreview(
42
+ parts: NormalizedPart[],
43
+ maxLength: number,
44
+ ): string | undefined {
45
+ for (let i = parts.length - 1; i >= 0; i--) {
46
+ const part = parts[i]!;
47
+ if (part.type === "text" && !part.ignored && part.text.trim()) {
48
+ return clipPreviewTextInfo(part.text, maxLength).preview;
49
+ }
50
+ }
51
+
52
+ return undefined;
53
+ }
54
+
55
+ function buildToolFallbackLabel(parts: NormalizedPart[]) {
56
+ if (computeToolCalls(parts).length === 0) {
57
+ return undefined;
58
+ }
59
+
60
+ const hasReasoning = parts.some((part) => part.type === "reasoning");
61
+ const hasAttachments = parts.some((part) => part.type === "image" || part.type === "file");
62
+
63
+ if (!hasReasoning && !hasAttachments) {
64
+ return "[tool calls only]";
65
+ }
66
+
67
+ let prefix = "[tool calls";
68
+
69
+ if (hasReasoning) {
70
+ prefix += " + reasoning";
71
+ }
72
+
73
+ if (hasAttachments) {
74
+ prefix += " + attachments";
75
+ }
76
+
77
+ return `${prefix}]`;
78
+ }
79
+
80
+ /**
81
+ * Compute a bracketed semantic fallback when no visible text exists.
82
+ *
83
+ * Higher priority means the fallback is more specific and should win when a turn
84
+ * needs to choose among multiple non-text messages for the same role.
85
+ */
86
+ export function computePreviewFallback(
87
+ msg: NormalizedMessage,
88
+ parts: NormalizedPart[],
89
+ hints: PreviewFallbackHints,
90
+ maxLength: number,
91
+ ): { preview: string; priority: number } | undefined {
92
+ function fallback(preview: string, priority: number) {
93
+ return {
94
+ preview: clipPreviewTextInfo(preview, maxLength).preview,
95
+ priority,
96
+ };
97
+ }
98
+
99
+ if (msg.role === "assistant" && msg.summary === true) {
100
+ return fallback("[compacted summary]", 60);
101
+ }
102
+
103
+ if (hints.hasCompaction) {
104
+ return fallback("[compaction trigger]", 50);
105
+ }
106
+
107
+ const toolLabel = buildToolFallbackLabel(parts);
108
+ if (toolLabel !== undefined) {
109
+ return fallback(toolLabel, 40);
110
+ }
111
+
112
+ if (hints.hasSubtask) {
113
+ return fallback("[subtask request]", 35);
114
+ }
115
+
116
+ const hasReasoning = parts.some((part) => part.type === "reasoning");
117
+ const hasAttachments = parts.some((part) => part.type === "image" || part.type === "file");
118
+
119
+ if (hasReasoning && hasAttachments) {
120
+ return fallback("[reasoning + attachments]", 30);
121
+ }
122
+
123
+ if (hasReasoning) {
124
+ return fallback("[reasoning only]", 20);
125
+ }
126
+
127
+ if (hasAttachments) {
128
+ return fallback("[attachments only]", 15);
129
+ }
130
+
131
+ return undefined;
132
+ }
@@ -0,0 +1,409 @@
1
+ /**
2
+ * serialize.ts - Response Serialization Layer
3
+ *
4
+ * This module handles the conversion from domain models to final JSON response structures.
5
+ * It is responsible for:
6
+ * - Field naming mappings (camelCase -> snake_case / template field names)
7
+ * - Field omission rules (empty arrays, null values)
8
+ * - Output contract enforcement
9
+ */
10
+
11
+ import type {
12
+ AnyMessageMeta,
13
+ BrowseItemOutput,
14
+ BrowseOutput,
15
+ BrowseUserItemOutput,
16
+ BrowseAssistantItemOutput,
17
+ OverviewTurnOutput,
18
+ OverviewOutput,
19
+ Section,
20
+ SectionOutput,
21
+ ReadablePartType,
22
+ SearchPartType,
23
+ SearchHitOutput,
24
+ SearchMessageOutput,
25
+ SearchOutput,
26
+ } from "./types.ts";
27
+
28
+ import {
29
+ formatToolCallSummaries,
30
+ } from "./domain.ts";
31
+
32
+ // =============================================================================
33
+ // Browse Item Serialization
34
+ // =============================================================================
35
+
36
+ /**
37
+ * Serialize a user message metadata into a browse item output.
38
+ */
39
+ function serializeUserBrowseItem(
40
+ meta: AnyMessageMeta & { role: "user" },
41
+ preview: string | undefined,
42
+ ): BrowseUserItemOutput {
43
+ const result: BrowseUserItemOutput = {
44
+ role: "user",
45
+ turn_index: meta.turn,
46
+ message_id: meta.id,
47
+ };
48
+
49
+ // preview: omit only when no text or semantic fallback is available
50
+ if (preview !== undefined) {
51
+ result.preview = preview;
52
+ }
53
+
54
+ // attachment: omit if empty
55
+ if (meta.attachments.length > 0) {
56
+ result.attachment = meta.attachments;
57
+ }
58
+
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Serialize an assistant message metadata into a browse item output.
64
+ */
65
+ function serializeAssistantBrowseItem(
66
+ meta: AnyMessageMeta & { role: "assistant" },
67
+ preview: string | undefined,
68
+ ): BrowseAssistantItemOutput {
69
+ const result: BrowseAssistantItemOutput = {
70
+ role: "assistant",
71
+ turn_index: meta.turn,
72
+ message_id: meta.id,
73
+ };
74
+
75
+ // preview: omit only when no text or semantic fallback is available
76
+ if (preview !== undefined) {
77
+ result.preview = preview;
78
+ }
79
+
80
+ // tool: include when there are tool calls
81
+ if (meta.toolCalls.length > 0) {
82
+ result.tool = {
83
+ calls: formatToolCallSummaries(meta.toolCalls),
84
+ outcome: meta.toolOutcome ?? "completed",
85
+ };
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * Serialize message metadata into a browse item output.
93
+ *
94
+ * @param meta Message metadata (user or assistant)
95
+ * @param preview Preview string or undefined
96
+ * @returns Browse item output with proper field omissions
97
+ */
98
+ export function serializeBrowseItem(
99
+ meta: AnyMessageMeta,
100
+ preview: string | undefined,
101
+ ): BrowseItemOutput {
102
+ if (meta.role === "user") {
103
+ return serializeUserBrowseItem(meta, preview);
104
+ }
105
+ return serializeAssistantBrowseItem(meta, preview);
106
+ }
107
+
108
+ /**
109
+ * Serialize a turn summary for overview output.
110
+ *
111
+ * @param turnIndex Turn number
112
+ * @param user Serialized user summary for this turn, or null if hidden/absent
113
+ * @param assistant Serialized assistant summary for this turn, or null if absent
114
+ * @returns Serialized turn output
115
+ */
116
+ export function serializeOverviewTurn(
117
+ turnIndex: number,
118
+ user: OverviewTurnOutput["user"],
119
+ assistant: OverviewTurnOutput["assistant"],
120
+ ): OverviewTurnOutput {
121
+ return {
122
+ turn_index: turnIndex,
123
+ user,
124
+ assistant,
125
+ };
126
+ }
127
+
128
+ // =============================================================================
129
+ // Overview Tool Serialization
130
+ // =============================================================================
131
+
132
+ /**
133
+ * Serialize a full overview response for the history_browse_turns tool.
134
+ *
135
+ * @param turns Turn summaries in ascending turn order
136
+ * @returns Complete overview output object
137
+ */
138
+ export function serializeOverview(
139
+ turns: OverviewTurnOutput[],
140
+ ): OverviewOutput {
141
+ return {
142
+ turns,
143
+ };
144
+ }
145
+
146
+ // =============================================================================
147
+ // Full Browse Serialization
148
+ // =============================================================================
149
+
150
+ /**
151
+ * Serialize a full browse response.
152
+ *
153
+ * @param beforeMessageID message_id of the visible message immediately before this window
154
+ * @param messages Array of browse items
155
+ * @param afterMessageID message_id of the visible message immediately after this window
156
+ * @returns Complete browse output object
157
+ */
158
+ export function serializeBrowse(
159
+ beforeMessageID: string | null | undefined,
160
+ messages: BrowseItemOutput[],
161
+ afterMessageID: string | null | undefined,
162
+ includeBefore = true,
163
+ includeAfter = true,
164
+ ): BrowseOutput {
165
+ const result: BrowseOutput = {
166
+ messages,
167
+ };
168
+
169
+ if (includeBefore && beforeMessageID !== undefined) {
170
+ result.before_message_id = beforeMessageID;
171
+ }
172
+
173
+ if (includeAfter && afterMessageID !== undefined) {
174
+ result.after_message_id = afterMessageID;
175
+ }
176
+
177
+ return result;
178
+ }
179
+
180
+ // =============================================================================
181
+ // Message Read Serialization
182
+ // =============================================================================
183
+
184
+ /**
185
+ * Serialize a section to output format.
186
+ *
187
+ * Rules:
188
+ * - `part_id` only appears when section is truncated (text/reasoning/tool)
189
+ * - `tool.title` is omitted when unavailable
190
+ * - `tool.content` is omitted when hidden or unavailable
191
+ * - image/file sections have no `part_id` or `content`
192
+ */
193
+ function emptyToNull(value: string | null | undefined) {
194
+ if (value === undefined) {
195
+ return undefined;
196
+ }
197
+ if (value === null || value.length === 0) {
198
+ return null;
199
+ }
200
+ return value;
201
+ }
202
+
203
+ function serializeSection(section: Section): SectionOutput {
204
+
205
+ switch (section.type) {
206
+ case "text": {
207
+ const output: Extract<SectionOutput, { type: "text" }> = {
208
+ type: "text",
209
+ content: emptyToNull(section.content) ?? null,
210
+ };
211
+ if (section.truncated) {
212
+ output.part_id = section.partId;
213
+ }
214
+ return output;
215
+ }
216
+
217
+ case "reasoning": {
218
+ const output: Extract<SectionOutput, { type: "reasoning" }> = {
219
+ type: "reasoning",
220
+ content: emptyToNull(section.content) ?? null,
221
+ };
222
+ if (section.truncated) {
223
+ output.part_id = section.partId;
224
+ }
225
+ return output;
226
+ }
227
+
228
+ case "tool": {
229
+ const output: Extract<SectionOutput, { type: "tool" }> = {
230
+ type: "tool",
231
+ tool: section.tool,
232
+ status: section.status,
233
+ };
234
+ if (section.input !== undefined) {
235
+ output.input = section.input;
236
+ }
237
+ if (section.content === undefined) {
238
+ // omit content
239
+ } else {
240
+ output.content = emptyToNull(section.content) ?? null;
241
+ }
242
+ // part_id only when truncated
243
+ if (section.truncated) {
244
+ output.part_id = section.partId;
245
+ }
246
+ return output;
247
+ }
248
+
249
+ case "image": {
250
+ return {
251
+ type: "image",
252
+ mime: section.mime,
253
+ };
254
+ }
255
+
256
+ case "file": {
257
+ return {
258
+ type: "file",
259
+ path: section.path,
260
+ mime: section.mime,
261
+ };
262
+ }
263
+ }
264
+ }
265
+
266
+ function toIsoTime(value: unknown): string {
267
+ if (typeof value !== "number" || !Number.isFinite(value)) {
268
+ return "unknown";
269
+ }
270
+
271
+ const date = new Date(value);
272
+ if (!Number.isFinite(date.getTime())) {
273
+ return "unknown";
274
+ }
275
+
276
+ return date.toISOString();
277
+ }
278
+
279
+ /**
280
+ * Serialize a message detail for read response.
281
+ *
282
+ * @param meta Message metadata
283
+ * @param sections Array of sections
284
+ * @returns MessageReadOutput as Record for JSON serialization
285
+ */
286
+ export function serializeMessageRead(
287
+ meta: AnyMessageMeta,
288
+ sections: Section[],
289
+ ): Record<string, unknown> {
290
+ const result: Record<string, unknown> = {
291
+ message_id: meta.id,
292
+ role: meta.role,
293
+ turn_index: meta.turn,
294
+ time: toIsoTime(meta.time),
295
+ sections: sections.map((section) => serializeSection(section)),
296
+ };
297
+
298
+ return result;
299
+ }
300
+
301
+ // =============================================================================
302
+ // Section Read Serialization
303
+ // =============================================================================
304
+
305
+ /**
306
+ * Serialize a part read response for full content retrieval.
307
+ *
308
+ * @param type Part type
309
+ * @param content Full content (not truncated)
310
+ * @returns PartReadOutput as Record for JSON serialization
311
+ */
312
+ export function serializePartRead(
313
+ type: ReadablePartType,
314
+ content: string | undefined,
315
+ ): Record<string, unknown> {
316
+ const result: Record<string, unknown> = {
317
+ type,
318
+ };
319
+
320
+ if (content !== undefined) {
321
+ result.content = content;
322
+ }
323
+
324
+ return result;
325
+ }
326
+
327
+ // =============================================================================
328
+ // Search Serialization (Phase 1: Contracts Only)
329
+ // =============================================================================
330
+
331
+ /**
332
+ * Serialize a single search hit.
333
+ *
334
+ * @param type Part type that matched
335
+ * @param partId Part identifier
336
+ * @param snippets Array of snippet strings
337
+ * @param toolName Optional tool name (only for tool hits)
338
+ * @returns Serialized search hit output
339
+ */
340
+ export function serializeSearchHit(
341
+ type: SearchPartType,
342
+ partId: string,
343
+ snippets: string[],
344
+ toolName?: string,
345
+ ): SearchHitOutput {
346
+ const result: SearchHitOutput = {
347
+ type,
348
+ part_id: partId,
349
+ snippets,
350
+ };
351
+
352
+ // tool_name only present for tool hits
353
+ if (toolName !== undefined) {
354
+ result.tool_name = toolName;
355
+ }
356
+
357
+ return result;
358
+ }
359
+
360
+ /**
361
+ * Serialize a search message result (grouped hits by message).
362
+ *
363
+ * @param messageId Message identifier
364
+ * @param role Message role
365
+ * @param turnIndex Turn number
366
+ * @param hits Array of serialized hits
367
+ * @param remainHits Number of omitted low-priority hits
368
+ * @returns Serialized search message output
369
+ */
370
+ export function serializeSearchMessage(
371
+ messageId: string,
372
+ role: "user" | "assistant",
373
+ turnIndex: number,
374
+ hits: SearchHitOutput[],
375
+ remainHits: number,
376
+ ): SearchMessageOutput {
377
+ const result: SearchMessageOutput = {
378
+ role,
379
+ turn_index: turnIndex,
380
+ message_id: messageId,
381
+ hits,
382
+ };
383
+
384
+ if (remainHits > 0) {
385
+ result.remain_hits = remainHits;
386
+ }
387
+
388
+ return result;
389
+ }
390
+
391
+ /**
392
+ * Serialize a full search response.
393
+ *
394
+ * @param messages Array of message results (omit when there are no hits)
395
+ * @returns Complete search output object
396
+ */
397
+ export function serializeSearch(
398
+ messages: SearchMessageOutput[] | undefined,
399
+ ): SearchOutput {
400
+ if (!messages || messages.length === 0) {
401
+ return {};
402
+ }
403
+
404
+ const result: SearchOutput = {
405
+ messages,
406
+ };
407
+
408
+ return result;
409
+ }