codemem 0.1.0 → 0.20.0-alpha.2

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.
Files changed (42) hide show
  1. package/.opencode/lib/compat.js +171 -0
  2. package/.opencode/plugin/codemem.js +1851 -0
  3. package/LICENSE +21 -0
  4. package/dist/commands/claude-hook-ingest.d.ts +15 -0
  5. package/dist/commands/claude-hook-ingest.d.ts.map +1 -0
  6. package/dist/commands/db.d.ts +3 -0
  7. package/dist/commands/db.d.ts.map +1 -0
  8. package/dist/commands/enqueue-raw-event.d.ts +3 -0
  9. package/dist/commands/enqueue-raw-event.d.ts.map +1 -0
  10. package/dist/commands/export-memories.d.ts +3 -0
  11. package/dist/commands/export-memories.d.ts.map +1 -0
  12. package/dist/commands/import-memories.d.ts +3 -0
  13. package/dist/commands/import-memories.d.ts.map +1 -0
  14. package/dist/commands/mcp.d.ts +3 -0
  15. package/dist/commands/mcp.d.ts.map +1 -0
  16. package/dist/commands/memory.d.ts +10 -0
  17. package/dist/commands/memory.d.ts.map +1 -0
  18. package/dist/commands/pack.d.ts +3 -0
  19. package/dist/commands/pack.d.ts.map +1 -0
  20. package/dist/commands/recent.d.ts +3 -0
  21. package/dist/commands/recent.d.ts.map +1 -0
  22. package/dist/commands/search.d.ts +3 -0
  23. package/dist/commands/search.d.ts.map +1 -0
  24. package/dist/commands/serve.d.ts +3 -0
  25. package/dist/commands/serve.d.ts.map +1 -0
  26. package/dist/commands/setup.d.ts +15 -0
  27. package/dist/commands/setup.d.ts.map +1 -0
  28. package/dist/commands/stats.d.ts +3 -0
  29. package/dist/commands/stats.d.ts.map +1 -0
  30. package/dist/commands/sync.d.ts +6 -0
  31. package/dist/commands/sync.d.ts.map +1 -0
  32. package/dist/commands/version.d.ts +3 -0
  33. package/dist/commands/version.d.ts.map +1 -0
  34. package/dist/help-style.d.ts +9 -0
  35. package/dist/help-style.d.ts.map +1 -0
  36. package/dist/index.d.ts +13 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +1076 -0
  39. package/dist/index.js.map +1 -0
  40. package/package.json +53 -15
  41. package/README.md +0 -5
  42. package/index.js +0 -6
