opencode-episodic-memory 0.2.0 → 0.3.1

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.
package/src/format.ts CHANGED
@@ -2,7 +2,13 @@
2
2
  // parsing, date formatting, transcript→markdown, and search-hit formatting live
3
3
  // here so the two front-ends can't drift apart.
4
4
  import type { SourceMessage } from "./reader";
5
- import type { SearchHit } from "./store";
5
+ import type { IndexedWindowRow, SearchHit } from "./store";
6
+ import { Buffer } from "node:buffer";
7
+
8
+ const MAX_CONTEXT_BODY_BYTES = 600;
9
+ const MAX_CONTEXT_TOOLS_BYTES = 200;
10
+ const MAX_CONTEXT_FIELD_BYTES = 100;
11
+ const MAX_CONTEXT_SESSION_FIELD_BYTES = 300;
6
12
 
7
13
  // Discriminated result so callers handle the parse error explicitly (no cast to
8
14
  // strip the error arm off a union). `ms` is undefined when no date was given.
@@ -55,6 +61,60 @@ export function renderTranscript(
55
61
  return lines.join("\n");
56
62
  }
57
63
 
64
+ // Render a bounded live-source window around a search-hit anchor. The helper in
65
+ // reader.ts has already applied the privacy gate and validated the anchor.
66
+ export function renderTranscriptContext(
67
+ meta: { title: string; time_created: number; directory: string; id: string },
68
+ context: { messages: SourceMessage[]; anchorIndex: number; sliceStart: number; total: number }
69
+ ): string {
70
+ const lines = [
71
+ `# ${truncateContext(meta.title, MAX_CONTEXT_SESSION_FIELD_BYTES)}`,
72
+ `${fmtDate(meta.time_created)} — ${truncateContext(meta.directory, MAX_CONTEXT_SESSION_FIELD_BYTES)} — ${truncateContext(meta.id, MAX_CONTEXT_FIELD_BYTES)}`,
73
+ `Context around message ${context.anchorIndex + 1}/${context.total}`,
74
+ "",
75
+ ];
76
+ for (const [offset, message] of context.messages.entries()) {
77
+ const position = context.sliceStart + offset;
78
+ const text = message.parts.filter((part) => part.type === "text" && part.text).map((part) => part.text).join("\n");
79
+ const tools = message.parts.filter((part) => part.type === "tool" && part.tool).map((part) => part.tool);
80
+ lines.push(`## ${truncateContext(message.role, MAX_CONTEXT_FIELD_BYTES)} — ${truncateContext(message.id, MAX_CONTEXT_FIELD_BYTES)} — ${position + 1}/${context.total}${position === context.anchorIndex ? " (anchor)" : ""}`);
81
+ if (text) lines.push(truncateContext(text, MAX_CONTEXT_BODY_BYTES));
82
+ if (tools.length) lines.push(truncateContext(`*(tools: ${tools.join(", ")})*`, MAX_CONTEXT_TOOLS_BYTES));
83
+ if (message.contextPartsOmitted) lines.push(`*(${message.contextPartsOmitted} parts omitted from bounded context)*`);
84
+ lines.push("");
85
+ }
86
+ return lines.join("\n");
87
+ }
88
+
89
+ export function renderIndexedContext(sessionId: string, sourceId: string, anchorMessageId: string, rows: IndexedWindowRow[]): string {
90
+ const lines = [
91
+ "# Indexed excerpts (not a live transcript)",
92
+ `source: ${truncateContext(sourceId, MAX_CONTEXT_FIELD_BYTES)} session: ${truncateContext(sessionId, MAX_CONTEXT_FIELD_BYTES)}`,
93
+ "Window bounds count condensed exchange chunks, not messages. Content may be stale until the source syncs.",
94
+ "",
95
+ ];
96
+ for (const row of rows) {
97
+ lines.push(`## Chunk ${row.seq}${row.anchor_message_id === anchorMessageId ? " (anchor)" : ""}`);
98
+ lines.push(truncateContext(row.text, MAX_CONTEXT_BODY_BYTES), "");
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+
103
+ function truncateContext(value: string, byteLimit: number): string {
104
+ if (Buffer.byteLength(value, "utf8") <= byteLimit) return value;
105
+ const suffix = "... [truncated]";
106
+ const contentBudget = byteLimit - Buffer.byteLength(suffix, "utf8");
107
+ let bytes = 0;
108
+ let result = "";
109
+ for (const codePoint of value) {
110
+ const codePointBytes = Buffer.byteLength(codePoint, "utf8");
111
+ if (bytes + codePointBytes > contentBudget) break;
112
+ result += codePoint;
113
+ bytes += codePointBytes;
114
+ }
115
+ return result + suffix;
116
+ }
117
+
58
118
  // One search hit as a markdown block. snippetLength defaults to 400 (plugin
59
119
  // tool output); the CLI passes 220 to keep terminal output brief. scoreLabel
60
120
  // names the score field: "score" for vector (cosine ~0.4–0.7) and BM25, "rrf"
@@ -62,7 +122,9 @@ export function renderTranscript(
62
122
  // AGENTS.md) so the number isn't misread against the cosine thresholds.
63
123
  export function formatHit(h: SearchHit, snippetLength = 400, scoreLabel = "score"): string {
64
124
  const snippet = h.text.replace(/\s+/g, " ").slice(0, snippetLength);
65
- return `## ${fmtDate(h.time_created)} ${h.title}\nsession: ${h.session_id} ${scoreLabel}: ${h.score.toFixed(3)}\n${h.directory}\n> ${snippet}`;
125
+ const anchor = h.anchor_message_id ?? "unavailable (refresh/reindex required)";
126
+ const source = h.source_id ? `source: ${h.source_id}\n` : "";
127
+ return `## ${fmtDate(h.time_created)} — ${h.title}\n${source}session: ${h.session_id} ${scoreLabel}: ${h.score.toFixed(3)}\nanchor: ${anchor}\n${h.directory}\n> ${snippet}`;
66
128
  }
67
129
 
68
130
  export function formatHits(hits: SearchHit[], snippetLength = 400, scoreLabel = "score"): string {
package/src/indexer.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // Incremental, idempotent indexer. Watermark = session.time_updated; a session
2
2
  // is re-embedded only when the source changed since we last indexed it.
3
3
  import type { Database } from "bun:sqlite";
4
- import { getTranscriptChecked, listSessions, type SourceSession } from "./reader";
4
+ import { getTranscriptChecked, listSessions, transcriptHasMarker, type SourceSession } from "./reader";
5
5
  import { parseTranscript, exchangeText } from "./parser";
6
6
  import { embed } from "./embed";
7
- import { getIndexedSession, replaceSessionChunks } from "./store";
7
+ import type { IndexStore } from "./store";
8
8
 
9
9
  export interface SyncResult {
10
10
  scanned: number;
@@ -17,20 +17,33 @@ export interface SyncResult {
17
17
 
18
18
  export async function syncSession(
19
19
  source: Database,
20
- index: Database,
20
+ index: IndexStore,
21
21
  s: SourceSession,
22
22
  force = false
23
23
  ): Promise<"indexed" | "fresh" | "excluded" | "empty"> {
24
- const prior = getIndexedSession(index, s.id);
24
+ const removeIfRemoteExcluded = async (): Promise<boolean> => {
25
+ if (!index.remote || !transcriptHasMarker(source, s.id)) return false;
26
+ await index.removeSession(s.id);
27
+ return true;
28
+ };
29
+ // Remote freshness must never preserve metadata for a newly excluded session.
30
+ // Local mode intentionally keeps its established cheap freshness-first path.
31
+ const checked = index.remote ? getTranscriptChecked(source, s.id) : undefined;
32
+ if (checked?.excluded) {
33
+ await index.removeSession(s.id);
34
+ return "excluded";
35
+ }
36
+ const prior = await index.getIndexedSession(s.id);
37
+ if (await removeIfRemoteExcluded()) return "excluded";
25
38
  if (!force && prior && prior.source_time_updated >= s.time_updated) return "fresh";
26
39
 
27
40
  // Authoritative opt-out gate lives inside getTranscriptChecked (raw-blob
28
41
  // scan before any read); parseTranscript's own parsed-text check is a
29
42
  // harmless redundant fast path for the non-excluded branch.
30
- const checked = getTranscriptChecked(source, s.id);
31
- const { exchanges, excluded } = checked.excluded
43
+ const transcript = checked ?? getTranscriptChecked(source, s.id);
44
+ const { exchanges, excluded } = transcript.excluded
32
45
  ? { exchanges: [], excluded: true }
33
- : parseTranscript(checked.messages);
46
+ : parseTranscript(transcript.messages);
34
47
  const meta = {
35
48
  id: s.id, project_id: s.project_id, parent_id: s.parent_id,
36
49
  title: s.title, directory: s.directory,
@@ -38,28 +51,37 @@ export async function syncSession(
38
51
  };
39
52
 
40
53
  if (excluded) {
41
- replaceSessionChunks(index, meta, [], "excluded");
54
+ // A remote index is an opt-in upload boundary: unlike the local index's
55
+ // useful excluded tombstone, it must retain no metadata for marked chats.
56
+ if (index.remote) await index.removeSession(s.id);
57
+ else await index.replaceSessionChunks(meta, [], "excluded");
42
58
  return "excluded";
43
59
  }
44
60
  if (exchanges.length === 0) {
45
- replaceSessionChunks(index, meta, [], "empty");
61
+ if (await removeIfRemoteExcluded()) return "excluded";
62
+ await index.replaceSessionChunks(meta, [], "empty");
46
63
  return "empty";
47
64
  }
48
65
 
49
66
  const date = new Date(s.time_created).toISOString().slice(0, 10);
50
67
  const texts = exchanges.map((e) => exchangeText(s.title, date, e));
51
68
  const vectors = await embed(texts);
52
- replaceSessionChunks(
53
- index,
69
+ // Embedding can take long enough for the source conversation to change. Run
70
+ // the cheap authoritative raw-marker check again immediately before a remote
71
+ // upload so a marker added during embedding never exports the prepared data.
72
+ if (await removeIfRemoteExcluded()) return "excluded";
73
+ await index.replaceSessionChunks(
54
74
  meta,
55
- exchanges.map((e, i) => ({ seq: i, time_created: e.time, text: texts[i], embedding: vectors[i] }))
75
+ exchanges.map((e, i) => ({
76
+ seq: i, time_created: e.time, text: texts[i], embedding: vectors[i], anchor_message_id: e.anchorMessageId,
77
+ }))
56
78
  );
57
79
  return "indexed";
58
80
  }
59
81
 
60
82
  export async function syncAll(
61
83
  source: Database,
62
- index: Database,
84
+ index: IndexStore,
63
85
  opts: { force?: boolean; onProgress?: (done: number, total: number, title: string) => void } = {}
64
86
  ): Promise<SyncResult> {
65
87
  const sessions = listSessions(source);
@@ -76,7 +98,7 @@ export async function syncAll(
76
98
 
77
99
  // Prune index rows whose session no longer exists in the source DB;
78
100
  // otherwise their stale (possibly wrong-dims) chunks linger forever.
79
- result.pruned = pruneOrphans(source, index, sessions);
101
+ result.pruned = await pruneOrphans(source, index, sessions);
80
102
 
81
103
  return result;
82
104
  }
@@ -85,15 +107,6 @@ export async function syncAll(
85
107
  // source DB. Extracted so the plugin's full-reindex path can call it without
86
108
  // re-running the whole sync. Pass already-fetched sessions to avoid a redundant
87
109
  // query in syncAll; omitted, it re-reads the source.
88
- export function pruneOrphans(source: Database, index: Database, knownSource?: SourceSession[]): number {
89
- const sourceIds = new Set((knownSource ?? listSessions(source)).map((s) => s.id));
90
- const indexedIds = index.prepare<{ id: string }, []>("SELECT id FROM sessions").all();
91
- let pruned = 0;
92
- for (const { id } of indexedIds) {
93
- if (sourceIds.has(id)) continue;
94
- index.run("DELETE FROM chunks WHERE session_id = ?", [id]);
95
- index.run("DELETE FROM sessions WHERE id = ?", [id]);
96
- pruned++;
97
- }
98
- return pruned;
110
+ export async function pruneOrphans(source: Database, index: IndexStore, knownSource?: SourceSession[]): Promise<number> {
111
+ return index.pruneOrphans((knownSource ?? listSessions(source)).map((s) => s.id));
99
112
  }
package/src/parser.ts CHANGED
@@ -23,6 +23,7 @@ export function hasExcludeMarker(messages: SourceMessage[]): boolean {
23
23
  }
24
24
 
25
25
  export interface Exchange {
26
+ anchorMessageId: string;
26
27
  user: string;
27
28
  assistant: string;
28
29
  tools: string[];
@@ -60,7 +61,7 @@ export function parseTranscript(messages: SourceMessage[]): {
60
61
  if (m.role === "user") {
61
62
  const text = textOf(m.parts);
62
63
  if (!text) continue; // e.g. pure tool-result turns
63
- current = { user: text, assistant: "", tools: [], time: m.timeCreated };
64
+ current = { anchorMessageId: m.id, user: text, assistant: "", tools: [], time: m.timeCreated };
64
65
  exchanges.push(current);
65
66
  } else if (m.role === "assistant" && current) {
66
67
  const text = textOf(m.parts);
@@ -73,7 +74,7 @@ export function parseTranscript(messages: SourceMessage[]): {
73
74
  return { exchanges: exchanges.filter((e) => e.user || e.assistant), excluded: false };
74
75
  }
75
76
 
76
- // Text stored per chunk (also displayed by episodic_read). Capped at 4000 chars
77
+ // Text stored per chunk (also displayed by episodic_read_session). Capped at 4000 chars
77
78
  // to keep storage sane; the embedding step (embed.ts) further truncates to 2000
78
79
  // chars where retrieval quality peaks. The head of an exchange carries the
79
80
  // most signal.
package/src/reader.ts CHANGED
@@ -42,11 +42,20 @@ const MessageRowSchema = z.object({
42
42
  time_created: z.number(),
43
43
  data: z.string(),
44
44
  });
45
+ type SourceMessageRow = z.infer<typeof MessageRowSchema>;
46
+
47
+ const AnchorRowSchema = z.object({
48
+ id: z.string(),
49
+ time_created: z.number(),
50
+ });
45
51
 
46
52
  const PartRowSchema = z.object({
47
53
  message_id: z.string(),
48
54
  data: z.string(),
49
55
  });
56
+ type SourcePartRow = z.infer<typeof PartRowSchema>;
57
+
58
+ const PartCountSchema = z.object({ message_id: z.string(), n: z.number() });
50
59
 
51
60
  // Aggregate row for the raw marker scan (structural: throw on drift).
52
61
  const MarkerCountSchema = z.object({ n: z.number() });
@@ -70,8 +79,12 @@ export interface SourceMessage {
70
79
  role: string;
71
80
  timeCreated: number;
72
81
  parts: SourcePart[];
82
+ contextPartsOmitted?: number;
73
83
  }
74
84
 
85
+ const MAX_CONTEXT_PART_BYTES = 8_192;
86
+ const MAX_CONTEXT_PARTS_PER_MESSAGE = 20;
87
+
75
88
  export function openSource(path: string = sourceDbPath()): Database {
76
89
  return new Database(path, { readonly: true });
77
90
  }
@@ -140,30 +153,90 @@ function getTranscript(db: Database, sessionId: string): SourceMessage[] {
140
153
  )
141
154
  .all(sessionId)
142
155
  );
156
+ return materializeMessages(db, sessionId, messages);
157
+ }
143
158
 
144
- const parts = PartRowSchema.array().parse(
145
- db
146
- .prepare(
147
- `SELECT message_id, data FROM part
148
- WHERE session_id = ? ORDER BY time_created, id`
149
- )
150
- .all(sessionId)
151
- );
159
+ // Parse parts only for the supplied message rows. Full transcript reads pass
160
+ // every row; bounded context reads pass just their selected SQL window.
161
+ function materializeMessages(
162
+ db: Database,
163
+ sessionId: string,
164
+ messages: SourceMessageRow[],
165
+ contextLimits?: { maxPartBytes: number; maxPartsPerMessage: number }
166
+ ): SourceMessage[] {
167
+ if (messages.length === 0) return [];
168
+ const ids = messages.map((message) => message.id);
169
+ const placeholders = ids.map(() => "?").join(", ");
170
+ const materialized = contextLimits
171
+ ? boundedParts(db, sessionId, ids, placeholders, contextLimits)
172
+ : {
173
+ rows: PartRowSchema.array().parse(
174
+ db
175
+ .prepare(
176
+ `SELECT message_id, data FROM part
177
+ WHERE session_id = ? AND message_id IN (${placeholders}) ORDER BY time_created, id`
178
+ )
179
+ .all(sessionId, ...ids)
180
+ ),
181
+ omittedByMessage: new Map<string, number>(),
182
+ };
152
183
 
153
184
  const partsByMsg = new Map<string, SourcePart[]>();
154
- for (const p of parts) {
185
+ for (const p of materialized.rows) {
155
186
  const d = PartDataSchema.parse(safeJsonParse(p.data));
156
187
  let list = partsByMsg.get(p.message_id);
157
188
  if (!list) partsByMsg.set(p.message_id, (list = []));
158
189
  list.push(d);
159
190
  }
160
191
 
161
- return messages.map((m) => ({
162
- id: m.id,
163
- role: MessageDataSchema.parse(safeJsonParse(m.data)).role,
164
- timeCreated: m.time_created,
165
- parts: partsByMsg.get(m.id) ?? [],
166
- }));
192
+ return messages.map((m) => {
193
+ const omitted = materialized.omittedByMessage.get(m.id) ?? 0;
194
+ return {
195
+ id: m.id,
196
+ role: MessageDataSchema.parse(safeJsonParse(m.data)).role,
197
+ timeCreated: m.time_created,
198
+ parts: partsByMsg.get(m.id) ?? [],
199
+ ...(omitted > 0 ? { contextPartsOmitted: omitted } : {}),
200
+ };
201
+ });
202
+ }
203
+
204
+ // Context-only part fetch: SQL excludes oversized raw blobs before they cross
205
+ // into JS, ranks remaining parts per selected message, and records omissions.
206
+ // Full transcript reads continue through the unbounded path above.
207
+ function boundedParts(
208
+ db: Database,
209
+ sessionId: string,
210
+ ids: string[],
211
+ placeholders: string,
212
+ limits: { maxPartBytes: number; maxPartsPerMessage: number }
213
+ ): { rows: SourcePartRow[]; omittedByMessage: Map<string, number> } {
214
+ const counts = PartCountSchema.array().parse(
215
+ db.prepare(
216
+ `SELECT message_id, COUNT(*) AS n FROM part
217
+ WHERE session_id = ? AND message_id IN (${placeholders}) GROUP BY message_id`
218
+ ).all(sessionId, ...ids)
219
+ );
220
+ const rows = PartRowSchema.array().parse(
221
+ db.prepare(
222
+ `WITH ranked AS (
223
+ SELECT message_id, data, time_created, id,
224
+ ROW_NUMBER() OVER (PARTITION BY message_id ORDER BY time_created, id) AS part_rank
225
+ FROM part
226
+ WHERE session_id = ? AND message_id IN (${placeholders})
227
+ AND (data IS NULL OR length(CAST(data AS BLOB)) <= ?)
228
+ )
229
+ SELECT message_id, data FROM ranked WHERE part_rank <= ? ORDER BY time_created, id`
230
+ ).all(sessionId, ...ids, limits.maxPartBytes, limits.maxPartsPerMessage)
231
+ );
232
+ const retained = new Map<string, number>();
233
+ for (const row of rows) retained.set(row.message_id, (retained.get(row.message_id) ?? 0) + 1);
234
+ const omittedByMessage = new Map<string, number>();
235
+ for (const count of counts) {
236
+ const omitted = count.n - (retained.get(count.message_id) ?? 0);
237
+ if (omitted > 0) omittedByMessage.set(count.message_id, omitted);
238
+ }
239
+ return { rows, omittedByMessage };
167
240
  }
168
241
 
169
242
  // Discriminated result: excluded conversations never yield a transcript.
@@ -174,9 +247,88 @@ export type CheckedTranscript =
174
247
  // The single privacy-gated entry point for reading a transcript. Runs the
175
248
  // AUTHORITATIVE raw-blob exclusion check (transcriptHasMarker) BEFORE reading,
176
249
  // so the opt-out marker cannot be bypassed by a caller forgetting to check.
177
- // All production call sites (CLI read, plugin episodic_read, indexer) use this;
250
+ // All production call sites (CLI read, plugin episodic_read_session, indexer) use this;
178
251
  // the raw getTranscript is module-internal.
179
252
  export function getTranscriptChecked(db: Database, sessionId: string): CheckedTranscript {
180
253
  if (transcriptHasMarker(db, sessionId)) return { excluded: true };
181
254
  return { excluded: false, messages: getTranscript(db, sessionId) };
182
255
  }
256
+
257
+ export const MAX_CONTEXT_MESSAGES = 20;
258
+
259
+ export type TranscriptContext =
260
+ | { ok: true; session: SourceSession; messages: SourceMessage[]; anchorIndex: number; sliceStart: number; total: number }
261
+ | { ok: false; reason: "unknown_session" | "excluded" | "invalid_anchor" | "invalid_bounds" };
262
+
263
+ // Read a small, chronological live-source window around an indexed user-message
264
+ // anchor. This deliberately has no indexed fallback: an index may outlive its
265
+ // source transcript, but it cannot safely reconstruct source message context.
266
+ export function getTranscriptContext(
267
+ db: Database,
268
+ sessionId: string,
269
+ anchorMessageId: string,
270
+ before: number = 3,
271
+ after: number = 3
272
+ ): TranscriptContext {
273
+ if (!isContextBound(before) || !isContextBound(after)) return { ok: false, reason: "invalid_bounds" };
274
+ return readSnapshot(db, () => {
275
+ // Keep the whole-session raw scan first: privacy is session-wide, while
276
+ // every subsequent query stays bounded to the requested context window.
277
+ if (transcriptHasMarker(db, sessionId)) return { ok: false, reason: "excluded" };
278
+ const session = getSession(db, sessionId);
279
+ if (!session) return { ok: false, reason: "unknown_session" };
280
+ const anchorRow = db.prepare("SELECT id, time_created FROM message WHERE session_id = ? AND id = ?").get(sessionId, anchorMessageId);
281
+ if (anchorRow === null || anchorRow === undefined) return { ok: false, reason: "invalid_anchor" };
282
+ const anchor = AnchorRowSchema.parse(anchorRow);
283
+ const total = MarkerCountSchema.parse(
284
+ db.prepare("SELECT COUNT(*) AS n FROM message WHERE session_id = ?").get(sessionId)
285
+ ).n;
286
+ const anchorIndex = MarkerCountSchema.parse(
287
+ db.prepare(
288
+ `SELECT COUNT(*) AS n FROM message
289
+ WHERE session_id = ? AND (time_created < ? OR (time_created = ? AND id < ?))`
290
+ ).get(sessionId, anchor.time_created, anchor.time_created, anchor.id)
291
+ ).n;
292
+ const sliceStart = Math.max(0, anchorIndex - before);
293
+ const sliceEnd = Math.min(total, anchorIndex + after + 1);
294
+ const sliceLength = sliceEnd - sliceStart;
295
+ const rows = MessageRowSchema.array().parse(
296
+ db.prepare(
297
+ `SELECT id, time_created, data FROM message
298
+ WHERE session_id = ? ORDER BY time_created, id LIMIT ? OFFSET ?`
299
+ ).all(sessionId, sliceLength, sliceStart)
300
+ );
301
+ return {
302
+ ok: true,
303
+ session,
304
+ messages: materializeMessages(db, sessionId, rows, {
305
+ maxPartBytes: MAX_CONTEXT_PART_BYTES,
306
+ maxPartsPerMessage: MAX_CONTEXT_PARTS_PER_MESSAGE,
307
+ }),
308
+ anchorIndex,
309
+ sliceStart,
310
+ total,
311
+ };
312
+ });
313
+ }
314
+
315
+ // BEGIN is deferred, so this remains a read transaction against the readonly
316
+ // source DB while pinning all context queries to one SQLite snapshot.
317
+ function readSnapshot<T>(db: Database, read: () => T): T {
318
+ let active = false;
319
+ try {
320
+ db.run("BEGIN");
321
+ active = true;
322
+ const result = read();
323
+ db.run("COMMIT");
324
+ active = false;
325
+ return result;
326
+ } catch (error) {
327
+ if (active) db.run("ROLLBACK");
328
+ throw error;
329
+ }
330
+ }
331
+
332
+ function isContextBound(value: number): boolean {
333
+ return Number.isInteger(value) && value >= 0 && value <= MAX_CONTEXT_MESSAGES;
334
+ }