codemem 0.20.8 → 0.20.9

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