@@ -0,0 +1,1851 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { tool } from "@opencode-ai/plugin";
5
+
6
+ import {
7
+ isVersionAtLeast,
8
+ parseBackendUpdatePolicy,
9
+ parseSemver,
10
+ resolveAutoUpdatePlan,
11
+ resolveUpgradeGuidance,
12
+ } from "../lib/compat.js";
13
+
14
+ const TRUTHY_VALUES = ["1", "true", "yes"];
15
+ const DISABLED_VALUES = ["0", "false", "off"];
16
+ const PINNED_BACKEND_VERSION = "0.20.0-alpha.2";
17
+ const DEFAULT_UVX_SOURCE = `codemem==${PINNED_BACKEND_VERSION}`;
18
+
19
+ const normalizeEnvValue = (value) => (value || "").toLowerCase();
20
+ const envHasValue = (value, truthyValues) =>
21
+ truthyValues.includes(normalizeEnvValue(value));
22
+ const envNotDisabled = (value) =>
23
+ !DISABLED_VALUES.includes(normalizeEnvValue(value));
24
+
25
+ const resolveLogPath = (logPathEnvRaw, cwd, homeDir) => {
26
+ const logPathEnv = normalizeEnvValue(logPathEnvRaw);
27
+ const logEnabled = !!logPathEnvRaw && !DISABLED_VALUES.includes(logPathEnv);
28
+ if (!logEnabled) {
29
+ return null;
30
+ }
31
+ if (["true", "yes", "1"].includes(logPathEnv)) {
32
+ return `${homeDir || cwd}/.codemem/plugin.log`;
33
+ }
34
+ return logPathEnvRaw;
35
+ };
36
+
37
+ const createLogLine = (logPath) => async (line) => {
38
+ if (!logPath) {
39
+ return;
40
+ }
41
+ try {
42
+ await mkdir(dirname(logPath), { recursive: true });
43
+ await appendFile(logPath, `${new Date().toISOString()} ${line}\n`);
44
+ } catch (err) {
45
+ // ignore logging failures
46
+ }
47
+ };
48
+
49
+ const createDebugLogger = ({ debug, client, logTimeoutMs, getLogLine }) =>
50
+ async (level, message, extra = {}) => {
51
+ if (!debug) {
52
+ return;
53
+ }
54
+ try {
55
+ const logPromise = client.app.log({
56
+ service: "codemem",
57
+ level,
58
+ message,
59
+ extra,
60
+ });
61
+ if (!Number.isFinite(logTimeoutMs) || logTimeoutMs <= 0) {
62
+ await logPromise;
63
+ return;
64
+ }
65
+ let timedOut = false;
66
+ await Promise.race([
67
+ logPromise,
68
+ new Promise((resolve) =>
69
+ setTimeout(() => {
70
+ timedOut = true;
71
+ resolve();
72
+ }, logTimeoutMs)
73
+ ),
74
+ ]);
75
+ if (timedOut) {
76
+ await getLogLine()("debug log timed out");
77
+ }
78
+ } catch (err) {
79
+ // ignore debug logging failures
80
+ }
81
+ };
82
+
83
+ const extractApplyPatchPaths = (patchText) => {
84
+ if (!patchText || typeof patchText !== "string") {
85
+ return [];
86
+ }
87
+ const paths = [];
88
+ const seen = new Set();
89
+ const lines = patchText.split(/\r?\n/);
90
+ for (const line of lines) {
91
+ const match = line.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/);
92
+ if (!match) {
93
+ continue;
94
+ }
95
+ const path = String(match[1] || "").trim();
96
+ if (!path || seen.has(path)) {
97
+ continue;
98
+ }
99
+ seen.add(path);
100
+ paths.push(path);
101
+ }
102
+ return paths;
103
+ };
104
+
105
+ const appendWorkingSetFileArgs = (args, workingSetFiles) => {
106
+ if (!Array.isArray(workingSetFiles) || workingSetFiles.length === 0) {
107
+ return args;
108
+ }
109
+ for (const file of workingSetFiles) {
110
+ const normalized = String(file || "").trim();
111
+ if (!normalized) {
112
+ continue;
113
+ }
114
+ args.push("--working-set-file", normalized.slice(0, 400));
115
+ }
116
+ return args;
117
+ };
118
+
119
+ const mapOpencodeEventTypeToAdapterType = (eventType) => {
120
+ if (eventType === "user_prompt") {
121
+ return "prompt";
122
+ }
123
+ if (eventType === "assistant_message") {
124
+ return "assistant";
125
+ }
126
+ if (eventType === "tool.execute.after") {
127
+ return "tool_result";
128
+ }
129
+ return null;
130
+ };
131
+
132
+ const buildOpencodeAdapterPayload = (event) => {
133
+ const eventType = event?.type;
134
+ if (eventType === "user_prompt") {
135
+ const text = String(event?.prompt_text || "").trim();
136
+ if (!text) {
137
+ return null;
138
+ }
139
+ return {
140
+ text,
141
+ prompt_number:
142
+ typeof event?.prompt_number === "number" ? event.prompt_number : null,
143
+ };
144
+ }
145
+
146
+ if (eventType === "assistant_message") {
147
+ const text = String(event?.assistant_text || "").trim();
148
+ if (!text) {
149
+ return null;
150
+ }
151
+ return { text };
152
+ }
153
+
154
+ if (eventType === "tool.execute.after") {
155
+ const toolName = String(event?.tool || "unknown");
156
+ return {
157
+ tool_name: toolName,
158
+ status: event?.error ? "error" : "ok",
159
+ tool_input: event?.args || {},
160
+ tool_output: event?.result ?? null,
161
+ error: event?.error ?? null,
162
+ };
163
+ }
164
+
165
+ return null;
166
+ };
167
+
168
+ const stableStringify = (value) => {
169
+ if (value === null || typeof value !== "object") {
170
+ return JSON.stringify(value);
171
+ }
172
+ if (Array.isArray(value)) {
173
+ return `[${value.map(stableStringify).join(",")}]`;
174
+ }
175
+ const keys = Object.keys(value).sort();
176
+ return `{${keys
177
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
178
+ .join(",")}}`;
179
+ };
180
+
181
+ const stableDigest = (value) =>
182
+ createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 20);
183
+
184
+ const sanitizeIdPart = (value, fallback, maxChars) => {
185
+ const normalized = String(value || "")
186
+ .replace(/[^A-Za-z0-9._:-]/g, "_")
187
+ .slice(0, maxChars);
188
+ return normalized || fallback;
189
+ };
190
+
191
+ const buildAdapterEventId = ({ sessionID, eventType, event, payload, ts }) => {
192
+ const safeSessionID = sanitizeIdPart(sessionID, "unknown", 48);
193
+ const safeType = sanitizeIdPart(eventType, "event", 24);
194
+ const rawTimestamp =
195
+ typeof event?.timestamp === "string" && event.timestamp.trim()
196
+ ? event.timestamp.trim()
197
+ : ts;
198
+ const digest = stableDigest({
199
+ session_id: String(sessionID || ""),
200
+ event_type: String(eventType || ""),
201
+ raw_event_type: String(event?.type || ""),
202
+ timestamp: rawTimestamp,
203
+ payload,
204
+ });
205
+ return `oc:${safeSessionID}:${safeType}:${digest}`.slice(0, 128);
206
+ };
207
+
208
+ const buildOpencodeAdapterEvent = ({ sessionID, event }) => {
209
+ if (!sessionID || !event || typeof event !== "object") {
210
+ return null;
211
+ }
212
+ const adapterType = mapOpencodeEventTypeToAdapterType(event.type);
213
+ if (!adapterType) {
214
+ return null;
215
+ }
216
+ const payload = buildOpencodeAdapterPayload(event);
217
+ if (!payload) {
218
+ return null;
219
+ }
220
+ const ts = typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString();
221
+ return {
222
+ schema_version: "1.0",
223
+ source: "opencode",
224
+ session_id: String(sessionID),
225
+ event_id: buildAdapterEventId({
226
+ sessionID,
227
+ eventType: adapterType,
228
+ event,
229
+ payload,
230
+ ts,
231
+ }),
232
+ event_type: adapterType,
233
+ ts,
234
+ ordering_confidence: "low",
235
+ payload,
236
+ meta: {
237
+ original_event_type: String(event.type || "unknown"),
238
+ },
239
+ };
240
+ };
241
+
242
+ const resolveProjectName = (project) =>
243
+ project?.name ||
244
+ (project?.root
245
+ ? String(project.root).split(/[/\\]/).filter(Boolean).pop()
246
+ : null) ||
247
+ null;
248
+
249
+ const selectRawEventId = ({ payload, nextEventId }) => {
250
+ const fromPayload =
251
+ payload &&
252
+ typeof payload === "object" &&
253
+ payload._raw_event_id;
254
+ return String(fromPayload || nextEventId());
255
+ };
256
+
257
+ const buildRawEventEnvelope = ({
258
+ sessionID,
259
+ type,
260
+ payload,
261
+ cwd,
262
+ project,
263
+ startedAt,
264
+ nowMs,
265
+ nowMono,
266
+ nextEventId,
267
+ }) => ({
268
+ session_stream_id: sessionID,
269
+ session_id: sessionID,
270
+ opencode_session_id: sessionID,
271
+ event_id: selectRawEventId({ payload, nextEventId }),
272
+ event_type: type,
273
+ ts_wall_ms: nowMs,
274
+ ts_mono_ms: nowMono,
275
+ payload,
276
+ cwd,
277
+ project,
278
+ started_at: startedAt,
279
+ });
280
+
281
+ const trimEventQueue = ({ events, maxEvents, hardMaxEvents, onUnsentPressure, onForcedDrop }) => {
282
+ if (!Number.isFinite(maxEvents) || maxEvents <= 0) {
283
+ return;
284
+ }
285
+ while (events.length > maxEvents) {
286
+ const droppableIndex = events.findIndex(
287
+ (queued) => queued && typeof queued === "object" && queued._raw_enqueued
288
+ );
289
+ if (droppableIndex >= 0) {
290
+ events.splice(droppableIndex, 1);
291
+ continue;
292
+ }
293
+ if (typeof onUnsentPressure === "function") {
294
+ onUnsentPressure(events.length, maxEvents);
295
+ }
296
+ if (
297
+ Number.isFinite(hardMaxEvents) &&
298
+ hardMaxEvents > 0 &&
299
+ events.length > hardMaxEvents
300
+ ) {
301
+ const dropped = events.shift();
302
+ if (typeof onForcedDrop === "function") {
303
+ onForcedDrop(dropped, events.length, hardMaxEvents);
304
+ }
305
+ continue;
306
+ }
307
+ break;
308
+ }
309
+ };
310
+
311
+ const attachAdapterEvent = ({ sessionID, event }) => {
312
+ if (!event || typeof event !== "object") {
313
+ return event;
314
+ }
315
+ let adapterEvent = null;
316
+ try {
317
+ adapterEvent = buildOpencodeAdapterEvent({ sessionID, event });
318
+ } catch (err) {
319
+ return event;
320
+ }
321
+ if (!adapterEvent) {
322
+ return event;
323
+ }
324
+ return {
325
+ ...event,
326
+ _adapter: adapterEvent,
327
+ };
328
+ };
329
+
330
+ const asNonNegativeCount = (value) => {
331
+ if (Array.isArray(value)) {
332
+ return value.length;
333
+ }
334
+ if (typeof value === "number" && Number.isFinite(value)) {
335
+ return Math.max(0, Math.trunc(value));
336
+ }
337
+ return null;
338
+ };
339
+
340
+ const asFiniteNonNegativeInt = (value) => {
341
+ if (typeof value !== "number" || !Number.isFinite(value)) {
342
+ return null;
343
+ }
344
+ if (value < 0) {
345
+ return null;
346
+ }
347
+ return Math.trunc(value);
348
+ };
349
+
350
+ const parsePositiveInt = (value, fallback) => {
351
+ const parsed = Number.parseInt(String(value ?? ""), 10);
352
+ if (!Number.isFinite(parsed) || parsed <= 0) {
353
+ return fallback;
354
+ }
355
+ return parsed;
356
+ };
357
+
358
+ export const buildInjectionToastMessage = (metrics) => {
359
+ const items = asFiniteNonNegativeInt(metrics?.items);
360
+ const packTokens = asFiniteNonNegativeInt(metrics?.pack_tokens);
361
+ const avoided = asFiniteNonNegativeInt(metrics?.avoided_work_tokens);
362
+ const avoidedUnknown = asNonNegativeCount(metrics?.avoided_work_unknown_items);
363
+ const avoidedKnown = asNonNegativeCount(metrics?.avoided_work_known_items);
364
+ const addedCount = asNonNegativeCount(metrics?.added_ids);
365
+ const removedCount = asNonNegativeCount(metrics?.removed_ids);
366
+ const deltaAvailable = metrics?.pack_delta_available === true;
367
+
368
+ const messageParts = ["codemem injected"];
369
+ if (items !== null) messageParts.push(`${items} items`);
370
+ if (packTokens !== null) messageParts.push(`~${packTokens} tokens`);
371
+ if (
372
+ avoided !== null
373
+ && avoided > 0
374
+ && avoidedKnown !== null
375
+ && avoidedUnknown !== null
376
+ && avoidedKnown >= avoidedUnknown
377
+ ) {
378
+ messageParts.push(`avoided work ~${avoided} tokens`);
379
+ }
380
+ if (deltaAvailable && (addedCount !== null || removedCount !== null)) {
381
+ messageParts.push(`delta +${addedCount || 0}/-${removedCount || 0}`);
382
+ }
383
+ return messageParts.join(" · ");
384
+ };
385
+
386
+ const detectRunner = ({ cwd, envRunner }) => {
387
+ if (envRunner) {
388
+ return envRunner;
389
+ }
390
+ // Check if we're in the codemem repo (dev mode)
391
+ try {
392
+ const pyproject = Bun.file(`${cwd}/pyproject.toml`);
393
+ if (pyproject.size > 0) {
394
+ const content = require("fs").readFileSync(
395
+ `${cwd}/pyproject.toml`,
396
+ "utf-8"
397
+ );
398
+ if (content.includes('name = "codemem"')) {
399
+ return "uv";
400
+ }
401
+ }
402
+ } catch (err) {
403
+ // Not in dev mode
404
+ }
405
+ return "uvx";
406
+ };
407
+
408
+ /**
409
+ * Check if the TS CLI is available at the given path.
410
+ * Used by the "node" runner to verify the built CLI exists.
411
+ */
412
+ const tsCliAvailable = (cliPath) => {
413
+ try {
414
+ return require("fs").existsSync(cliPath);
415
+ } catch {
416
+ return false;
417
+ }
418
+ };
419
+
420
+ const buildRunnerArgs = ({ runner, runnerFrom, runnerFromExplicit }) => {
421
+ if (runner === "uvx") {
422
+ if (runnerFromExplicit) {
423
+ return ["--from", runnerFrom, "codemem"];
424
+ }
425
+ return [runnerFrom || "codemem"];
426
+ }
427
+ if (runner === "uv") {
428
+ return ["run", "--directory", runnerFrom, "codemem"];
429
+ }
430
+ if (runner === "node") {
431
+ // TS CLI — runnerFrom points to the built CLI entry or repo root.
432
+ // Default: packages/cli/dist/index.js relative to repo root.
433
+ const cliPath = runnerFromExplicit
434
+ ? runnerFrom
435
+ : require("path").join(runnerFrom, "packages/cli/dist/index.js");
436
+ return [cliPath];
437
+ }
438
+ if (runner === "npx") {
439
+ const pkg = runnerFromExplicit ? runnerFrom : "@codemem/cli";
440
+ return ["-y", pkg];
441
+ }
442
+ return [];
443
+ };
444
+
445
+ export const OpencodeMemPlugin = async ({
446
+ project,
447
+ client,
448
+ directory,
449
+ worktree,
450
+ }) => {
451
+ const events = [];
452
+ const maxEvents = parsePositiveInt(process.env.CODEMEM_PLUGIN_MAX_EVENTS, 200);
453
+ const maxChars = Number.parseInt(
454
+ process.env.CODEMEM_PLUGIN_MAX_EVENT_CHARS || "8000",
455
+ 10
456
+ );
457
+ const cwd = worktree || directory || process.cwd();
458
+ const debug = envHasValue(process.env.CODEMEM_PLUGIN_DEBUG, TRUTHY_VALUES);
459
+ const debugExtraction = envHasValue(
460
+ process.env.CODEMEM_DEBUG_EXTRACTION,
461
+ TRUTHY_VALUES
462
+ );
463
+ const logTimeoutMs = Number.parseInt(
464
+ process.env.CODEMEM_PLUGIN_LOG_TIMEOUT_MS || "1500",
465
+ 10
466
+ );
467
+ const logPathEnvRaw = process.env.CODEMEM_PLUGIN_LOG || "";
468
+ const logPath = resolveLogPath(logPathEnvRaw, cwd, process.env.HOME);
469
+ const logLine = createLogLine(logPath);
470
+ const log = createDebugLogger({
471
+ debug,
472
+ client,
473
+ logTimeoutMs,
474
+ getLogLine: () => logLine,
475
+ });
476
+ const pluginIgnored = envHasValue(
477
+ process.env.CODEMEM_PLUGIN_IGNORE,
478
+ TRUTHY_VALUES
479
+ );
480
+ if (pluginIgnored) {
481
+ return {};
482
+ }
483
+
484
+ // Determine runner mode:
485
+ // - If CODEMEM_RUNNER is set, use that ("uvx", "uv", "node", "npx")
486
+ // - If we're in a directory with pyproject.toml containing codemem, use "uv" (dev mode)
487
+ // - Otherwise, use "uvx" with a package source pinned to plugin version
488
+ const runner = detectRunner({
489
+ cwd,
490
+ envRunner: process.env.CODEMEM_RUNNER,
491
+ });
492
+ const runnerFromExplicit = Boolean(String(process.env.CODEMEM_RUNNER_FROM || "").trim());
493
+ const defaultRunnerFrom = runner === "uvx" ? DEFAULT_UVX_SOURCE : cwd;
494
+ const runnerFrom = process.env.CODEMEM_RUNNER_FROM || defaultRunnerFrom;
495
+ const runnerArgs = buildRunnerArgs({ runner, runnerFrom, runnerFromExplicit });
496
+ const viewerEnabled = envNotDisabled(process.env.CODEMEM_VIEWER || "1");
497
+ const viewerAutoStart = envNotDisabled(
498
+ process.env.CODEMEM_VIEWER_AUTO || "1"
499
+ );
500
+ const viewerAutoStop = envNotDisabled(
501
+ process.env.CODEMEM_VIEWER_AUTO_STOP || "1"
502
+ );
503
+ const viewerHost = process.env.CODEMEM_VIEWER_HOST || "127.0.0.1";
504
+ const viewerPort = process.env.CODEMEM_VIEWER_PORT || "38888";
505
+ const commandTimeout = Number.parseInt(
506
+ process.env.CODEMEM_PLUGIN_CMD_TIMEOUT || "20000",
507
+ 10
508
+ );
509
+ const backendUpdatePolicy = parseBackendUpdatePolicy(
510
+ process.env.CODEMEM_BACKEND_UPDATE_POLICY || "notify"
511
+ );
512
+
513
+ const parseNumber = (value, fallback) => {
514
+ const parsed = Number.parseInt(value, 10);
515
+ return Number.isFinite(parsed) ? parsed : fallback;
516
+ };
517
+ const injectEnabled = envNotDisabled(
518
+ process.env.CODEMEM_INJECT_CONTEXT || "1"
519
+ );
520
+ // Only use env overrides if explicitly set; otherwise CLI uses config defaults
521
+ const injectLimitEnv = process.env.CODEMEM_INJECT_LIMIT;
522
+ const injectLimit = injectLimitEnv ? parseNumber(injectLimitEnv, null) : null;
523
+ const injectTokenBudgetEnv = process.env.CODEMEM_INJECT_TOKEN_BUDGET;
524
+ const injectTokenBudget = injectTokenBudgetEnv ? parseNumber(injectTokenBudgetEnv, null) : null;
525
+ const injectedSessions = new Map();
526
+ const injectionToastShown = new Set();
527
+ let sessionStartedAt = null;
528
+ let activeSessionID = null;
529
+ let viewerStarted = false;
530
+ let promptCounter = 0;
531
+ let lastPromptText = null;
532
+ let lastAssistantText = null;
533
+ const assistantUsageCaptured = new Set();
534
+
535
+ // Track message roles and accumulated text by messageID
536
+ const messageRoles = new Map();
537
+ const messageTexts = new Map();
538
+ let debugLogCount = 0;
539
+
540
+ const rawEventsEnabled = envNotDisabled(
541
+ process.env.CODEMEM_RAW_EVENTS || "1"
542
+ );
543
+ const rawEventsUrl = `http://${viewerHost}:${viewerPort}/api/raw-events`;
544
+ const rawEventsStatusUrl = `http://${viewerHost}:${viewerPort}/api/raw-events/status?limit=1`;
545
+ const rawEventsBackoffMs = parseNumber(
546
+ process.env.CODEMEM_RAW_EVENTS_BACKOFF_MS || "10000",
547
+ 10000
548
+ );
549
+ const rawEventsStatusCheckMs = parseNumber(
550
+ process.env.CODEMEM_RAW_EVENTS_STATUS_CHECK_MS || "30000",
551
+ 30000
552
+ );
553
+ const rawEventsHardMax = parseNumber(
554
+ process.env.CODEMEM_RAW_EVENTS_HARD_MAX || "2000",
555
+ 2000
556
+ );
557
+ let streamUnavailableUntil = 0;
558
+ let streamErrorNoted = false;
559
+ let fallbackFailureNoted = false;
560
+ let lastStatusCheckAt = 0;
561
+ let lastStatusAvailable = true;
562
+ const nextEventId = () => {
563
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
564
+ return crypto.randomUUID();
565
+ }
566
+ return `${Date.now()}-${Math.random()}`;
567
+ };
568
+
569
+ const queueRawEventViaCli = async (body) => {
570
+ const result = await runCli(["enqueue-raw-event"], {
571
+ stdinText: JSON.stringify(body),
572
+ });
573
+ if (result?.exitCode !== 0) {
574
+ throw new Error(
575
+ `enqueue-raw-event failed (${result?.exitCode ?? "unknown"})`
576
+ );
577
+ }
578
+ return true;
579
+ };
580
+
581
+ const lastToastAtBySession = new Map();
582
+ const shouldToast = (sessionID) => {
583
+ const now = Date.now();
584
+ const last = lastToastAtBySession.get(sessionID) || 0;
585
+ if (now - last < 60000) {
586
+ return false;
587
+ }
588
+ lastToastAtBySession.set(sessionID, now);
589
+ return true;
590
+ };
591
+
592
+ const emitRawEvent = async ({ sessionID, type, payload }) => {
593
+ if (!rawEventsEnabled) {
594
+ return true;
595
+ }
596
+ if (!sessionID || !type) {
597
+ return false;
598
+ }
599
+ const now = Date.now();
600
+ const body = buildRawEventEnvelope({
601
+ sessionID,
602
+ type,
603
+ payload,
604
+ cwd,
605
+ project: resolveProjectName(project),
606
+ startedAt: sessionStartedAt,
607
+ nowMs: now,
608
+ nowMono:
609
+ typeof performance !== "undefined" && performance.now
610
+ ? performance.now()
611
+ : null,
612
+ nextEventId,
613
+ });
614
+
615
+ if (now < streamUnavailableUntil) {
616
+ try {
617
+ await queueRawEventViaCli(body);
618
+ fallbackFailureNoted = false;
619
+ if (payload && typeof payload === "object") {
620
+ payload._raw_enqueued = true;
621
+ }
622
+ return true;
623
+ } catch (fallbackErr) {
624
+ await logLine(
625
+ `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
626
+ fallbackErr
627
+ ).slice(0, 200)}`
628
+ );
629
+ if (!fallbackFailureNoted) {
630
+ fallbackFailureNoted = true;
631
+ try {
632
+ await client.app.log({
633
+ service: "codemem",
634
+ level: "error",
635
+ message: "codemem fallback enqueue failed during stream backoff",
636
+ extra: {
637
+ sessionID,
638
+ backoffMs: rawEventsBackoffMs,
639
+ },
640
+ });
641
+ } catch (logErr) {
642
+ // best-effort logging only
643
+ }
644
+ if (client.tui?.showToast && shouldToast(sessionID)) {
645
+ try {
646
+ await client.tui.showToast({
647
+ body: {
648
+ message: "codemem: fallback enqueue failed while stream is down",
649
+ variant: "error",
650
+ },
651
+ });
652
+ } catch (toastErr) {
653
+ // best-effort only
654
+ }
655
+ }
656
+ }
657
+ return false;
658
+ }
659
+ }
660
+ try {
661
+ if (now - lastStatusCheckAt >= Math.max(1000, rawEventsStatusCheckMs)) {
662
+ const statusResp = await fetch(rawEventsStatusUrl, { method: "GET" });
663
+ if (!statusResp.ok) {
664
+ throw new Error(`raw-events status failed (${statusResp.status})`);
665
+ }
666
+ const statusJson = await statusResp.json();
667
+ lastStatusAvailable = statusJson?.ingest?.available !== false;
668
+ lastStatusCheckAt = now;
669
+ }
670
+ if (!lastStatusAvailable) {
671
+ throw new Error("raw-events ingest unavailable");
672
+ }
673
+
674
+ const postResp = await fetch(rawEventsUrl, {
675
+ method: "POST",
676
+ headers: { "Content-Type": "application/json" },
677
+ body: JSON.stringify(body),
678
+ });
679
+ if (!postResp.ok) {
680
+ throw new Error(`raw-events post failed (${postResp.status})`);
681
+ }
682
+ streamUnavailableUntil = 0;
683
+ streamErrorNoted = false;
684
+ fallbackFailureNoted = false;
685
+ lastStatusAvailable = true;
686
+ if (payload && typeof payload === "object") {
687
+ payload._raw_enqueued = true;
688
+ }
689
+ return true;
690
+ } catch (err) {
691
+ streamUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
692
+ await logLine(`raw_events.error sessionID=${sessionID} type=${type} err=${String(err).slice(0, 200)}`);
693
+ try {
694
+ await client.app.log({
695
+ service: "codemem",
696
+ level: "error",
697
+ message: "Failed to stream raw events to codemem viewer",
698
+ extra: {
699
+ sessionID,
700
+ type,
701
+ viewerHost,
702
+ viewerPort,
703
+ error: String(err),
704
+ },
705
+ });
706
+ } catch (logErr) {
707
+ // best-effort logging only
708
+ }
709
+
710
+ let fallbackOk = false;
711
+ try {
712
+ await queueRawEventViaCli(body);
713
+ fallbackOk = true;
714
+ } catch (fallbackErr) {
715
+ await logLine(
716
+ `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
717
+ fallbackErr
718
+ ).slice(0, 200)}`
719
+ );
720
+ }
721
+
722
+ if (fallbackOk) {
723
+ fallbackFailureNoted = false;
724
+ if (payload && typeof payload === "object") {
725
+ payload._raw_enqueued = true;
726
+ }
727
+ if (!streamErrorNoted) {
728
+ streamErrorNoted = true;
729
+ try {
730
+ await client.app.log({
731
+ service: "codemem",
732
+ level: "warn",
733
+ message: "codemem stream unavailable; queued raw event via CLI fallback",
734
+ extra: {
735
+ sessionID,
736
+ backoffMs: rawEventsBackoffMs,
737
+ },
738
+ });
739
+ } catch (logErr) {
740
+ // best-effort logging only
741
+ }
742
+ }
743
+ if (client.tui?.showToast && shouldToast(sessionID)) {
744
+ try {
745
+ await client.tui.showToast({
746
+ body: {
747
+ message: "codemem: viewer stream unavailable; queue fallback active",
748
+ variant: "warning",
749
+ },
750
+ });
751
+ } catch (toastErr) {
752
+ // best-effort only
753
+ }
754
+ }
755
+ return true;
756
+ }
757
+
758
+ if (!streamErrorNoted) {
759
+ streamErrorNoted = true;
760
+ try {
761
+ await client.app.log({
762
+ service: "codemem",
763
+ level: "error",
764
+ message: "codemem stream unavailable; fallback enqueue failed",
765
+ extra: {
766
+ sessionID,
767
+ backoffMs: rawEventsBackoffMs,
768
+ },
769
+ });
770
+ } catch (logErr) {
771
+ // best-effort logging only
772
+ }
773
+ }
774
+
775
+ if (client.tui?.showToast && shouldToast(sessionID)) {
776
+ try {
777
+ await client.tui.showToast({
778
+ body: {
779
+ message: `codemem: stream unavailable (${viewerHost}:${viewerPort}); fallback failed`,
780
+ variant: "error",
781
+ },
782
+ });
783
+ } catch (toastErr) {
784
+ // best-effort only
785
+ }
786
+ }
787
+ return false;
788
+ }
789
+ };
790
+
791
+ const extractSessionID = (event) => {
792
+ if (!event) {
793
+ return null;
794
+ }
795
+ return event?.properties?.sessionID || null;
796
+ };
797
+
798
+ // Session context tracking for comprehensive memories
799
+ const sessionContext = {
800
+ firstPrompt: null,
801
+ promptCount: 0,
802
+ toolCount: 0,
803
+ startTime: null,
804
+ filesModified: new Set(),
805
+ filesRead: new Set(),
806
+ };
807
+
808
+ const resetSessionContext = () => {
809
+ sessionContext.firstPrompt = null;
810
+ sessionContext.promptCount = 0;
811
+ sessionContext.toolCount = 0;
812
+ sessionContext.startTime = null;
813
+ sessionContext.filesModified = new Set();
814
+ sessionContext.filesRead = new Set();
815
+ };
816
+
817
+ // Check if we should force flush immediately (threshold-based)
818
+ const shouldForceFlush = () => {
819
+ const { toolCount, promptCount } = sessionContext;
820
+ // Force flush if we've accumulated a lot of work
821
+ if (toolCount >= 50 || promptCount >= 15) {
822
+ return true;
823
+ }
824
+ // Force flush if session has been running for 10+ minutes
825
+ if (sessionContext.startTime) {
826
+ const sessionDurationMs = Date.now() - sessionContext.startTime;
827
+ if (sessionDurationMs >= 600000) { // 10 minutes
828
+ return true;
829
+ }
830
+ }
831
+ return false;
832
+ };
833
+
834
+
835
+ const updateActivity = () => {};
836
+
837
+ const extractPromptText = (event) => {
838
+ if (!event) {
839
+ return null;
840
+ }
841
+
842
+ // For message.updated events, track the role and check if we have buffered text
843
+ if (event.type === "message.updated" && event.properties?.info) {
844
+ const info = event.properties.info;
845
+ if (info.id && info.role) {
846
+ messageRoles.set(info.id, info.role);
847
+
848
+ // If we have buffered text for this message and it's a user message, return it
849
+ if (info.role === "user" && messageTexts.has(info.id)) {
850
+ const text = messageTexts.get(info.id);
851
+ messageTexts.delete(info.id); // Clean up
852
+ if (debugExtraction) {
853
+ logLine(
854
+ `user prompt captured from buffered text id=${info.id.slice(
855
+ -8
856
+ )} len=${text.length}`
857
+ );
858
+ }
859
+ return text;
860
+ }
861
+ }
862
+ return null;
863
+ }
864
+
865
+ // For message.part.updated events, accumulate or return text based on known role
866
+ if (event.type === "message.part.updated" && event.properties?.part) {
867
+ const part = event.properties.part;
868
+ if (part.type !== "text" || !part.text) {
869
+ return null;
870
+ }
871
+
872
+ const role = messageRoles.get(part.messageID);
873
+ if (role === "user") {
874
+ // We know it's a user message, return the text immediately
875
+ if (debugExtraction) {
876
+ logLine(
877
+ `user prompt captured immediately id=${part.messageID.slice(
878
+ -8
879
+ )} len=${part.text.length}`
880
+ );
881
+ }
882
+ return part.text.trim() || null;
883
+ } else if (!role) {
884
+ // Buffer this text until we know the role
885
+ const existing = messageTexts.get(part.messageID) || "";
886
+ messageTexts.set(part.messageID, existing + part.text);
887
+ if (debugExtraction) {
888
+ logLine(
889
+ `buffering text for unknown role id=${part.messageID.slice(
890
+ -8
891
+ )} len=${(existing + part.text).length}`
892
+ );
893
+ }
894
+ }
895
+ }
896
+
897
+ return null;
898
+ };
899
+
900
+ const extractAssistantText = (event) => {
901
+ if (!event) {
902
+ return null;
903
+ }
904
+
905
+ // Only capture assistant messages when complete (message.updated with finish)
906
+ if (event.type === "message.updated" && event.properties?.info) {
907
+ const info = event.properties.info;
908
+ if (info.id && info.role) {
909
+ messageRoles.set(info.id, info.role);
910
+
911
+ // Log when we see an assistant message.updated (debug only)
912
+ if (debugExtraction && info.role === "assistant") {
913
+ logLine(
914
+ `assistant message.updated id=${info.id.slice(
915
+ -8
916
+ )} finish=${!!info.finish} hasText=${messageTexts.has(
917
+ info.id
918
+ )} textLen=${messageTexts.get(info.id)?.length || 0}`
919
+ );
920
+ }
921
+
922
+ // Only return assistant text when message is finished
923
+ if (
924
+ info.role === "assistant" &&
925
+ info.finish &&
926
+ messageTexts.has(info.id)
927
+ ) {
928
+ const text = messageTexts.get(info.id);
929
+ messageTexts.delete(info.id); // Clean up
930
+ return text.trim() || null;
931
+ }
932
+ }
933
+ return null;
934
+ }
935
+
936
+ // For message.part.updated, store the latest text (don't capture yet)
937
+ // Store for ALL messages regardless of role - role might not be known yet
938
+ if (event.type === "message.part.updated" && event.properties?.part) {
939
+ const part = event.properties.part;
940
+ if (part.type === "text" && part.text) {
941
+ // Store latest text, will be captured on finish (for assistant) or on role discovery (for user)
942
+ if (debugExtraction) {
943
+ const prevLen = messageTexts.get(part.messageID)?.length || 0;
944
+ logLine(
945
+ `text part stored id=${part.messageID.slice(
946
+ -8
947
+ )} prevLen=${prevLen} newLen=${part.text.length} role=${
948
+ messageRoles.get(part.messageID) || "unknown"
949
+ }`
950
+ );
951
+ }
952
+ messageTexts.set(part.messageID, part.text);
953
+ }
954
+ }
955
+
956
+ return null;
957
+ };
958
+
959
+ const normalizeUsage = (usage) => {
960
+ if (!usage || typeof usage !== "object") {
961
+ return null;
962
+ }
963
+ const inputTokens = Number(usage.input_tokens || 0);
964
+ const outputTokens = Number(usage.output_tokens || 0);
965
+ const cacheCreationTokens = Number(usage.cache_creation_input_tokens || 0);
966
+ const cacheReadTokens = Number(usage.cache_read_input_tokens || 0);
967
+ const total = inputTokens + outputTokens + cacheCreationTokens;
968
+ if (!Number.isFinite(total) || total <= 0) {
969
+ return null;
970
+ }
971
+ return {
972
+ input_tokens: inputTokens,
973
+ output_tokens: outputTokens,
974
+ cache_creation_input_tokens: cacheCreationTokens,
975
+ cache_read_input_tokens: cacheReadTokens,
976
+ };
977
+ };
978
+
979
+ const extractAssistantUsage = (event) => {
980
+ if (!event || event.type !== "message.updated" || !event.properties?.info) {
981
+ return null;
982
+ }
983
+ const info = event.properties.info;
984
+ if (!info.id || info.role !== "assistant" || !info.finish) {
985
+ return null;
986
+ }
987
+ if (assistantUsageCaptured.has(info.id)) {
988
+ return null;
989
+ }
990
+ const usage = normalizeUsage(
991
+ info.usage || event.properties?.usage || event.usage
992
+ );
993
+ if (!usage) {
994
+ return null;
995
+ }
996
+ assistantUsageCaptured.add(info.id);
997
+ return { usage, id: info.id };
998
+ };
999
+
1000
+ const startViewer = () => {
1001
+ if (!viewerEnabled || !viewerAutoStart || viewerStarted) {
1002
+ return;
1003
+ }
1004
+ viewerStarted = true;
1005
+ log("info", "starting codemem viewer", { cwd });
1006
+ Bun.spawn({
1007
+ cmd: [runner, ...runnerArgs, "serve", "--background"],
1008
+ cwd,
1009
+ env: process.env,
1010
+ stdout: "pipe",
1011
+ stderr: "pipe",
1012
+ });
1013
+ };
1014
+
1015
+ const runCommand = async (cmd, options = {}) => {
1016
+ const { stdinText = null } = options;
1017
+ const proc = Bun.spawn({
1018
+ cmd,
1019
+ cwd,
1020
+ env: process.env,
1021
+ stdin: "pipe",
1022
+ stdout: "pipe",
1023
+ stderr: "pipe",
1024
+ });
1025
+ let stdinFailure = null;
1026
+ if (typeof stdinText === "string") {
1027
+ try {
1028
+ proc.stdin.write(stdinText);
1029
+ } catch (stdinErr) {
1030
+ stdinFailure = `stdin write failed: ${String(stdinErr)}`;
1031
+ }
1032
+ }
1033
+ try {
1034
+ proc.stdin.end();
1035
+ } catch (stdinErr) {
1036
+ if (!stdinFailure) {
1037
+ stdinFailure = `stdin close failed: ${String(stdinErr)}`;
1038
+ }
1039
+ }
1040
+ if (stdinFailure) {
1041
+ try {
1042
+ proc.kill();
1043
+ } catch (killErr) {
1044
+ // ignore
1045
+ }
1046
+ return { exitCode: 1, stdout: "", stderr: stdinFailure };
1047
+ }
1048
+ const resultPromise = Promise.all([
1049
+ proc.exited,
1050
+ new Response(proc.stdout).text(),
1051
+ new Response(proc.stderr).text(),
1052
+ ]).then(([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr }));
1053
+ if (!Number.isFinite(commandTimeout) || commandTimeout <= 0) {
1054
+ return resultPromise;
1055
+ }
1056
+ let timer = null;
1057
+ const timeoutPromise = new Promise((resolve) => {
1058
+ timer = setTimeout(() => {
1059
+ try {
1060
+ proc.kill();
1061
+ } catch (err) {
1062
+ // ignore
1063
+ }
1064
+ resolve({ exitCode: null, stdout: "", stderr: "timeout" });
1065
+ }, commandTimeout);
1066
+ });
1067
+ const result = await Promise.race([resultPromise, timeoutPromise]);
1068
+ if (timer) {
1069
+ clearTimeout(timer);
1070
+ }
1071
+ return result;
1072
+ };
1073
+
1074
+ const runCli = async (args, options = {}) =>
1075
+ runCommand([runner, ...runnerArgs, ...args], options);
1076
+
1077
+ const showToast = async (message, variant = "warning") => {
1078
+ if (backendUpdatePolicy === "off") {
1079
+ return;
1080
+ }
1081
+ if (!client.tui?.showToast) {
1082
+ return;
1083
+ }
1084
+ try {
1085
+ await client.tui.showToast({
1086
+ body: {
1087
+ message,
1088
+ variant,
1089
+ },
1090
+ });
1091
+ } catch (toastErr) {
1092
+ // best-effort only
1093
+ }
1094
+ };
1095
+
1096
+ const restartViewerAfterAutoUpdate = async () => {
1097
+ if (!viewerEnabled || !viewerAutoStart || !viewerStarted) {
1098
+ return { attempted: false, ok: false };
1099
+ }
1100
+ const restartResult = await runCli(["serve", "--restart"]);
1101
+ if (restartResult?.exitCode === 0) {
1102
+ await logLine("compat.auto_update_viewer_restart ok");
1103
+ return { attempted: true, ok: true };
1104
+ }
1105
+ await logLine(
1106
+ `compat.auto_update_viewer_restart_failed exit=${restartResult?.exitCode ?? "unknown"} stderr=${redactLog(
1107
+ (restartResult?.stderr || "").trim()
1108
+ )}`
1109
+ );
1110
+ return { attempted: true, ok: false };
1111
+ };
1112
+
1113
+ const verifyCliCompatibility = async () => {
1114
+ const minVersion = process.env.CODEMEM_MIN_VERSION || "0.9.20";
1115
+ const versionResult = await runCli(["version"]);
1116
+ if (!versionResult || versionResult.exitCode !== 0) {
1117
+ await logLine(
1118
+ `compat.version_check_failed exit=${versionResult?.exitCode ?? "unknown"} stderr=${
1119
+ versionResult?.stderr ? redactLog(versionResult.stderr.trim()) : ""
1120
+ }`
1121
+ );
1122
+ return;
1123
+ }
1124
+
1125
+ const currentVersion = (versionResult.stdout || "").trim();
1126
+ const parsedCurrent = parseSemver(currentVersion);
1127
+ const parsedMinimum = parseSemver(minVersion);
1128
+ if (!parsedCurrent || !parsedMinimum) {
1129
+ const guidance = resolveUpgradeGuidance({ runner, runnerFrom });
1130
+ await logLine(
1131
+ `compat.version_unparsed current=${redactLog(currentVersion || "")} required=${redactLog(minVersion)}`
1132
+ );
1133
+ await log("warn", "codemem compatibility check could not parse versions", {
1134
+ currentVersion,
1135
+ minVersion,
1136
+ runner,
1137
+ runnerFromSet: Boolean(String(runnerFrom || "").trim()),
1138
+ upgradeMode: guidance.mode,
1139
+ });
1140
+ await showToast(
1141
+ `codemem compatibility check could not parse versions (cli='${currentVersion || "unknown"}', required='${minVersion}'). Suggested action: ${guidance.action}`,
1142
+ "warning"
1143
+ );
1144
+ return;
1145
+ }
1146
+
1147
+ if (isVersionAtLeast(currentVersion, minVersion)) {
1148
+ return;
1149
+ }
1150
+
1151
+ const guidance = resolveUpgradeGuidance({ runner, runnerFrom });
1152
+ const message = `codemem CLI ${currentVersion || "unknown"} is older than required ${minVersion}`;
1153
+ await log("warn", message, {
1154
+ currentVersion,
1155
+ minVersion,
1156
+ runner,
1157
+ runnerFromSet: Boolean(String(runnerFrom || "").trim()),
1158
+ upgradeMode: guidance.mode,
1159
+ upgradeAction: guidance.action,
1160
+ });
1161
+ await logLine(
1162
+ `compat.version_mismatch current=${currentVersion} required=${minVersion} mode=${guidance.mode} note=${redactLog(guidance.note)}`
1163
+ );
1164
+
1165
+ const autoPlan = resolveAutoUpdatePlan({ runner, runnerFrom });
1166
+ if (backendUpdatePolicy === "auto") {
1167
+ if (autoPlan.allowed && Array.isArray(autoPlan.command) && autoPlan.command.length > 0) {
1168
+ const commandText = autoPlan.commandText || autoPlan.command.join(" ");
1169
+ await logLine(`compat.auto_update_start cmd=${redactLog(commandText)}`);
1170
+ const updateResult = await runCommand(autoPlan.command);
1171
+ await logLine(
1172
+ `compat.auto_update_result exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog(
1173
+ (updateResult?.stderr || "").trim()
1174
+ )}`
1175
+ );
1176
+
1177
+ const refreshedResult = await runCli(["version"]);
1178
+ const refreshedVersion = (refreshedResult?.stdout || "").trim();
1179
+ if (
1180
+ updateResult?.exitCode === 0
1181
+ && refreshedResult?.exitCode === 0
1182
+ && isVersionAtLeast(refreshedVersion, minVersion)
1183
+ ) {
1184
+ const viewerRestart = await restartViewerAfterAutoUpdate();
1185
+ await logLine(
1186
+ `compat.auto_update_success before=${currentVersion} after=${refreshedVersion}`
1187
+ );
1188
+ await showToast(
1189
+ `Updated codemem backend from ${currentVersion || "unknown"} to ${refreshedVersion}.`,
1190
+ "success"
1191
+ );
1192
+ if (viewerRestart.attempted && !viewerRestart.ok) {
1193
+ await showToast(
1194
+ "Backend updated, but viewer restart failed. Run `codemem serve --restart`.",
1195
+ "warning"
1196
+ );
1197
+ }
1198
+ return;
1199
+ }
1200
+
1201
+ await showToast(
1202
+ `${message}. Auto-update did not resolve it. Suggested action: ${guidance.action}`,
1203
+ "warning"
1204
+ );
1205
+ return;
1206
+ }
1207
+
1208
+ await logLine(
1209
+ `compat.auto_update_skipped reason=${autoPlan.reason || "not-eligible"}`
1210
+ );
1211
+ await showToast(
1212
+ `${message}. Auto-update skipped (${autoPlan.reason || "not eligible"}). Suggested action: ${guidance.action}`,
1213
+ "warning"
1214
+ );
1215
+ return;
1216
+ }
1217
+
1218
+ await showToast(`${message}. Suggested action: ${guidance.action}`, "warning");
1219
+ };
1220
+
1221
+ const resolveInjectQuery = () => {
1222
+ const parts = [];
1223
+
1224
+ // First prompt captures session intent (most stable signal)
1225
+ if (sessionContext.firstPrompt && sessionContext.firstPrompt.trim()) {
1226
+ parts.push(sessionContext.firstPrompt.trim());
1227
+ }
1228
+
1229
+ // Latest prompt adds current focus (skip if same as first, or trivial)
1230
+ if (
1231
+ lastPromptText &&
1232
+ lastPromptText.trim() &&
1233
+ lastPromptText.trim() !== (sessionContext.firstPrompt || "").trim() &&
1234
+ lastPromptText.trim().length > 5
1235
+ ) {
1236
+ parts.push(lastPromptText.trim());
1237
+ }
1238
+
1239
+ // Project name for scoping
1240
+ const projectName = resolveProjectName(project);
1241
+ if (projectName) {
1242
+ parts.push(projectName);
1243
+ }
1244
+
1245
+ // Recently modified files signal what area of the codebase we're in
1246
+ if (sessionContext.filesModified.size > 0) {
1247
+ const recentFiles = Array.from(sessionContext.filesModified)
1248
+ .slice(-5)
1249
+ .map((f) => f.split("/").pop())
1250
+ .join(" ");
1251
+ parts.push(recentFiles);
1252
+ }
1253
+
1254
+ if (parts.length === 0) {
1255
+ return "recent work";
1256
+ }
1257
+
1258
+ // Cap total length to avoid overly long CLI args
1259
+ const query = parts.join(" ");
1260
+ return query.length > 500 ? query.slice(0, 500) : query;
1261
+ };
1262
+
1263
+ const buildPackArgs = (query) => {
1264
+ const workingSetFiles = Array.from(sessionContext.filesModified)
1265
+ .slice(-8)
1266
+ .map((value) => String(value || "").trim())
1267
+ .filter(Boolean);
1268
+ const args = ["pack", query];
1269
+ if (injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0) {
1270
+ args.push("--limit", String(injectLimit));
1271
+ }
1272
+ if (injectTokenBudget !== null && Number.isFinite(injectTokenBudget) && injectTokenBudget > 0) {
1273
+ args.push("--token-budget", String(injectTokenBudget));
1274
+ }
1275
+ appendWorkingSetFileArgs(args, workingSetFiles);
1276
+ return args;
1277
+ };
1278
+
1279
+ const parsePackText = (stdout) => {
1280
+ if (!stdout || !stdout.trim()) {
1281
+ return "";
1282
+ }
1283
+ try {
1284
+ const payload = JSON.parse(stdout);
1285
+ return (payload?.pack_text || "").trim();
1286
+ } catch (err) {
1287
+ return "";
1288
+ }
1289
+ };
1290
+
1291
+ const parsePackMetrics = (stdout) => {
1292
+ if (!stdout || !stdout.trim()) {
1293
+ return null;
1294
+ }
1295
+ try {
1296
+ const payload = JSON.parse(stdout);
1297
+ return payload?.metrics || null;
1298
+ } catch (err) {
1299
+ return null;
1300
+ }
1301
+ };
1302
+
1303
+ const redactLog = (value, limit = 400) => {
1304
+ if (!value) return "";
1305
+ const masked = String(value).replace(/(Bearer\s+)[^\s]+/gi, "$1[redacted]");
1306
+ return masked.length > limit ? `${masked.slice(0, limit)}…` : masked;
1307
+ };
1308
+
1309
+ const buildInjectedContext = async (query) => {
1310
+ const packArgs = buildPackArgs(query);
1311
+ const result = await runCli(packArgs);
1312
+ if (!result || result.exitCode !== 0) {
1313
+ const exitCode = result?.exitCode ?? "unknown";
1314
+ const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
1315
+ const stdout = redactLog(result?.stdout ? result.stdout.trim() : "");
1316
+ const cmd = [runner, ...runnerArgs, ...packArgs].join(" ");
1317
+ await logLine(
1318
+ `inject.pack.error ${exitCode} cmd=${cmd}` +
1319
+ `${stderr ? ` stderr=${stderr}` : ""}` +
1320
+ `${stdout ? ` stdout=${stdout}` : ""}`
1321
+ );
1322
+ return "";
1323
+ }
1324
+ const packText = parsePackText(result.stdout);
1325
+ if (!packText) {
1326
+ return "";
1327
+ }
1328
+ const metrics = parsePackMetrics(result.stdout);
1329
+ if (metrics) {
1330
+ return {
1331
+ text: `[codemem context]\n${packText}`,
1332
+ metrics,
1333
+ };
1334
+ }
1335
+ return { text: `[codemem context]\n${packText}` };
1336
+ };
1337
+
1338
+ const stopViewer = async () => {
1339
+ if (!viewerEnabled || !viewerAutoStop || !viewerStarted) {
1340
+ return;
1341
+ }
1342
+ viewerStarted = false;
1343
+ await logLine("viewer stop requested");
1344
+ await runCli(["serve", "--stop"]);
1345
+ };
1346
+
1347
+ // Get version info (commit hash) for debugging
1348
+ let version = "unknown";
1349
+ try {
1350
+ const gitProc = Bun.spawn({
1351
+ cmd: ["git", "rev-parse", "--short", "HEAD"],
1352
+ cwd: runnerFrom,
1353
+ stdout: "pipe",
1354
+ stderr: "pipe",
1355
+ });
1356
+ const gitResult = await Promise.race([
1357
+ new Response(gitProc.stdout).text(),
1358
+ new Promise((resolve) => setTimeout(() => resolve("timeout"), 500)),
1359
+ ]);
1360
+ if (typeof gitResult === "string" && gitResult !== "timeout") {
1361
+ version = gitResult.trim();
1362
+ }
1363
+ } catch (err) {
1364
+ // Ignore - version will remain 'unknown'
1365
+ }
1366
+
1367
+ await log("info", "codemem plugin initialized", { cwd, version });
1368
+ await logLine(`plugin initialized cwd=${cwd} version=${version}`);
1369
+ startViewer();
1370
+ void verifyCliCompatibility().catch(async (err) => {
1371
+ await logLine(
1372
+ `compat.version_check_error message=${String(err?.message || err || "unknown")}`
1373
+ );
1374
+ });
1375
+
1376
+ const truncate = (value) => {
1377
+ if (value === undefined || value === null) {
1378
+ return null;
1379
+ }
1380
+ const text = String(value);
1381
+ if (Number.isNaN(maxChars) || maxChars <= 0) {
1382
+ return "";
1383
+ }
1384
+ if (text.length <= maxChars) {
1385
+ return text;
1386
+ }
1387
+ return `${text.slice(0, maxChars)}\n[codemem] event truncated\n`;
1388
+ };
1389
+
1390
+ const safeStringify = (value) => {
1391
+ if (value === undefined || value === null) {
1392
+ return null;
1393
+ }
1394
+ if (typeof value === "string") {
1395
+ return value;
1396
+ }
1397
+ try {
1398
+ return JSON.stringify(value);
1399
+ } catch (err) {
1400
+ return String(value);
1401
+ }
1402
+ };
1403
+
1404
+ const recordEvent = (event) => {
1405
+ events.push(event);
1406
+ trimEventQueue({
1407
+ events,
1408
+ maxEvents,
1409
+ hardMaxEvents: Math.max(maxEvents, rawEventsHardMax),
1410
+ onUnsentPressure: (queuedCount, cap) => {
1411
+ void logLine(`queue.pressure unsent_preserved queued=${queuedCount} max_events=${cap}`);
1412
+ },
1413
+ onForcedDrop: (dropped, queuedCount, hardCap) => {
1414
+ void logLine(
1415
+ `queue.drop hard_cap event_id=${dropped?._raw_event_id || "unknown"} queued=${queuedCount} hard_max=${hardCap}`
1416
+ );
1417
+ },
1418
+ });
1419
+ };
1420
+
1421
+ const captureEvent = (sessionID, event) => {
1422
+ const normalizedSessionID =
1423
+ typeof sessionID === "string" && sessionID.trim() ? sessionID.trim() : null;
1424
+ if (normalizedSessionID) {
1425
+ activeSessionID = normalizedSessionID;
1426
+ }
1427
+ const effectiveSessionID = normalizedSessionID || activeSessionID;
1428
+ const resolvedSessionID =
1429
+ effectiveSessionID || `missing:${Date.now()}:${String(nextEventId()).slice(0, 8)}`;
1430
+ if (!effectiveSessionID) {
1431
+ activeSessionID = resolvedSessionID;
1432
+ void logLine(`capture.fallback_session_id ${resolvedSessionID}`);
1433
+ }
1434
+ const adapterAnnotatedEvent = attachAdapterEvent({
1435
+ sessionID: resolvedSessionID,
1436
+ event,
1437
+ });
1438
+ const rawEventId =
1439
+ adapterAnnotatedEvent?._adapter?.event_id ||
1440
+ (adapterAnnotatedEvent && adapterAnnotatedEvent._raw_event_id) ||
1441
+ nextEventId();
1442
+ const queuedEvent = {
1443
+ ...adapterAnnotatedEvent,
1444
+ _raw_event_id: rawEventId,
1445
+ _raw_session_id: resolvedSessionID,
1446
+ _raw_retry_count: 0,
1447
+ };
1448
+ recordEvent(queuedEvent);
1449
+ void emitRawEvent({
1450
+ sessionID: resolvedSessionID,
1451
+ type: queuedEvent?.type || "unknown",
1452
+ payload: queuedEvent,
1453
+ });
1454
+ };
1455
+
1456
+ const flushEvents = async () => {
1457
+ if (!events.length) {
1458
+ await logLine("flush.skip empty");
1459
+ return;
1460
+ }
1461
+
1462
+ const batch = events.splice(0, events.length);
1463
+ if (!batch.length) {
1464
+ await logLine("flush.skip empty");
1465
+ return;
1466
+ }
1467
+
1468
+ const failed = [];
1469
+ for (const queuedEvent of batch) {
1470
+ if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_enqueued) {
1471
+ continue;
1472
+ }
1473
+ const queuedSessionID =
1474
+ queuedEvent?._raw_session_id ||
1475
+ queuedEvent?.properties?.sessionID ||
1476
+ null;
1477
+ const ok = await emitRawEvent({
1478
+ sessionID: queuedSessionID,
1479
+ type: queuedEvent?.type || "unknown",
1480
+ payload: queuedEvent,
1481
+ });
1482
+ if (!ok) {
1483
+ const currentRetry =
1484
+ typeof queuedEvent?._raw_retry_count === "number" && Number.isFinite(queuedEvent._raw_retry_count)
1485
+ ? queuedEvent._raw_retry_count
1486
+ : 0;
1487
+ const nextRetry = currentRetry + 1;
1488
+ failed.push({
1489
+ ...queuedEvent,
1490
+ _raw_retry_count: nextRetry,
1491
+ });
1492
+ }
1493
+ }
1494
+ if (failed.length) {
1495
+ events.unshift(...failed);
1496
+ await logLine(`flush.retry_deferred count=${failed.length}`);
1497
+ return;
1498
+ }
1499
+
1500
+ // Calculate session duration
1501
+ const durationMs = sessionContext.startTime
1502
+ ? Date.now() - sessionContext.startTime
1503
+ : 0;
1504
+ await logLine(
1505
+ `flush.stream_only finalize count=${batch.length} tools=${sessionContext.toolCount} prompts=${sessionContext.promptCount} duration=${Math.round(durationMs / 1000)}s`
1506
+ );
1507
+ await logLine(`flush.ok count=${batch.length}`);
1508
+ sessionStartedAt = null;
1509
+ resetSessionContext();
1510
+ };
1511
+
1512
+ return {
1513
+ "experimental.chat.system.transform": async (input, output) => {
1514
+ if (!injectEnabled) {
1515
+ return;
1516
+ }
1517
+ const query = resolveInjectQuery();
1518
+ if (debug) {
1519
+ await logLine(
1520
+ `inject.transform sessionID=${input.sessionID} query_len=${
1521
+ query ? query.length : 0
1522
+ } tui_toast=${Boolean(client.tui?.showToast)}`
1523
+ );
1524
+ }
1525
+ const cached = injectedSessions.get(input.sessionID);
1526
+ let contextText = cached?.text || "";
1527
+ if (!contextText || cached?.query !== query) {
1528
+ const injected = await buildInjectedContext(query);
1529
+ if (injected?.text) {
1530
+ injectedSessions.set(input.sessionID, {
1531
+ query,
1532
+ text: injected.text,
1533
+ metrics: injected.metrics || null,
1534
+ });
1535
+ contextText = injected.text;
1536
+
1537
+ if (!injectionToastShown.has(input.sessionID) && client.tui?.showToast) {
1538
+ injectionToastShown.add(input.sessionID);
1539
+ try {
1540
+ await client.tui.showToast({
1541
+ body: {
1542
+ message: buildInjectionToastMessage(injected.metrics),
1543
+ variant: "info",
1544
+ },
1545
+ });
1546
+ } catch (toastErr) {
1547
+ // best-effort only
1548
+ }
1549
+ }
1550
+ }
1551
+ }
1552
+ if (!contextText) {
1553
+ return;
1554
+ }
1555
+ if (!Array.isArray(output.system)) {
1556
+ output.system = [];
1557
+ }
1558
+ output.system.push(contextText);
1559
+ },
1560
+ event: async ({ event }) => {
1561
+ const eventType = event?.type || "unknown";
1562
+ const sessionID = extractSessionID(event);
1563
+
1564
+ // Always log session-related events for debugging /new
1565
+ if (eventType.startsWith("session.")) {
1566
+ await logLine(`SESSION EVENT: ${eventType}`);
1567
+ }
1568
+
1569
+ if (debugExtraction) {
1570
+ await logLine(`event ${eventType}`);
1571
+ }
1572
+
1573
+ // Debug: log event structure for message events (only when debug enabled)
1574
+ if (
1575
+ debugExtraction &&
1576
+ [
1577
+ "message.updated",
1578
+ "message.created",
1579
+ "message.appended",
1580
+ "message.part.updated",
1581
+ ].includes(eventType)
1582
+ ) {
1583
+ // Log full event structure for debugging (only first few times per event type)
1584
+ if (!global.eventLogCount) global.eventLogCount = {};
1585
+ if (!global.eventLogCount[eventType])
1586
+ global.eventLogCount[eventType] = 0;
1587
+ if (global.eventLogCount[eventType] < 2) {
1588
+ global.eventLogCount[eventType]++;
1589
+ await logLine(
1590
+ `FULL EVENT (${eventType}): ${JSON.stringify(
1591
+ event,
1592
+ null,
1593
+ 2
1594
+ ).substring(0, 3000)}`
1595
+ );
1596
+ }
1597
+
1598
+ await logLine(
1599
+ `event payload keys: ${Object.keys(event || {}).join(", ")}`
1600
+ );
1601
+ if (event?.properties) {
1602
+ await logLine(
1603
+ `event properties keys: ${Object.keys(event.properties).join(", ")}`
1604
+ );
1605
+ if (event.properties.role) {
1606
+ await logLine(`event role: ${event.properties.role}`);
1607
+ }
1608
+ if (event.properties.message) {
1609
+ await logLine(`event has properties.message`);
1610
+ }
1611
+ if (event.properties.info) {
1612
+ const infoKeys = Object.keys(event.properties.info);
1613
+ await logLine(`event properties.info keys: ${infoKeys.join(", ")}`);
1614
+ if (event.properties.info.role) {
1615
+ await logLine(`event info.role: ${event.properties.info.role}`);
1616
+ }
1617
+ }
1618
+ }
1619
+ }
1620
+
1621
+ if (
1622
+ [
1623
+ "message.updated",
1624
+ "message.created",
1625
+ "message.appended",
1626
+ "message.part.updated",
1627
+ ].includes(eventType)
1628
+ ) {
1629
+ const promptText = extractPromptText(event);
1630
+ if (promptText) {
1631
+ // Update activity tracking
1632
+ updateActivity();
1633
+
1634
+ // Track session context
1635
+ if (!sessionContext.firstPrompt) {
1636
+ sessionContext.firstPrompt = promptText;
1637
+ sessionContext.startTime = Date.now();
1638
+ }
1639
+ sessionContext.promptCount++;
1640
+
1641
+ // Check for /new command and flush before session reset
1642
+ if (
1643
+ promptText.trim() === "/new" ||
1644
+ promptText.trim().startsWith("/new ")
1645
+ ) {
1646
+ await logLine("detected /new command, flushing events");
1647
+ await flushEvents();
1648
+ }
1649
+
1650
+ if (promptText !== lastPromptText) {
1651
+ promptCounter += 1;
1652
+ // promptCount incremented when capturing user_prompt
1653
+
1654
+ lastPromptText = promptText;
1655
+ captureEvent(sessionID, {
1656
+ type: "user_prompt",
1657
+ prompt_number: promptCounter,
1658
+ prompt_text: promptText,
1659
+ timestamp: new Date().toISOString(),
1660
+ });
1661
+ await logLine(
1662
+ `user_prompt captured #${promptCounter}: ${promptText.substring(
1663
+ 0,
1664
+ 50
1665
+ )}`
1666
+ );
1667
+
1668
+ // Check if we should force flush due to threshold
1669
+ if (shouldForceFlush()) {
1670
+ await logLine(`force flush triggered: tools=${sessionContext.toolCount}, prompts=${sessionContext.promptCount}, duration=${Math.round((Date.now() - (sessionContext.startTime || Date.now())) / 1000)}s`);
1671
+ await flushEvents();
1672
+ }
1673
+ }
1674
+ }
1675
+
1676
+ const assistantText = extractAssistantText(event);
1677
+ if (assistantText && assistantText !== lastAssistantText) {
1678
+ updateActivity();
1679
+ lastAssistantText = assistantText;
1680
+ captureEvent(sessionID, {
1681
+ type: "assistant_message",
1682
+ assistant_text: assistantText,
1683
+ timestamp: new Date().toISOString(),
1684
+ });
1685
+ await logLine(
1686
+ `assistant_message captured: ${assistantText.substring(0, 50)}`
1687
+ );
1688
+ }
1689
+
1690
+ const assistantUsage = extractAssistantUsage(event);
1691
+ if (assistantUsage) {
1692
+ updateActivity();
1693
+ captureEvent(sessionID, {
1694
+ type: "assistant_usage",
1695
+ message_id: assistantUsage.id,
1696
+ usage: assistantUsage.usage,
1697
+ timestamp: new Date().toISOString(),
1698
+ });
1699
+ await logLine(
1700
+ `assistant_usage captured id=${assistantUsage.id.slice(-8)}`
1701
+ );
1702
+ }
1703
+ }
1704
+
1705
+ // NEW ACCUMULATION STRATEGY
1706
+ // Only flush on:
1707
+ // - session.error (immediate error boundary)
1708
+ // - session.idle AFTER delay (scheduled via timeout)
1709
+ // - /new command (handled above)
1710
+ // - session.created (session boundary)
1711
+ //
1712
+ // REMOVED: session.compacted, session.compacting (too frequent)
1713
+ if (eventType === "session.error") {
1714
+ await logLine("session.error detected, flushing immediately");
1715
+ await flushEvents();
1716
+ }
1717
+
1718
+ if (eventType === "session.idle") {
1719
+ await logLine(
1720
+ `session.idle detected, flushing immediately (tools=${sessionContext.toolCount}, prompts=${sessionContext.promptCount})`
1721
+ );
1722
+ await flushEvents();
1723
+ }
1724
+
1725
+ if (eventType === "session.created") {
1726
+ if (events.length) {
1727
+ await flushEvents();
1728
+ }
1729
+ activeSessionID = sessionID || null;
1730
+ sessionStartedAt = new Date().toISOString();
1731
+ promptCounter = 0;
1732
+ lastPromptText = null;
1733
+ lastAssistantText = null;
1734
+ resetSessionContext();
1735
+ startViewer();
1736
+ }
1737
+ if (eventType === "session.deleted") {
1738
+ activeSessionID = null;
1739
+ await stopViewer();
1740
+ }
1741
+ },
1742
+ "tool.execute.after": async (input, output) => {
1743
+ const args = output?.args ?? input?.args ?? {};
1744
+ const result = output?.result ?? output?.output ?? output?.data ?? null;
1745
+ const error = output?.error ?? null;
1746
+ const toolName = input?.tool || output?.tool || "unknown";
1747
+
1748
+ // Update activity and session context
1749
+ updateActivity();
1750
+ sessionContext.toolCount++;
1751
+
1752
+ // Track files from tool events
1753
+ const filePath = args.filePath || args.path;
1754
+ if (filePath) {
1755
+ const lowerTool = toolName.toLowerCase();
1756
+ if (lowerTool === "edit" || lowerTool === "write") {
1757
+ sessionContext.filesModified.add(filePath);
1758
+ } else if (lowerTool === "read") {
1759
+ sessionContext.filesRead.add(filePath);
1760
+ }
1761
+ }
1762
+ if (toolName.toLowerCase() === "apply_patch") {
1763
+ const patchPaths = extractApplyPatchPaths(args.patchText);
1764
+ for (const path of patchPaths) {
1765
+ sessionContext.filesModified.add(path);
1766
+ }
1767
+ }
1768
+
1769
+ captureEvent(input?.sessionID || null, {
1770
+ type: "tool.execute.after",
1771
+ tool: toolName,
1772
+ args,
1773
+ result: truncate(safeStringify(result)),
1774
+ error: truncate(safeStringify(error)),
1775
+ timestamp: new Date().toISOString(),
1776
+ });
1777
+ await logLine(`tool.execute.after ${toolName} queued=${events.length} tools=${sessionContext.toolCount}`);
1778
+
1779
+ // Check if we should force flush due to threshold
1780
+ if (shouldForceFlush()) {
1781
+ await logLine(`force flush triggered: tools=${sessionContext.toolCount}, prompts=${sessionContext.promptCount}, duration=${Math.round((Date.now() - (sessionContext.startTime || Date.now())) / 1000)}s`);
1782
+ await flushEvents();
1783
+ }
1784
+ },
1785
+ tool: {
1786
+ "mem-status": tool({
1787
+ description: "Show codemem stats and recent entries",
1788
+ args: {},
1789
+ async execute() {
1790
+ const stats = await runCli(["stats"]);
1791
+ const recent = await runCli(["recent", "--limit", "5"]);
1792
+ const lines = [
1793
+ `viewer: http://${viewerHost}:${viewerPort}`,
1794
+ `log: ${logPath || "disabled"}`,
1795
+ ];
1796
+ if (stats.exitCode === 0 && stats.stdout.trim()) {
1797
+ lines.push("", "stats:", stats.stdout.trim());
1798
+ }
1799
+ if (recent.exitCode === 0 && recent.stdout.trim()) {
1800
+ lines.push("", "recent:", recent.stdout.trim());
1801
+ }
1802
+ return lines.join("\n");
1803
+ },
1804
+ }),
1805
+
1806
+ "mem-recent": tool({
1807
+ description: "Show recent codemem entries",
1808
+ args: {
1809
+ limit: tool.schema.number().optional(),
1810
+ },
1811
+ async execute({ limit }) {
1812
+ const safeLimit = Number.isFinite(limit) ? String(limit) : "5";
1813
+ const recent = await runCli(["recent", "--limit", safeLimit]);
1814
+ if (recent.exitCode === 0) {
1815
+ return recent.stdout.trim() || "No recent memories.";
1816
+ }
1817
+ return `Failed to fetch recent: ${recent.stderr || recent.exitCode}`;
1818
+ },
1819
+ }),
1820
+
1821
+ "mem-stats": tool({
1822
+ description: "Show codemem stats",
1823
+ args: {},
1824
+ async execute() {
1825
+ const stats = await runCli(["stats"]);
1826
+ if (stats.exitCode === 0) {
1827
+ return stats.stdout.trim() || "No stats yet.";
1828
+ }
1829
+ return `Failed to fetch stats: ${stats.stderr || stats.exitCode}`;
1830
+ },
1831
+ }),
1832
+ },
1833
+ };
1834
+ };
1835
+
1836
+ export default OpencodeMemPlugin;
1837
+ export const __testUtils = {
1838
+ PINNED_BACKEND_VERSION,
1839
+ DEFAULT_UVX_SOURCE,
1840
+ buildRunnerArgs,
1841
+ appendWorkingSetFileArgs,
1842
+ extractApplyPatchPaths,
1843
+ mapOpencodeEventTypeToAdapterType,
1844
+ buildOpencodeAdapterPayload,
1845
+ buildOpencodeAdapterEvent,
1846
+ attachAdapterEvent,
1847
+ selectRawEventId,
1848
+ buildRawEventEnvelope,
1849
+ trimEventQueue,
1850
+ parsePositiveInt,
1851
+ };