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,224 @@
1
+ import type { Message, Part } from "@opencode-ai/sdk";
2
+
3
+ import type { PluginInput } from "../common/common.ts";
4
+ import type { NormalizedMessage } from "../domain/types.ts";
5
+
6
+ export const defaultCount = 20;
7
+ export const internalScanPageSize = 100;
8
+
9
+ /**
10
+ * MessageBundle from SDK API response.
11
+ */
12
+ export type MessageBundle = {
13
+ info: Message;
14
+ parts: Part[];
15
+ };
16
+
17
+ /**
18
+ * A page of messages from getMessagePage.
19
+ */
20
+ export type MessagePage = {
21
+ msgs: MessageBundle[];
22
+ next_cursor: string | undefined;
23
+ };
24
+
25
+ export function normalizeCursor(cursor?: string) {
26
+ const value = cursor?.trim();
27
+ if (!value) return undefined;
28
+ return value;
29
+ }
30
+
31
+ export function messageLimit(count: number | undefined, maxMessages: number) {
32
+ const normalized = count === undefined ? defaultCount : count;
33
+ if (!Number.isInteger(normalized) || normalized < 1) {
34
+ throw new Error("limit must be a positive integer");
35
+ }
36
+ return Math.min(normalized, maxMessages);
37
+ }
38
+
39
+ /**
40
+ * Convert an SDK Message to a NormalizedMessage for domain layer consumption.
41
+ */
42
+ export function toNormalizedMessage(msg: Message): NormalizedMessage {
43
+ return {
44
+ id: msg.id,
45
+ role: msg.role as "user" | "assistant",
46
+ time: msg.time.created,
47
+ summary: msg.summary === true,
48
+ };
49
+ }
50
+
51
+ export function sortMessagesChronological(msgs: MessageBundle[]) {
52
+ return msgs
53
+ .map((msg, index) => ({ msg, index }))
54
+ .sort((left, right) => {
55
+ const leftTime = left.msg.info.time.created ?? Number.POSITIVE_INFINITY;
56
+ const rightTime = right.msg.info.time.created ?? Number.POSITIVE_INFINITY;
57
+ const timeDiff = leftTime - rightTime;
58
+ if (timeDiff !== 0) {
59
+ return timeDiff;
60
+ }
61
+
62
+ if (left.msg.info.role !== right.msg.info.role) {
63
+ return left.msg.info.role === "user" ? -1 : 1;
64
+ }
65
+
66
+ const idDiff = left.msg.info.id.localeCompare(right.msg.info.id);
67
+ if (idDiff !== 0) {
68
+ return idDiff;
69
+ }
70
+
71
+ return left.index - right.index;
72
+ })
73
+ .map((entry) => entry.msg);
74
+ }
75
+
76
+ export function sortMessagesNewestFirst(msgs: MessageBundle[]) {
77
+ return msgs
78
+ .map((msg, index) => ({ msg, index }))
79
+ .sort((left, right) => {
80
+ const leftTime = left.msg.info.time.created;
81
+ const rightTime = right.msg.info.time.created;
82
+
83
+ // Undefined time should always be placed at the end,
84
+ // even when sorting newest-first.
85
+ if (leftTime === undefined || rightTime === undefined) {
86
+ if (leftTime === undefined && rightTime === undefined) {
87
+ return left.index - right.index;
88
+ }
89
+ return leftTime === undefined ? 1 : -1;
90
+ }
91
+
92
+ const timeDiff = rightTime - leftTime;
93
+ if (timeDiff !== 0) {
94
+ return timeDiff;
95
+ }
96
+
97
+ // Preserve original relative order for equal timestamps.
98
+ return left.index - right.index;
99
+ })
100
+ .map((entry) => entry.msg);
101
+ }
102
+
103
+ export async function getMessagePage(
104
+ input: PluginInput,
105
+ sessionID: string,
106
+ limit: number,
107
+ cursor?: string,
108
+ ) {
109
+ const result = await input.client.session.messages({
110
+ path: { id: sessionID },
111
+ query: {
112
+ limit,
113
+ ...(cursor ? { before: cursor } : {}),
114
+ },
115
+ throwOnError: false,
116
+ });
117
+
118
+ const status = result.response?.status ?? 0;
119
+ if (result.error || status >= 400 || !result.data) {
120
+ if (cursor && status === 400) {
121
+ throw new Error(`Message '${cursor}' not found in history. It may be an invalid message_id.`);
122
+ }
123
+ throw new Error("Failed to read session messages. This may be a temporary issue — try again.");
124
+ }
125
+
126
+ const rawCursor = result.response.headers.get("x-next-cursor");
127
+ // Normalize empty string to undefined (no more pages)
128
+ const nextCursor = rawCursor && rawCursor.trim() ? rawCursor : undefined;
129
+
130
+ return {
131
+ msgs: result.data,
132
+ next_cursor: nextCursor,
133
+ };
134
+ }
135
+
136
+ export async function getMessage(
137
+ input: PluginInput,
138
+ sessionID: string,
139
+ messageID: string,
140
+ ) {
141
+ const result = await input.client.session.message({
142
+ path: {
143
+ id: sessionID,
144
+ messageID,
145
+ },
146
+ throwOnError: false,
147
+ });
148
+
149
+ const status = result.response?.status ?? 0;
150
+ if (status === 404) {
151
+ throw new Error("Requested message not found. Please ensure the message_id is correct.");
152
+ }
153
+ if (status >= 500 || status === 0) {
154
+ // 0 typically means network/transport error
155
+ throw new Error(`Failed to read message. This may be a temporary issue — try again.`);
156
+ }
157
+ if (status === 401 || status === 403) {
158
+ throw new Error(`Not authorized to read this message. Please check your permissions.`);
159
+ }
160
+ if (status >= 400) {
161
+ throw new Error(`Invalid request (status ${status}). Please check your parameters.`);
162
+ }
163
+ if (result.error) {
164
+ // SDK returned an error without status (e.g., network error)
165
+ throw new Error(`Failed to read message. This may be a temporary issue — try again.`);
166
+ }
167
+ if (!result.data) {
168
+ throw new Error("Failed to read message. This may be a temporary issue — try again.");
169
+ }
170
+
171
+ return result.data;
172
+ }
173
+
174
+ /**
175
+ * Fetch all messages from the upstream history.
176
+ * Optionally accepts a seed page to avoid re-fetching the first page.
177
+ */
178
+ export async function getAllMessages(
179
+ input: PluginInput,
180
+ sessionID: string,
181
+ pageSize: number,
182
+ seedPage?: MessagePage,
183
+ ) {
184
+ const messages: MessageBundle[] = [];
185
+ const seenCursors = new Set<string>();
186
+ let cursor: string | undefined;
187
+
188
+ // If seedPage is provided, use it as the first page
189
+ if (seedPage) {
190
+ messages.push(...seedPage.msgs);
191
+ cursor = seedPage.next_cursor;
192
+ // If no more pages, return early
193
+ if (!cursor) {
194
+ return messages;
195
+ }
196
+ seenCursors.add(cursor);
197
+ }
198
+
199
+ while (true) {
200
+ const page = await getMessagePage(input, sessionID, pageSize, cursor);
201
+ messages.push(...page.msgs);
202
+
203
+ if (!page.next_cursor) {
204
+ break;
205
+ }
206
+
207
+ if (seenCursors.has(page.next_cursor)) {
208
+ input.client.app.log({
209
+ body: {
210
+ service: "engram-plugin",
211
+ level: "error",
212
+ message: "Internal error: paging cursor repeated in getAllMessages",
213
+ extra: { sessionID, repeatedCursor: page.next_cursor },
214
+ },
215
+ }).catch(() => undefined);
216
+ throw new Error("Internal error (do not retry).");
217
+ }
218
+
219
+ seenCursors.add(page.next_cursor);
220
+ cursor = page.next_cursor;
221
+ }
222
+
223
+ return messages;
224
+ }