@codemem/opencode-plugin 0.44.2 → 0.45.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4654 +1,169 @@
1
- import { appendFile, mkdir } from "node:fs/promises";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { basename, dirname, join, posix, resolve, win32 } from "node:path";
4
- import { homedir } from "node:os";
5
- import { createHash } from "node:crypto";
6
- import { spawn as nodeSpawn, execSync } from "node:child_process";
7
1
  import { tool } from "@opencode-ai/plugin";
2
+ import { createDelegationContext } from "../lib/delegation-context.js";
8
3
 
4
+ import { createRuntimeHost, createRuntimeLocation } from "../lib/host-contract.js";
9
5
  import {
10
- isVersionAtLeast,
11
- parseBackendUpdatePolicy,
12
- parseSemver,
13
- resolveAutoUpdatePlan,
14
- resolveUpgradeGuidance,
15
- } from "../lib/compat.js";
16
- import {
17
- DEFAULT_DRAIN_LIMIT,
18
- DEFAULT_MAX_ENTRIES,
19
- RAW_EVENT_SPOOL_FULL_CODE,
20
- loadRawEventSpoolEntries,
21
- removeRawEventSpoolEntry,
22
- resolveSpoolDirectory,
23
- writeRawEventSpoolEntry,
24
- } from "../lib/raw-event-spool.js";
25
-
26
- const TRUTHY_VALUES = ["1", "true", "yes"];
27
- const DISABLED_VALUES = ["0", "false", "off"];
28
- const PINNED_BACKEND_VERSION = "0.44.2";
29
- const COMPAT_CHECK_DELAY_MS = 1500;
30
- const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
31
- const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
32
- const MAX_UPDATE_ACTION_CHARS = 1000;
33
- const MAX_UPDATE_VERSION_CHARS = 128;
34
- const RELEASE_VERSION =
35
- /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
36
- const CODEMEM_CONTEXT_PART_ID_PREFIX = "codemem-context-";
37
- const MAX_MESSAGE_INJECTION_CACHE_SESSIONS = 20;
38
- const COMPACTION_INJECTION_SKIP_TTL_MS = 30 * 1000;
39
- const MAX_WORKING_SET_PATH_CHARS = 400;
40
- const VIEWER_HEALTH_CHECK_INTERVAL_MS = 60_000;
41
- const VIEWER_HEALTH_TIMEOUT_MS = 5_000;
42
- const VIEWER_HEALTH_RESTART_THRESHOLD = 3;
43
- const VIEWER_HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
44
- const RAW_EVENTS_STATUS_TIMEOUT_MS = 5_000;
45
- const DEFAULT_INJECT_TOKEN_BUDGET = 800;
46
- const DEFAULT_RETAINED_TOKEN_BUDGET = Infinity;
47
- const CODEMEM_CONTEXT_PREFIX = "[codemem context]\n";
48
- const DEFAULT_EMBEDDING_MODEL = "Xenova/bge-small-en-v1.5";
49
- const DEFAULT_EMBEDDING_REVISION = "ea104dacec62c0de699686887e3f920caeb4f3e3";
50
- const PLUGIN_REGISTRATIONS_KEY = Symbol.for("codemem.opencode-plugin.registrations");
51
-
52
- let compatCheckCache = null;
53
- const notifiedReleaseVersions = new Set();
54
-
55
- const claimPluginRegistration = (cwd) => {
56
- let registrations = globalThis[PLUGIN_REGISTRATIONS_KEY];
57
- if (!(registrations instanceof Map)) {
58
- registrations = new Map();
59
- globalThis[PLUGIN_REGISTRATIONS_KEY] = registrations;
60
- }
61
- const projectPath = resolve(cwd);
62
- if (registrations.has(projectPath)) return null;
63
- const registration = Symbol(projectPath);
64
- registrations.set(projectPath, registration);
65
- return () => {
66
- if (registrations.get(projectPath) === registration) {
67
- registrations.delete(projectPath);
68
- }
69
- };
70
- };
71
- const rawEventSpoolDrainsInFlight = new Map();
72
-
73
- // Release an unread response body without surfacing cancellation failures.
74
- const discardResponseBody = (response) => {
75
- try {
76
- const cancelled = response?.body?.cancel?.();
77
- if (cancelled && typeof cancelled.catch === "function") {
78
- cancelled.catch(() => {});
79
- }
80
- } catch {
81
- // Best effort — a locked or already-errored stream is fine to abandon.
6
+ __testUtils,
7
+ buildInjectionToastMessage,
8
+ createCodememRuntime,
9
+ } from "../lib/runtime.js";
10
+
11
+ const extractV1SessionID = (event) => {
12
+ if (!event || typeof event !== "object") return null;
13
+ const properties = event.properties;
14
+ if (!properties || typeof properties !== "object") return null;
15
+ if (typeof properties.sessionID === "string") return properties.sessionID;
16
+ if (typeof properties.info?.sessionID === "string") return properties.info.sessionID;
17
+ if (typeof properties.part?.sessionID === "string") return properties.part.sessionID;
18
+ if (
19
+ ["session.created", "session.updated", "session.deleted"].includes(event.type)
20
+ && typeof properties.info?.id === "string"
21
+ ) {
22
+ return properties.info.id;
82
23
  }
24
+ return null;
83
25
  };
84
26
 
85
- // Bounded ingest-availability preflight: a hung viewer socket must not stall
86
- // raw-event delivery indefinitely. Failures fall into the existing stream
87
- // backoff + CLI enqueue fallback path.
88
- const fetchRawEventsStatus = (url, fetchFn = fetch) =>
89
- fetchFn(url, {
90
- method: "GET",
91
- signal: AbortSignal.timeout(RAW_EVENTS_STATUS_TIMEOUT_MS),
92
- });
93
-
94
- const normalizeEnvValue = (value) => (value || "").toLowerCase();
95
- const envHasValue = (value, truthyValues) =>
96
- truthyValues.includes(normalizeEnvValue(value));
97
- const envNotDisabled = (value) =>
98
- !DISABLED_VALUES.includes(normalizeEnvValue(value));
99
-
100
- const createViewerHealthMonitor = ({
101
- viewerHealthUrl,
102
- legacyStatusUrl,
103
- isActive,
104
- restartViewer,
105
- logLine,
106
- fetchFn = fetch,
107
- now = Date.now,
108
- setIntervalFn = setInterval,
109
- clearIntervalFn = clearInterval,
110
- timeoutSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs),
111
- }) => {
112
- let timer = null;
113
- let consecutiveFailures = 0;
114
- let lastRestartAttempt = 0;
115
-
116
- const boundedFetch = (url) => fetchFn(url, {
117
- method: "GET",
118
- signal: timeoutSignal(VIEWER_HEALTH_TIMEOUT_MS),
119
- });
120
-
121
- const probe = async () => {
122
- let response;
123
- try {
124
- response = await boundedFetch(viewerHealthUrl);
125
- } catch (error) {
126
- return { live: false, detail: `error: ${String(error).slice(0, 200)}` };
127
- }
128
-
129
- if (response.status === 404) {
130
- discardResponseBody(response);
131
- try {
132
- // Old-viewer compatibility: released viewers serving this route
133
- // always include the `ingest` availability object, so require that
134
- // identifying evidence rather than trusting any 2xx from an
135
- // arbitrary local service.
136
- const fallbackResponse = await boundedFetch(legacyStatusUrl);
137
- if (!fallbackResponse.ok) {
138
- discardResponseBody(fallbackResponse);
139
- return { live: false, detail: `fallback status=${fallbackResponse.status}` };
140
- }
141
- const fallbackPayload = await fallbackResponse.json();
142
- const looksLikeViewer =
143
- fallbackPayload &&
144
- typeof fallbackPayload === "object" &&
145
- fallbackPayload.ingest &&
146
- typeof fallbackPayload.ingest === "object";
147
- return looksLikeViewer
148
- ? { live: true }
149
- : { live: false, detail: "fallback unexpected payload" };
150
- } catch (error) {
151
- return { live: false, detail: `fallback error: ${String(error).slice(0, 200)}` };
152
- }
153
- }
154
-
155
- if (!response.ok) {
156
- // Release the unread body so a persistently failing viewer does not
157
- // pin connections across the 60s monitor interval.
158
- discardResponseBody(response);
159
- return { live: false, detail: `status=${response.status}` };
160
- }
161
-
162
- let payload;
163
- try {
164
- payload = await response.json();
165
- } catch (error) {
166
- return { live: false, detail: `invalid JSON: ${String(error).slice(0, 200)}` };
167
- }
168
- if (!payload || typeof payload !== "object" || payload.service !== "codemem-viewer") {
169
- return { live: false, detail: "unexpected service" };
170
- }
171
- return { live: true };
172
- };
173
-
174
- const check = async () => {
175
- if (!isActive()) return;
176
- const result = await probe();
177
- if (result.live) {
178
- if (consecutiveFailures > 0) {
179
- await logLine(`viewer.health recovered after ${consecutiveFailures} failure(s)`);
180
- }
181
- consecutiveFailures = 0;
182
- return;
183
- }
184
-
185
- consecutiveFailures += 1;
186
- await logLine(
187
- `viewer.health check failed (${result.detail}, consecutive=${consecutiveFailures})`
188
- );
189
- if (
190
- consecutiveFailures < VIEWER_HEALTH_RESTART_THRESHOLD ||
191
- now() - lastRestartAttempt < VIEWER_HEALTH_RESTART_COOLDOWN_MS
192
- ) {
193
- return;
194
- }
195
-
196
- // Re-check after the awaited probe: a stop requested while the probe was
197
- // in flight must not be undone by a restart.
198
- if (!isActive()) return;
199
-
200
- lastRestartAttempt = now();
201
- await logLine(`viewer.health restarting viewer after ${consecutiveFailures} consecutive failures`);
202
- try {
203
- const restartResult = await restartViewer();
204
- const restarted = restartResult?.exitCode === 0;
205
- await logLine(
206
- `viewer.health restart ${restarted ? "succeeded" : "failed"} (exit=${restartResult?.exitCode ?? "unknown"})`
207
- );
208
- if (restarted) consecutiveFailures = 0;
209
- } catch (error) {
210
- await logLine(`viewer.health restart error: ${String(error).slice(0, 200)}`);
211
- }
212
- };
213
-
214
- const start = () => {
215
- if (timer) return;
216
- consecutiveFailures = 0;
217
- timer = setIntervalFn(() => {
218
- check().catch(() => {});
219
- }, VIEWER_HEALTH_CHECK_INTERVAL_MS);
220
- if (timer?.unref) timer.unref();
221
- };
222
-
223
- const stop = () => {
224
- if (timer) {
225
- clearIntervalFn(timer);
226
- timer = null;
227
- }
228
- consecutiveFailures = 0;
229
- };
230
-
231
- return {
232
- check,
233
- start,
234
- stop,
235
- state: () => ({
236
- consecutiveFailures,
237
- lastRestartAttempt,
238
- running: timer !== null,
239
- }),
240
- };
241
- };
242
-
243
- const resolveInjectSurface = (value) => {
244
- const normalized = String(value || "message").trim().toLowerCase();
245
- if (normalized === "system") {
246
- return "system";
247
- }
248
- return "message";
27
+ const extractV1HookSessionID = (input) => {
28
+ if (!input || typeof input !== "object") return null;
29
+ return input.sessionID
30
+ || input.sessionId
31
+ || input.session?.id
32
+ || input.session?.sessionID
33
+ || input.properties?.sessionID
34
+ || null;
249
35
  };
250
36
 
251
- const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key);
252
-
253
- const deterministicUuid = (namespace, components) => {
254
- const hex = createHash("sha256")
255
- .update(JSON.stringify([namespace, ...components.map((value) => String(value ?? ""))]))
256
- .digest("hex");
257
- const bytes = Buffer.from(hex.slice(0, 32), "hex");
258
- bytes[6] = (bytes[6] & 0x0f) | 0x50;
259
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
260
- const value = bytes.toString("hex");
261
- return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
262
- };
37
+ const translateV1Event = (event) => ({
38
+ type: event?.type || "unknown",
39
+ sessionID: extractV1SessionID(event),
40
+ messageInfo: event?.properties?.info || null,
41
+ part: event?.properties?.part || null,
42
+ usage: event?.properties?.usage || event?.usage || null,
43
+ raw: event,
44
+ });
263
45
 
264
- const promptPackIdentity = ({
265
- source = "opencode",
266
- sessionID = "unknown",
267
- requestKey,
268
- surface,
269
- promptNumber = 0,
270
- queryHash,
271
- }) => {
272
- const components = [source, sessionID, requestKey, surface, promptNumber, queryHash];
273
- return {
274
- attemptId: deterministicUuid("codemem.prompt-pack.attempt.v1", components),
275
- requestId: deterministicUuid("codemem.prompt-pack.request.v1", components),
276
- };
277
- };
46
+ const translateV1PromptInput = (input) => ({
47
+ sessionID: input.sessionID,
48
+ });
278
49
 
279
- const hashPromptPackQuery = (query) =>
280
- createHash("sha256").update(String(query || "")).digest("hex");
50
+ const translateV1ToolResult = (input, output) => ({
51
+ input: {
52
+ sessionID: input.sessionID,
53
+ tool: input.tool,
54
+ args: input.args,
55
+ },
56
+ output: {
57
+ output: output.output,
58
+ },
59
+ });
281
60
 
282
- const redactPackCommand = (runner, runnerArgs, packArgs) => {
283
- const safePackArgs = [...packArgs];
284
- if (safePackArgs[0] === "pack" && safePackArgs.length > 1) {
285
- safePackArgs[1] = "[query-redacted]";
286
- }
287
- for (let index = 0; index < safePackArgs.length - 1; index += 1) {
288
- if (safePackArgs[index] === "--working-set-file") {
289
- safePackArgs[index + 1] = "[path-redacted]";
61
+ const createV1Tool = (definition) => {
62
+ const args = {};
63
+ for (const [name, argument] of Object.entries(definition.args)) {
64
+ if (argument.type !== "number") {
65
+ throw new Error(`Unsupported OpenCode 1 tool argument: ${name}:${argument.type}`);
290
66
  }
67
+ const schema = tool.schema.number();
68
+ args[name] = argument.optional ? schema.optional() : schema;
291
69
  }
292
- return [runner, ...runnerArgs, ...safePackArgs].join(" ");
293
- };
294
-
295
- const rejectsInternalLedgerFlag = (result) => {
296
- if (!result || result.exitCode === 0) return false;
297
- const diagnostics = `${result.stderr || ""}\n${result.stdout || ""}`;
298
- return diagnostics.includes("--internal-ledger")
299
- && /(?:unknown|unsupported|unrecognized|invalid)\s+(?:option|argument)/i.test(diagnostics);
70
+ return tool({
71
+ description: definition.description,
72
+ args,
73
+ execute: definition.execute,
74
+ });
300
75
  };
301
76
 
302
- const parseFallbackStructuredError = (stdout) => {
303
- const lines = String(stdout || "").trim().split(/\r?\n/).reverse();
304
- for (const line of lines) {
77
+ const dispatchV1Event = async (delegation, runtime, event) => {
78
+ const translated = translateV1Event(event);
79
+ if (event?.type === "session.deleted") {
305
80
  try {
306
- const parsed = JSON.parse(line);
307
- if (parsed && typeof parsed === "object") {
308
- return {
309
- code: typeof parsed.error === "string" ? parsed.error : "",
310
- message: typeof parsed.message === "string" ? parsed.message : "",
311
- };
81
+ for (const childSessionID of delegation.boundChildren(translated.sessionID)) {
82
+ await runtime.capturePendingPrompts({ sessionID: childSessionID });
312
83
  }
313
- } catch {
314
- // Continue past non-JSON CLI output.
315
- }
316
- }
317
- return { code: "", message: "" };
318
- };
319
-
320
- const classifyFallbackCommandResult = (result) => {
321
- const structured = parseFallbackStructuredError(result?.stdout);
322
- const diagnostic = [structured.code, structured.message, result?.stderr, result?.stdout]
323
- .filter(Boolean)
324
- .join("\n");
325
- if (/SQLITE_(?:BUSY|LOCKED)|database(?: table)? (?:is )?(?:busy|locked)/i.test(diagnostic)) {
326
- return { retryable: true, cause: "SQLite database is locked" };
327
- }
328
- if (result?.exitCode == null && /(?:timeout|timed out|ETIMEDOUT)/i.test(diagnostic)) {
329
- return { retryable: true, cause: "enqueue-raw-event command timeout" };
330
- }
331
- if (structured.code === "validation_error" || /invalid raw event|session id required/i.test(diagnostic)) {
332
- return { retryable: false, cause: "enqueue-raw-event validation failed" };
333
- }
334
- if (/unknown command ['\"]?enqueue-raw-event|command not found|\bENOENT\b/i.test(diagnostic)) {
335
- return { retryable: false, cause: "enqueue-raw-event command unavailable" };
336
- }
337
- const exitCode = result?.exitCode ?? "unknown";
338
- return { retryable: false, cause: `enqueue-raw-event failed (${exitCode})` };
339
- };
340
-
341
- const DEFAULT_LOG_PATH = (homeDir, cwd) => `${homeDir || cwd}/.codemem/plugin.log`;
342
-
343
- const resolveLogPath = (logPathEnvRaw, cwd, homeDir) => {
344
- const logPathEnv = normalizeEnvValue(logPathEnvRaw);
345
- const logEnabled = !!logPathEnvRaw && !DISABLED_VALUES.includes(logPathEnv);
346
- if (!logEnabled) {
347
- return null;
348
- }
349
- if (["true", "yes", "1"].includes(logPathEnv)) {
350
- return DEFAULT_LOG_PATH(homeDir, cwd);
351
- }
352
- return logPathEnvRaw;
353
- };
354
-
355
- /** Path for error/warning logging — always available regardless of debug flag. */
356
- const resolveErrorLogPath = (cwd, homeDir) => DEFAULT_LOG_PATH(homeDir, cwd);
357
-
358
- const resolveCompatCheckCacheKey = ({ backendUpdatePolicy, minVersion, runner, runnerFrom }) =>
359
- [backendUpdatePolicy, minVersion, runner, runnerFrom || ""].join("|");
360
-
361
- const readCompatCheckCache = (cacheKey) => {
362
- if (!compatCheckCache) {
363
- return null;
364
- }
365
- if (compatCheckCache.cacheKey !== cacheKey) {
366
- return null;
367
- }
368
- if (Date.now() >= compatCheckCache.expiresAtMs) {
369
- compatCheckCache = null;
370
- return null;
371
- }
372
- return compatCheckCache.value;
373
- };
374
-
375
- const writeCompatCheckCache = (cacheKey, value) => {
376
- compatCheckCache = {
377
- cacheKey,
378
- expiresAtMs: Date.now() + COMPAT_CHECK_CACHE_TTL_MS,
379
- value,
380
- };
381
- };
382
-
383
- const clearCompatCheckCache = () => {
384
- compatCheckCache = null;
385
- };
386
-
387
- const parseReleaseVersion = (value) => {
388
- if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return null;
389
- const match = RELEASE_VERSION.exec(value);
390
- if (!match || !match.slice(1, 4).map(Number).every(Number.isSafeInteger)) return null;
391
- const prerelease = match[4]?.split(".") || [];
392
- if (prerelease.some((identifier) => /^0\d+$/.test(identifier))) return null;
393
- return { core: match.slice(1, 4).map(Number), prerelease };
394
- };
395
-
396
- const releaseChannelForVersion = (value) => {
397
- const parsed = parseReleaseVersion(value);
398
- if (!parsed) return null;
399
- if (parsed.prerelease.length === 0) return "latest";
400
- const channel = parsed.prerelease[0];
401
- return channel === "alpha" || channel === "beta" || channel === "rc" ? channel : null;
402
- };
403
-
404
- const comparePrereleaseIdentifier = (left, right) => {
405
- if (left === undefined) return right === undefined ? 0 : -1;
406
- if (right === undefined) return 1;
407
- if (left === right) return 0;
408
- const leftNumeric = /^\d+$/.test(left);
409
- const rightNumeric = /^\d+$/.test(right);
410
- if (leftNumeric && rightNumeric && left.length !== right.length) {
411
- return Math.sign(left.length - right.length);
412
- }
413
- if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
414
- return left < right ? -1 : 1;
415
- };
416
-
417
- const compareReleaseVersions = (left, right) => {
418
- const leftVersion = parseReleaseVersion(left);
419
- const rightVersion = parseReleaseVersion(right);
420
- if (!leftVersion || !rightVersion) return null;
421
- for (let index = 0; index < leftVersion.core.length; index += 1) {
422
- const difference = leftVersion.core[index] - rightVersion.core[index];
423
- if (difference !== 0) return Math.sign(difference);
424
- }
425
- if (leftVersion.prerelease.length === 0) {
426
- return rightVersion.prerelease.length === 0 ? 0 : 1;
427
- }
428
- if (rightVersion.prerelease.length === 0) return -1;
429
- const length = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length);
430
- for (let index = 0; index < length; index += 1) {
431
- const comparison = comparePrereleaseIdentifier(
432
- leftVersion.prerelease[index],
433
- rightVersion.prerelease[index],
434
- );
435
- if (comparison !== 0) return comparison;
436
- }
437
- return 0;
438
- };
439
-
440
- const isSameChannelVersionAtLeast = (currentVersion, minimumVersion) => {
441
- const currentChannel = releaseChannelForVersion(currentVersion);
442
- if (!currentChannel || currentChannel !== releaseChannelForVersion(minimumVersion)) return false;
443
- const comparison = compareReleaseVersions(currentVersion, minimumVersion);
444
- return comparison !== null && comparison >= 0;
445
- };
446
-
447
- const PINNED_RELEASE_CHANNEL = releaseChannelForVersion(PINNED_BACKEND_VERSION);
448
-
449
- const guidanceMatchesRelease = (guidance, latestVersion) => {
450
- const versions =
451
- String(guidance || "").match(
452
- /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?/g,
453
- ) || [];
454
- return versions.length === 0 || versions.every((version) => version === latestVersion);
455
- };
456
-
457
- const parseReleaseNotification = (result) => {
458
- if (result?.exitCode !== 0) return null;
459
- const stdout = String(result?.stdout || "").trim();
460
- if (!stdout || Buffer.byteLength(stdout, "utf8") > MAX_UPDATE_STATUS_BYTES) return null;
461
- try {
462
- const status = JSON.parse(stdout);
463
- if (!status || typeof status !== "object" || Array.isArray(status)) return null;
464
- if (status.update_available !== true) return null;
465
- if (
466
- !PINNED_RELEASE_CHANNEL
467
- || status.channel !== PINNED_RELEASE_CHANNEL
468
- || releaseChannelForVersion(status.latest_version) !== PINNED_RELEASE_CHANNEL
469
- ) {
470
- return null;
84
+ return await runtime.handleEvent(translated);
85
+ } finally {
86
+ delegation.observe(event);
471
87
  }
472
- if (
473
- typeof status.recommended_action !== "string"
474
- || !status.recommended_action.trim()
475
- || status.recommended_action.length > MAX_UPDATE_ACTION_CHARS
476
- || !guidanceMatchesRelease(status.recommended_action, status.latest_version)
477
- ) {
478
- return null;
479
- }
480
- return {
481
- latestVersion: status.latest_version,
482
- channel: PINNED_RELEASE_CHANNEL,
483
- recommendedAction: status.recommended_action.trim(),
484
- autoUpdateEligible: status.auto_update_eligible === true,
485
- };
486
- } catch {
487
- return null;
488
- }
489
- };
490
-
491
- const resolveAutoUpdateEnvironment = ({
492
- platform = process.platform,
493
- env = process.env,
494
- } = {}) => {
495
- if (platform !== "linux") return env;
496
- return { ...env, ONNXRUNTIME_NODE_INSTALL: "skip" };
497
- };
498
-
499
- const createLogLine = (logPath) => async (line) => {
500
- if (!logPath) {
501
- return;
502
88
  }
503
- try {
504
- await mkdir(dirname(logPath), { recursive: true });
505
- await appendFile(logPath, `${new Date().toISOString()} ${line}\n`);
506
- } catch (err) {
507
- // ignore logging failures
508
- }
509
- };
510
-
511
- const createDebugLogger = ({ debug, client, logTimeoutMs, getLogLine, getErrorLogLine }) =>
512
- async (level, message, extra = {}) => {
513
- // Always log errors and warnings to the error log path
514
- const alwaysLog = level === "error" || level === "warn";
515
- if (alwaysLog) {
516
- const extraStr = Object.keys(extra).length > 0 ? ` ${JSON.stringify(extra)}` : "";
517
- await getErrorLogLine()(`[${level}] ${message}${extraStr}`);
518
- }
519
- if (!debug && !alwaysLog) {
520
- return;
521
- }
89
+ const part = event?.properties?.part;
90
+ const terminalTaskUpdate = event?.type === "message.part.updated"
91
+ && part?.type === "tool"
92
+ && part.tool === "task"
93
+ && ["completed", "error"].includes(part.state?.status);
94
+ const boundChildSessionID = terminalTaskUpdate
95
+ ? delegation.boundChild(part.sessionID, part.callID)
96
+ : null;
97
+ if (boundChildSessionID) {
522
98
  try {
523
- const logPromise = client.app.log({
524
- service: "codemem",
525
- level,
526
- message,
527
- extra,
528
- });
529
- if (!Number.isFinite(logTimeoutMs) || logTimeoutMs <= 0) {
530
- await logPromise;
531
- return;
532
- }
533
- let timedOut = false;
534
- await Promise.race([
535
- logPromise,
536
- new Promise((resolve) =>
537
- setTimeout(() => {
538
- timedOut = true;
539
- resolve();
540
- }, logTimeoutMs)
541
- ),
542
- ]);
543
- if (timedOut) {
544
- await getLogLine()("debug log timed out");
545
- }
546
- } catch (err) {
547
- // ignore debug logging failures
548
- }
549
- };
550
-
551
- const extractApplyPatchPaths = (patchText) => {
552
- if (!patchText || typeof patchText !== "string") {
553
- return [];
554
- }
555
- const paths = [];
556
- const seen = new Set();
557
- const lines = patchText.split(/\r?\n/);
558
- for (const line of lines) {
559
- const match = line.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/);
560
- if (!match) {
561
- continue;
99
+ await runtime.capturePendingPrompts({ sessionID: boundChildSessionID });
100
+ return await runtime.handleEvent(translated);
101
+ } finally {
102
+ delegation.observe(event);
562
103
  }
563
- const path = String(match[1] || "").trim();
564
- if (!path || seen.has(path)) {
565
- continue;
566
- }
567
- seen.add(path);
568
- paths.push(path);
569
- }
570
- return paths;
571
- };
572
-
573
- const windowsPathFlavor = (value, doubleSlashIsUnc = true) =>
574
- /^[A-Za-z]:[\\/]/.test(value)
575
- || /^\\\\/.test(value)
576
- || (doubleSlashIsUnc && /^\/\/[^/]+[\\/][^/]+/.test(value));
577
-
578
- const hasTraversalSegment = (value) =>
579
- value.replaceAll("\\", "/").split("/").includes("..");
580
-
581
- const normalizeWorkingSetPath = (value, repositoryRoot) => {
582
- if (typeof value !== "string" || typeof repositoryRoot !== "string") return null;
583
- const candidate = value.trim();
584
- if (
585
- !candidate
586
- || candidate.length > MAX_WORKING_SET_PATH_CHARS
587
- || hasTraversalSegment(candidate)
588
- ) {
589
- return null;
590
- }
591
-
592
- const root = repositoryRoot.trim();
593
- const candidateIsAbsolute = posix.isAbsolute(candidate) || win32.isAbsolute(candidate);
594
- if (candidateIsAbsolute) {
595
- if (!root || hasTraversalSegment(root)) return null;
596
- const rootUsesWindows = windowsPathFlavor(root);
597
- const candidateUsesWindows = windowsPathFlavor(candidate, rootUsesWindows);
598
- if (candidateUsesWindows !== rootUsesWindows) return null;
599
- const pathApi = candidateUsesWindows ? win32 : posix;
600
- if (!pathApi.isAbsolute(root) || !pathApi.isAbsolute(candidate)) return null;
601
- const relative = pathApi.relative(pathApi.normalize(root), pathApi.normalize(candidate));
602
- if (
603
- !relative
604
- || relative === ".."
605
- || relative.startsWith(`..${pathApi.sep}`)
606
- || pathApi.isAbsolute(relative)
607
- ) {
608
- return null;
609
- }
610
- const normalized = relative.replaceAll("\\", "/");
611
- return normalized.length <= MAX_WORKING_SET_PATH_CHARS ? normalized : null;
612
- }
613
-
614
- if (/^[A-Za-z]:/.test(candidate)) return null;
615
- const normalized = posix.normalize(candidate.replaceAll("\\", "/")).replace(/^\.\//, "");
616
- if (
617
- !normalized
618
- || normalized === "."
619
- || normalized === ".."
620
- || normalized.startsWith("../")
621
- || posix.isAbsolute(normalized)
622
- || normalized.length > MAX_WORKING_SET_PATH_CHARS
623
- ) {
624
- return null;
625
- }
626
- return normalized;
627
- };
628
-
629
- const addWorkingSetPath = (paths, value, repositoryRoot) => {
630
- const normalized = normalizeWorkingSetPath(value, repositoryRoot);
631
- if (!normalized) return null;
632
- const duplicate = windowsPathFlavor(repositoryRoot)
633
- ? Array.from(paths).some((existing) => existing.toLowerCase() === normalized.toLowerCase())
634
- : paths.has(normalized);
635
- if (!duplicate) paths.add(normalized);
636
- return normalized;
637
- };
638
-
639
- const appendWorkingSetFileArgs = (args, workingSetFiles) => {
640
- if (!Array.isArray(workingSetFiles) || workingSetFiles.length === 0) {
641
- return args;
642
- }
643
- for (const file of workingSetFiles) {
644
- const normalized = String(file || "").trim();
645
- if (!normalized || normalized.length > MAX_WORKING_SET_PATH_CHARS) {
646
- continue;
647
- }
648
- args.push("--working-set-file", normalized);
649
- }
650
- return args;
651
- };
652
-
653
- const buildInjectQuery = ({ firstPrompt, lastPromptText, projectName, filesModified }) => {
654
- const parts = [];
655
-
656
- if (firstPrompt && String(firstPrompt).trim()) {
657
- parts.push(String(firstPrompt).trim());
658
- }
659
-
660
- if (
661
- lastPromptText
662
- && String(lastPromptText).trim()
663
- && String(lastPromptText).trim() !== String(firstPrompt || "").trim()
664
- && String(lastPromptText).trim().length > 5
665
- ) {
666
- parts.push(String(lastPromptText).trim());
667
- }
668
-
669
- if (projectName) {
670
- parts.push(String(projectName));
671
104
  }
672
-
673
- const recentFiles = Array.from(filesModified || [])
674
- .slice(-5)
675
- .map((filePath) => String(filePath || "").split("/").pop())
676
- .filter(Boolean)
677
- .join(" ");
678
- if (recentFiles) {
679
- parts.push(recentFiles);
680
- }
681
-
682
- if (parts.length === 0) {
683
- return "recent work";
684
- }
685
-
686
- const query = parts.join(" ");
687
- return query.length > 500 ? query.slice(0, 500) : query;
688
- };
689
-
690
- const buildPackArgs = ({ query, filesModified, injectLimit, injectTokenBudget, internalLedger = false }) => {
691
- const workingSetFiles = Array.from(filesModified || [])
692
- .slice(-8)
693
- .map((value) => String(value || "").trim())
694
- .filter(Boolean);
695
- const args = ["pack", query, "--json"];
696
- if (internalLedger) {
697
- args.push("--internal-ledger");
698
- }
699
- if (injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0) {
700
- args.push("--limit", String(injectLimit));
701
- }
702
- if (
703
- injectTokenBudget !== null
704
- && Number.isFinite(injectTokenBudget)
705
- && injectTokenBudget > 0
706
- ) {
707
- args.push("--token-budget", String(injectTokenBudget));
708
- }
709
- return appendWorkingSetFileArgs(args, workingSetFiles);
105
+ delegation.observe(event);
106
+ return runtime.handleEvent(translated);
710
107
  };
711
108
 
712
- const parsePackText = (stdout) => {
713
- if (!stdout || !stdout.trim()) {
714
- return "";
715
- }
716
- try {
717
- const payload = JSON.parse(stdout);
718
- return (payload?.pack_text || "").trim();
719
- } catch {
720
- return "";
721
- }
722
- };
109
+ const createOpenCodeV1Adapter = async ({ project, client, directory, worktree }) => {
110
+ const delegation = createDelegationContext({
111
+ requirePromptSnapshot: true,
112
+ readSession: client.session?.get
113
+ ? async (id, signal) => (await client.session.get({ path: { id }, signal })).data
114
+ : null,
115
+ readMessage: client.session?.message
116
+ ? async (id, messageID, signal) => (await client.session.message({ path: { id, messageID }, signal })).data
117
+ : null,
118
+ });
119
+ const runtime = await createCodememRuntime({
120
+ location: createRuntimeLocation({ project, directory, worktree }),
121
+ host: createRuntimeHost({
122
+ log: (entry) => client.app.log({ body: entry }),
123
+ resolveCaptureContext: delegation.resolve,
124
+ notify: client.tui?.showToast
125
+ ? (notice) => client.tui.showToast({ body: notice })
126
+ : null,
127
+ }),
128
+ });
129
+ if (!runtime) return {};
723
130
 
724
- const parsePackMetrics = (stdout) => {
725
- if (!stdout || !stdout.trim()) {
726
- return null;
727
- }
728
- try {
729
- const payload = JSON.parse(stdout);
730
- return payload?.metrics || null;
731
- } catch {
732
- return null;
733
- }
734
- };
131
+ const memoryTools = Object.fromEntries(
132
+ Object.entries(runtime.tools).map(([name, definition]) => [name, createV1Tool(definition)]),
133
+ );
735
134
 
736
- const buildViewerIdentityTarget = (env = process.env, cwd = process.cwd()) => {
737
- const normalizeIdentityPath = (value) => {
738
- const trimmed = String(value || "").trim();
739
- if (!trimmed) return null;
740
- const expanded = trimmed.startsWith("~/")
741
- ? join(env.HOME?.trim() || homedir(), trimmed.slice(2))
742
- : trimmed;
743
- return resolve(cwd, expanded);
744
- };
745
- const embeddingModel = env.CODEMEM_EMBEDDING_MODEL || DEFAULT_EMBEDDING_MODEL;
746
- const configuredEmbeddingRevision = env.CODEMEM_EMBEDDING_REVISION?.trim();
747
- // Match core's configured request identity. The runtime resolves mutable refs
748
- // to canonical commits before vectors are persisted.
749
- let embeddingRevision;
750
- if (configuredEmbeddingRevision) {
751
- embeddingRevision = configuredEmbeddingRevision;
752
- } else {
753
- embeddingRevision =
754
- embeddingModel === DEFAULT_EMBEDDING_MODEL ? DEFAULT_EMBEDDING_REVISION : null;
755
- }
756
135
  return {
757
- device_id: env.CODEMEM_DEVICE_ID?.trim() || null,
758
- actor_id_present: Object.hasOwn(env, "CODEMEM_ACTOR_ID"),
759
- actor_id: env.CODEMEM_ACTOR_ID?.trim() || null,
760
- config_path: normalizeIdentityPath(env.CODEMEM_CONFIG),
761
- runtime_root: normalizeIdentityPath(env.CODEMEM_RUNTIME_ROOT),
762
- workspace_id: env.CODEMEM_WORKSPACE_ID?.trim() || null,
763
- home_dir: normalizeIdentityPath(env.HOME || homedir()),
764
- pack_compression: env.CODEMEM_PACK_COMPRESSION?.trim() || null,
765
- embedding_disabled: ["1", "true", "yes"].includes(
766
- String(env.CODEMEM_EMBEDDING_DISABLED || "").toLowerCase(),
136
+ dispose: async () => {
137
+ try {
138
+ await runtime.dispose();
139
+ } finally {
140
+ delegation.dispose();
141
+ }
142
+ },
143
+ "chat.message": async (input, output) => {
144
+ delegation.observePrompt(input, output);
145
+ },
146
+ "experimental.session.compacting": (input) => runtime.handleCompacting({
147
+ sessionID: extractV1HookSessionID(input),
148
+ }),
149
+ "experimental.chat.messages.transform": (input, output) => runtime.transformMessages(
150
+ translateV1PromptInput(input),
151
+ output,
767
152
  ),
768
- embedding_offline: ["1", "true", "yes"].includes(
769
- String(env.CODEMEM_EMBEDDING_OFFLINE || "").toLowerCase(),
153
+ "experimental.chat.system.transform": (input, output) => runtime.transformSystem(
154
+ translateV1PromptInput(input),
155
+ output,
770
156
  ),
771
- embedding_model: embeddingModel,
772
- embedding_revision: embeddingRevision,
773
- };
774
- };
775
-
776
- const parsePackOutput = (result) => {
777
- const succeeded = result?.exitCode === 0;
778
- let payload = null;
779
- if (succeeded) {
780
- try {
781
- payload = JSON.parse(result.stdout);
782
- } catch {
783
- payload = null;
784
- }
785
- }
786
- const ledgerConflict = payload?.ledger_outcome?.ok === false
787
- && payload.ledger_outcome.errorCode === "retrieval_ledger_write_failed"
788
- && payload.ledger_outcome.reason === "idempotency_conflict";
789
- const metrics = succeeded ? payload?.metrics || null : null;
790
- const itemCount = Number.isFinite(Number(metrics?.total_items))
791
- ? Number(metrics.total_items)
792
- : null;
793
- const candidatePackText = succeeded && itemCount !== 0
794
- ? String(payload?.pack_text || "")
795
- : "";
796
- return {
797
- conflictPackText: ledgerConflict ? candidatePackText : "",
798
- ledgerConflict,
799
- metrics,
800
- itemCount,
801
- // The real builder renders section headings even when it selected no
802
- // memories. Treat the structured count as authoritative so headings alone
803
- // are never handed to the model as retrieved context.
804
- packText: !ledgerConflict ? candidatePackText : "",
805
- };
806
- };
807
-
808
- const isRecord = (value) => value != null && typeof value === "object" && !Array.isArray(value);
809
-
810
- const isValidPackHttpPayload = (payload) => {
811
- if (!isRecord(payload) || typeof payload.pack_text !== "string" || !isRecord(payload.metrics)) {
812
- return false;
813
- }
814
- const itemCount = payload.metrics.total_items;
815
- if (!Number.isInteger(itemCount) || itemCount < 0) {
816
- return false;
817
- }
818
- if (
819
- payload.ledger_artifact_fingerprint != null
820
- && !/^[0-9a-f]{64}$/i.test(payload.ledger_artifact_fingerprint)
821
- ) {
822
- return false;
823
- }
824
- if (payload.ledger_outcome != null) {
825
- return isRecord(payload.ledger_outcome)
826
- && payload.ledger_outcome.ok === false
827
- && payload.ledger_outcome.errorCode === "retrieval_ledger_write_failed"
828
- && payload.ledger_outcome.reason === "idempotency_conflict";
829
- }
830
- return true;
831
- };
832
-
833
- const isValidLedgerHttpPayload = (payload) => isRecord(payload) && payload.ok === true;
834
-
835
- const isValidLedgerFailureHttpPayload = (payload) =>
836
- isRecord(payload)
837
- && payload.ok === false
838
- && (
839
- payload.errorCode === "retrieval_ledger_write_failed"
840
- || payload.errorCode === "retrieval_ledger_delivery_write_failed"
841
- || payload.errorCode === "automatic_recall_write_failed"
842
- )
843
- && typeof payload.reason === "string";
844
-
845
- const isViewerDbMismatchPayload = (payload) =>
846
- isRecord(payload)
847
- && isRecord(payload.error)
848
- && payload.error.code === "viewer_db_mismatch";
849
-
850
- const isViewerIdentityMismatchPayload = (payload) =>
851
- isRecord(payload)
852
- && isRecord(payload.error)
853
- && payload.error.code === "viewer_identity_mismatch";
854
-
855
- const isViewerContractUnsupportedPayload = (payload) =>
856
- isRecord(payload)
857
- && isRecord(payload.error)
858
- && payload.error.code === "viewer_contract_unsupported";
859
-
860
- const isViewerInvalidRequestPayload = (payload) =>
861
- isRecord(payload)
862
- && isRecord(payload.error)
863
- && payload.error.code === "invalid_request";
864
-
865
- const classifyRawEventViewerFailure = (payload) => {
866
- if (isViewerDbMismatchPayload(payload)) return "database";
867
- if (isViewerIdentityMismatchPayload(payload)) return "identity";
868
- if (isViewerContractUnsupportedPayload(payload)) return "contract";
869
- return "connection";
870
- };
871
-
872
- const RAW_EVENT_FAILURE_ACTIONS = Object.freeze({
873
- database: "restart the viewer from the same workspace/config",
874
- identity: "restart Codemem and OpenCode with the same environment",
875
- contract: "update Codemem on the installed channel, then restart OpenCode",
876
- connection: "check or restart the viewer",
877
- });
878
-
879
- // Dependency-free port of @codemem/core prompt-transport semantics. Keep the
880
- // range and classifier parity pinned by the plugin injection tests.
881
- const PROMPT_TRANSPORT_PROTOCOL_RANGE = Object.freeze({
882
- minSupportedProtocolVersion: 1,
883
- protocolVersion: 1,
884
- });
885
-
886
- const normalizePromptTransportProtocolRange = (
887
- protocolVersion,
888
- minSupportedProtocolVersion = undefined,
889
- ) => {
890
- if (!Number.isSafeInteger(protocolVersion) || protocolVersion < 1) return null;
891
- const minimum = minSupportedProtocolVersion === undefined
892
- ? protocolVersion
893
- : minSupportedProtocolVersion;
894
- if (!Number.isSafeInteger(minimum) || minimum < 1 || minimum > protocolVersion) return null;
895
- return { minSupportedProtocolVersion: minimum, protocolVersion };
896
- };
897
-
898
- const arePromptTransportProtocolRangesCompatible = (left, right) =>
899
- left.minSupportedProtocolVersion <= right.protocolVersion
900
- && right.minSupportedProtocolVersion <= left.protocolVersion;
901
-
902
- const classifyPromptTransportFailure = ({ kind, compatibleProfile = false }) => {
903
- if (kind === "database_mismatch" || kind === "runtime_identity_mismatch") {
904
- return "local_fallback";
905
- }
906
- if (kind === "invalid_request") {
907
- return compatibleProfile ? "terminal" : "fallback";
908
- }
909
- if (kind === "policy_failure" || kind === "authorization_failure") {
910
- return "terminal";
911
- }
912
- if (kind === "viewer_contract_unsupported") {
913
- return compatibleProfile ? "terminal" : "fallback";
914
- }
915
- return "fallback";
916
- };
917
-
918
- const isViewerPolicyOrAuthFailurePayload = (payload) => {
919
- const code = isRecord(payload) && isRecord(payload.error)
920
- ? payload.error.code
921
- : isRecord(payload)
922
- ? payload.error
923
- : null;
924
- return typeof code === "string" && [
925
- "authorization_failed",
926
- "forbidden",
927
- "policy_denied",
928
- "policy_disabled",
929
- "unauthorized",
930
- ].includes(code);
931
- };
932
-
933
- const viewerFailureClassification = (cause, disposition, kind = null) => ({
934
- cause,
935
- disposition,
936
- kind,
937
- retryable: disposition !== "terminal",
938
- });
939
-
940
- const classifyViewerHttpFailure = ({
941
- operation,
942
- status = null,
943
- error = null,
944
- malformed = false,
945
- body = null,
946
- compatibleProfile = false,
947
- }) => {
948
- if (malformed) {
949
- return viewerFailureClassification(
950
- `${operation} returned malformed success`,
951
- classifyPromptTransportFailure({ kind: "malformed_response" }),
952
- );
953
- }
954
- if (isViewerDbMismatchPayload(body)) {
955
- return viewerFailureClassification(
956
- `${operation} viewer database mismatch`,
957
- classifyPromptTransportFailure({ kind: "database_mismatch" }),
958
- );
959
- }
960
- if (isViewerIdentityMismatchPayload(body)) {
961
- return viewerFailureClassification(
962
- `${operation} viewer runtime identity mismatch`,
963
- classifyPromptTransportFailure({ kind: "runtime_identity_mismatch" }),
964
- );
965
- }
966
- if (isViewerContractUnsupportedPayload(body)) {
967
- return viewerFailureClassification(
968
- `${operation} viewer contract unsupported`,
969
- classifyPromptTransportFailure({
970
- kind: "viewer_contract_unsupported",
971
- compatibleProfile,
972
- }),
973
- "viewer_contract_unsupported",
974
- );
975
- }
976
- if (operation === "prompt-pack-ledger" && isValidLedgerFailureHttpPayload(body)) {
977
- return viewerFailureClassification(`${operation} request rejected (${status})`, "terminal");
978
- }
979
- if (status === 401 || status === 403 || isViewerPolicyOrAuthFailurePayload(body)) {
980
- return viewerFailureClassification(
981
- `${operation} policy or authorization failure (${status})`,
982
- classifyPromptTransportFailure({
983
- kind: status === 401 || status === 403 ? "authorization_failure" : "policy_failure",
984
- }),
985
- );
986
- }
987
- if (isViewerInvalidRequestPayload(body)) {
988
- return viewerFailureClassification(
989
- `${operation} request rejected (${status})`,
990
- classifyPromptTransportFailure({ kind: "invalid_request", compatibleProfile }),
991
- );
992
- }
993
- if (status === 404 || status === 405) {
994
- return viewerFailureClassification(`${operation} endpoint unavailable (${status})`, "fallback");
995
- }
996
- if (Number.isInteger(status) && status >= 500) {
997
- return viewerFailureClassification(`${operation} server failure (${status})`, "fallback");
998
- }
999
- if (Number.isInteger(status)) {
1000
- return viewerFailureClassification(`${operation} unexpected response (${status})`, "fallback");
1001
- }
1002
- const diagnostic = [
1003
- error?.name,
1004
- error?.code,
1005
- error?.cause?.code,
1006
- error?.message,
1007
- error,
1008
- ].filter(Boolean).join(" ");
1009
- const timedOut = /AbortError|TimeoutError|timeout|timed out|ETIMEDOUT/i.test(diagnostic);
1010
- return viewerFailureClassification(
1011
- timedOut ? `${operation} request timeout` : `${operation} connection failed`,
1012
- "fallback",
1013
- );
1014
- };
1015
-
1016
- const buildPackHttpBody = ({
1017
- query,
1018
- filesModified,
1019
- injectLimit,
1020
- injectTokenBudget,
1021
- projectName,
1022
- cwd,
1023
- dbPath,
1024
- identityTarget,
1025
- attempt,
1026
- automaticContext,
1027
- }) => ({
1028
- context: query,
1029
- limit: injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0
1030
- ? Math.trunc(injectLimit)
1031
- : 10,
1032
- token_budget:
1033
- injectTokenBudget !== null
1034
- && Number.isFinite(injectTokenBudget)
1035
- && injectTokenBudget > 0
1036
- ? Math.trunc(injectTokenBudget)
1037
- : null,
1038
- ...(projectName ? { project: projectName } : {}),
1039
- ...(cwd ? { cwd } : {}),
1040
- db_path: dbPath,
1041
- identity_target: identityTarget,
1042
- working_set_files: Array.from(filesModified || [])
1043
- .slice(-8)
1044
- .map((value) => String(value || "").trim())
1045
- .filter((value) => value && value.length <= MAX_WORKING_SET_PATH_CHARS),
1046
- attempt,
1047
- ...(automaticContext !== undefined ? { automatic_context: automaticContext } : {}),
1048
- });
1049
-
1050
- const canonicalJson = (value) => {
1051
- if (Array.isArray(value)) {
1052
- return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
1053
- }
1054
- if (value && typeof value === "object") {
1055
- return `{${Object.keys(value)
1056
- .sort()
1057
- .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
1058
- .join(",")}}`;
1059
- }
1060
- return JSON.stringify(value);
1061
- };
1062
-
1063
- const promptPackArtifactFingerprint = (stdout, packText) => {
1064
- try {
1065
- const payload = JSON.parse(stdout);
1066
- if (/^[0-9a-f]{64}$/i.test(payload?.ledger_artifact_fingerprint || "")) {
1067
- return payload.ledger_artifact_fingerprint.toLowerCase();
1068
- }
1069
- return createHash("sha256").update(canonicalJson(payload)).digest("hex");
1070
- } catch {
1071
- return createHash("sha256").update(packText).digest("hex");
1072
- }
1073
- };
1074
-
1075
- const applyInjectedContextToOutput = async ({
1076
- injectEnabled,
1077
- input,
1078
- output,
1079
- injectionToastShown,
1080
- showToast,
1081
- resolveInjectQuery,
1082
- buildInjectedContext,
1083
- confirmDelivery,
1084
- recordSkipped,
1085
- }) => {
1086
- if (!injectEnabled) {
1087
- recordSkipped?.("injection_disabled", input?.sessionID || null);
1088
- return false;
1089
- }
1090
-
1091
- // The previous incarnation cached by sessionID-only, so a follow-up
1092
- // turn whose query happened to match the cache key (e.g. because
1093
- // lastPromptText hadn't yet been captured by the time
1094
- // experimental.chat.system.transform fired) would silently re-serve
1095
- // the first turn's pack. Recompute on every call instead — the chat
1096
- // path tolerates the transport round trip, and correctness beats the
1097
- // O(1) hit when the cache key isn't tied to the prompt that produced
1098
- // the pack.
1099
- const query = resolveInjectQuery();
1100
- const sessionID = input?.sessionID || null;
1101
- const injected = await buildInjectedContext(query, {
1102
- sessionID,
1103
- requestKey: `system:${sessionID || "unknown"}`,
1104
- surface: "system",
1105
- });
1106
- if (!injected?.text) {
1107
- return false;
1108
- }
1109
-
1110
- if (!injectionToastShown.has(input.sessionID) && showToast) {
1111
- injectionToastShown.add(input.sessionID);
1112
- try {
1113
- await showToast(buildInjectionToastMessage(injected.metrics));
1114
- } catch {
1115
- // best-effort only
1116
- }
1117
- }
1118
-
1119
- try {
1120
- if (!Array.isArray(output.system)) {
1121
- output.system = [];
1122
- }
1123
- output.system.push(injected.text);
1124
- } catch (error) {
1125
- if (injected.attemptId) {
1126
- confirmDelivery?.(injected.attemptId, "failed");
1127
- }
1128
- throw error;
1129
- }
1130
- if (injected.attemptId) {
1131
- confirmDelivery?.(injected.attemptId);
1132
- }
1133
- return true;
1134
- };
1135
-
1136
- const isUserMessageEntry = (entry) => entry?.info?.role === "user";
1137
-
1138
- const resolveEntryMessageId = (entry) => {
1139
- const id = entry?.info?.id || entry?.parts?.find((part) => part?.messageID)?.messageID;
1140
- return id ? String(id) : null;
1141
- };
1142
-
1143
- const fallbackEntryMessageId = (entry, index = 0) => {
1144
- const id = resolveEntryMessageId(entry);
1145
- if (id) return id;
1146
- const text = extractMessageText(entry);
1147
- if (text) {
1148
- return `message-${createHash("sha256").update(text).digest("hex").slice(0, 16)}`;
1149
- }
1150
- return `message-${index}`;
1151
- };
1152
-
1153
- const resolveEntrySessionID = (entry) => {
1154
- const id = entry?.info?.sessionID || entry?.parts?.find((part) => part?.sessionID)?.sessionID;
1155
- return id ? String(id) : null;
1156
- };
1157
-
1158
- const isCodememContextPart = (part) =>
1159
- part?.type === "text" && String(part?.id || "").startsWith(CODEMEM_CONTEXT_PART_ID_PREFIX);
1160
-
1161
- const extractMessageText = (entry) => {
1162
- if (!Array.isArray(entry?.parts)) {
1163
- return "";
1164
- }
1165
- return entry.parts
1166
- .filter((part) => part?.type === "text" && !isCodememContextPart(part))
1167
- .map((part) => String(part?.text || "").trim())
1168
- .filter(Boolean)
1169
- .join("\n")
1170
- .trim();
1171
- };
1172
-
1173
- const findFirstUserMessage = (messages) => {
1174
- for (let index = 0; index < messages.length; index += 1) {
1175
- const entry = messages[index];
1176
- if (isUserMessageEntry(entry)) {
1177
- return { entry, index };
1178
- }
1179
- }
1180
- return null;
1181
- };
1182
-
1183
- const findLatestUserMessage = (messages) => {
1184
- for (let index = messages.length - 1; index >= 0; index -= 1) {
1185
- const entry = messages[index];
1186
- if (isUserMessageEntry(entry)) {
1187
- return { entry, index };
1188
- }
1189
- }
1190
- return null;
1191
- };
1192
-
1193
- const markCompactionInjectionSkip = (compactionInjectionSkips, sessionID, now = Date.now()) => {
1194
- if (!compactionInjectionSkips || !sessionID) return;
1195
- compactionInjectionSkips.set(String(sessionID), now + COMPACTION_INJECTION_SKIP_TTL_MS);
1196
- };
1197
-
1198
- const consumeCompactionInjectionSkip = (compactionInjectionSkips, sessionID, now = Date.now()) => {
1199
- if (!compactionInjectionSkips || !sessionID) return false;
1200
- const key = String(sessionID);
1201
- const expiresAt = compactionInjectionSkips.get(key);
1202
- if (!expiresAt) return false;
1203
- compactionInjectionSkips.delete(key);
1204
- return now <= expiresAt;
1205
- };
1206
-
1207
- // OpenCode does not guarantee that synthetic message parts survive into the
1208
- // next transform call. Cache each message's injected block by message ID and
1209
- // re-append the exact same bytes on later turns: older prompts stay stable for
1210
- // provider prefix caching while only the newest user message triggers a fresh
1211
- // pack build. The replay cache only activates when OpenCode provides both a
1212
- // session ID and a real message ID; unidentified messages still receive current
1213
- // turn context, but are not cached because positional fallbacks can drift across
1214
- // turns or sessions. Do not "simplify" this into recomputing previous turns.
1215
- const getSessionMessageInjectionCache = (messageInjectionCache, sessionID) => {
1216
- if (!sessionID) {
1217
- return null;
1218
- }
1219
- const cacheKey = sessionID;
1220
- let sessionCache = messageInjectionCache.get(cacheKey);
1221
- if (sessionCache) {
1222
- // Refresh recency so the bounded cache behaves like a small LRU.
1223
- messageInjectionCache.delete(cacheKey);
1224
- } else {
1225
- sessionCache = new Map();
1226
- }
1227
- messageInjectionCache.set(cacheKey, sessionCache);
1228
- while (messageInjectionCache.size > MAX_MESSAGE_INJECTION_CACHE_SESSIONS) {
1229
- const oldestKey = messageInjectionCache.keys().next().value;
1230
- if (!oldestKey) break;
1231
- messageInjectionCache.delete(oldestKey);
1232
- }
1233
- return sessionCache;
1234
- };
1235
-
1236
- const normalizeInjectedMessageParts = (messages, sessionCache, { pruneAbsent = true } = {}) => {
1237
- const presentIds = new Set(messages.map(resolveEntryMessageId).filter(Boolean));
1238
- for (const id of sessionCache?.keys() || []) {
1239
- if (pruneAbsent && !presentIds.has(id)) sessionCache.delete(id);
1240
- }
1241
- for (let index = 0; index < messages.length; index += 1) {
1242
- const entry = messages[index];
1243
- if (!isUserMessageEntry(entry) || !Array.isArray(entry.parts)) {
1244
- continue;
1245
- }
1246
-
1247
- const messageId = resolveEntryMessageId(entry);
1248
- const parts = entry.parts.filter(isCodememContextPart);
1249
- if (parts.length && sessionCache && messageId) {
1250
- const cached = sessionCache.get(messageId);
1251
- sessionCache.set(messageId, {
1252
- ...cached,
1253
- text: parts[0].text,
1254
- parts: parts.map((part) => {
1255
- const recall = retainedPartRecall(part, cached?.parts);
1256
- return recall
1257
- ? { ...part, metadata: { ...part.metadata, codemem: recall } }
1258
- : { ...part };
1259
- }),
1260
- });
1261
- }
1262
- }
1263
- };
1264
-
1265
- const buildInjectedContextPart = (entry, index, text, options = {}) => {
1266
- const messageId = options.messageId || fallbackEntryMessageId(entry, index);
1267
- const sessionID = options.sessionID || resolveEntrySessionID(entry) || "unknown";
1268
- return {
1269
- id: `${CODEMEM_CONTEXT_PART_ID_PREFIX}${messageId}`,
1270
- sessionID,
1271
- messageID: messageId,
1272
- type: "text",
1273
- text,
1274
- synthetic: true,
1275
- ...(options.recall ? { metadata: { codemem: options.recall } } : {}),
1276
- };
1277
- };
1278
-
1279
- const appendCachedInjectedContextParts = async (
1280
- messages,
1281
- sessionCache,
1282
- sessionID = null,
1283
- options = {},
1284
- ) => {
1285
- let appended = 0;
1286
- for (let index = 0; index < messages.length; index += 1) {
1287
- const entry = messages[index];
1288
- if (!isUserMessageEntry(entry) || !Array.isArray(entry.parts)) {
1289
- continue;
1290
- }
1291
- const messageId = resolveEntryMessageId(entry);
1292
- if (!messageId) {
1293
- continue;
1294
- }
1295
- const cached = sessionCache.get(messageId);
1296
- const text = typeof cached === "string" ? cached : cached?.text;
1297
- if (!text) {
1298
- continue;
1299
- }
1300
- let attemptId = cached?.attemptId || null;
1301
- let cacheReuseReady = null;
1302
- if (
1303
- messageId === options.latestMessageId
1304
- && !options.latestWasBuilt
1305
- && attemptId
1306
- && options.recordCacheReuse
1307
- ) {
1308
- const reuse = options.recordCacheReuse(cached, {
1309
- messageId,
1310
- sessionID,
1311
- });
1312
- attemptId = typeof reuse === "string" ? reuse : reuse?.attemptId || null;
1313
- cacheReuseReady = typeof reuse === "object" ? reuse?.ready : null;
1314
- }
1315
- try {
1316
- if (!entry.parts.some(isCodememContextPart)) {
1317
- const parts = cached?.parts || [buildInjectedContextPart(entry, index, text, { messageId, sessionID })];
1318
- entry.parts.push(...parts.map((part) => ({ ...part })));
1319
- }
1320
- } catch (error) {
1321
- if (messageId === options.latestMessageId && attemptId && options.confirmDelivery) {
1322
- if (cacheReuseReady) {
1323
- void Promise.resolve(cacheReuseReady)
1324
- .then(() => options.confirmDelivery(attemptId, "failed"))
1325
- .catch(() => {});
1326
- } else {
1327
- options.confirmDelivery(attemptId, "failed");
1328
- }
1329
- }
1330
- throw error;
1331
- }
1332
- if (messageId === options.latestMessageId && attemptId && options.confirmDelivery) {
1333
- if (cacheReuseReady) {
1334
- void Promise.resolve(cacheReuseReady)
1335
- .then(() => options.confirmDelivery(attemptId))
1336
- .catch(() => {});
1337
- } else {
1338
- options.confirmDelivery(attemptId);
1339
- }
1340
- }
1341
- appended += 1;
1342
- }
1343
- return appended;
1344
- };
1345
-
1346
- const scopeMessagesToSession = (messages, sessionID) => messages.filter((entry) => {
1347
- const entrySession = resolveEntrySessionID(entry);
1348
- return !entrySession || !sessionID || entrySession === sessionID;
1349
- });
1350
-
1351
- const applyInjectedContextToMessages = async ({
1352
- injectEnabled,
1353
- input,
1354
- output,
1355
- injectionToastShown,
1356
- showToast,
1357
- resolveInjectQuery,
1358
- buildInjectedContext,
1359
- messageInjectionCache,
1360
- compactionInjectionSkips,
1361
- confirmDelivery,
1362
- recordCacheReuse,
1363
- recordSkipped,
1364
- injectTokenBudget = DEFAULT_INJECT_TOKEN_BUDGET,
1365
- retainedTokenBudget = DEFAULT_RETAINED_TOKEN_BUDGET,
1366
- workingSet = [],
1367
- recordMeasurement,
1368
- }) => {
1369
- const hasMessages = Array.isArray(output?.messages);
1370
- const hookSessionID = input?.sessionID || null;
1371
- const hookMessages = hasMessages ? scopeMessagesToSession(output.messages, hookSessionID) : [];
1372
- const latestUser = findLatestUserMessage(hookMessages);
1373
- const sessionID = hookSessionID || (latestUser ? resolveEntrySessionID(latestUser.entry) : null);
1374
- // Both transforms use the hook session when an entry omits session identity.
1375
- const messages = scopeMessagesToSession(hookMessages, sessionID);
1376
- const report = (reason, newTokens = 0, duplicates = 0) => {
1377
- const measurement = {
1378
- new_tokens: newTokens,
1379
- retained_tokens: countRetainedInjectionTokens(messages),
1380
- duplicates_omitted: duplicates,
1381
- reason,
1382
- };
1383
- try { void Promise.resolve(recordMeasurement?.(measurement)).catch(() => {}); } catch { /* best effort */ }
1384
- };
1385
- if (!injectEnabled) {
1386
- recordSkipped?.("injection_disabled", sessionID);
1387
- report("injection_disabled");
1388
- return false;
1389
- }
1390
- if (!hasMessages) {
1391
- report("missing_history");
1392
- return false;
1393
- }
1394
- if (consumeCompactionInjectionSkip(compactionInjectionSkips, sessionID)) {
1395
- const cache = getSessionMessageInjectionCache(messageInjectionCache, sessionID);
1396
- normalizeInjectedMessageParts(messages, cache, { pruneAbsent: false });
1397
- recordSkipped?.("compaction_skipped", sessionID);
1398
- report("compaction_skipped");
1399
- return false;
1400
- }
1401
-
1402
- if (!latestUser) {
1403
- report("missing_user");
1404
- return false;
1405
- }
1406
-
1407
- const sessionCache = getSessionMessageInjectionCache(messageInjectionCache, sessionID);
1408
- normalizeInjectedMessageParts(messages, sessionCache);
1409
-
1410
- const latestMessageId = resolveEntryMessageId(latestUser.entry);
1411
- const canReplay = Boolean(sessionCache && latestMessageId);
1412
- const latestCached = canReplay ? sessionCache.get(latestMessageId) : null;
1413
- const latestWasCached = Boolean(latestCached) || latestUser.entry.parts?.some(isCodememContextPart);
1414
- if (sessionCache) {
1415
- try {
1416
- await appendCachedInjectedContextParts(messages, sessionCache, sessionID, {
1417
- latestMessageId, confirmDelivery, recordCacheReuse,
1418
- });
1419
- } catch (error) {
1420
- report("delivery_failed");
1421
- throw error;
1422
- }
1423
- }
1424
- const retainedTokens = countRetainedInjectionTokens(messages);
1425
- const fullBudget = Math.min(injectTokenBudget, Math.max(0, retainedTokenBudget - retainedTokens));
1426
- let latestWasBuilt = false;
1427
- let newlyDelivered = 0;
1428
- let duplicates = 0;
1429
- let reason = latestWasCached ? "replay" : "no_context";
1430
- if (!latestWasCached) {
1431
- if (reserveContextPrefixBudget(fullBudget) === null) {
1432
- if (reserveContextPrefixBudget(injectTokenBudget) !== null) {
1433
- recordSkipped?.("allowance_exhausted", sessionID);
1434
- }
1435
- report("allowance_exhausted");
1436
- return retainedTokens > 0;
1437
- }
1438
- const firstUser = findFirstUserMessage(messages);
1439
- const workingContext = workingContextDigest(messages, workingSet);
1440
- const query = resolveInjectQuery({
1441
- firstPrompt: firstUser ? extractMessageText(firstUser.entry) : null,
1442
- lastPromptText: extractMessageText(latestUser.entry),
1443
- });
1444
- const injected = await buildInjectedContext(query, {
1445
- sessionID,
1446
- requestKey: latestMessageId || fallbackEntryMessageId(latestUser.entry, latestUser.index),
1447
- fallbackTurn: sessionID && latestMessageId ? null : latestUser.index,
1448
- surface: "message",
1449
- tokenBudget: fullBudget,
1450
- retainedItems: retainedMemoryFingerprints(messages, sessionCache),
1451
- retainedMetadata: retainedMetadataGaps(messages, sessionCache),
1452
- workingContext,
1453
- continuationOnly: isContinuationOnly(extractMessageText(latestUser.entry), messages, workingContext, sessionCache),
1454
- });
1455
- // Another transform may have delivered while retrieval was awaiting I/O.
1456
- if (sessionCache) {
1457
- await appendCachedInjectedContextParts(messages, sessionCache, sessionID, {
1458
- latestMessageId, confirmDelivery, recordCacheReuse,
1459
- });
1460
- }
1461
- if (latestUser.entry.parts?.some(isCodememContextPart)) {
1462
- duplicates = injected?.duplicates || 0;
1463
- if (injected?.attemptId) {
1464
- confirmDelivery?.(injected.attemptId, "unknown", injected.evaluation);
1465
- }
1466
- report("replay", 0, duplicates);
1467
- return true;
1468
- }
1469
- const deliveryBudget = Math.min(fullBudget, Math.max(
1470
- 0, retainedTokenBudget - countRetainedInjectionTokens(messages),
1471
- ));
1472
- duplicates = injected?.duplicates || 0;
1473
- reason = injected?.skipReason || "no_context";
1474
- if (injected?.text && estimateTokens(injected.text) <= deliveryBudget) {
1475
- latestWasBuilt = true;
1476
- if (Array.isArray(latestUser.entry.parts)) {
1477
- try {
1478
- latestUser.entry.parts.push(
1479
- buildInjectedContextPart(latestUser.entry, latestUser.index, injected.text, {
1480
- messageId: latestMessageId || undefined,
1481
- sessionID: sessionID || undefined,
1482
- recall: injected.recall,
1483
- })
1484
- );
1485
- } catch (error) {
1486
- if (injected.attemptId) {
1487
- confirmDelivery?.(injected.attemptId, "failed", injected.evaluation);
1488
- }
1489
- report("delivery_failed", 0, duplicates);
1490
- throw error;
1491
- }
1492
- newlyDelivered = estimateTokens(injected.text);
1493
- reason = "delivered";
1494
- if (injected.attemptId) {
1495
- confirmDelivery?.(injected.attemptId, "handed_off", injected.evaluation);
1496
- }
1497
- if (canReplay) {
1498
- sessionCache.set(latestMessageId, {
1499
- ...injected,
1500
- parts: latestUser.entry.parts.filter(isCodememContextPart).map((part) => ({ ...part })),
1501
- reuseCount: 0,
1502
- });
1503
- }
1504
- }
1505
-
1506
- const toastKey = sessionID || latestMessageId || "unknown";
1507
- if (!injectionToastShown.has(toastKey) && showToast) {
1508
- injectionToastShown.add(toastKey);
1509
- try {
1510
- await showToast(buildInjectionToastMessage(injected.metrics));
1511
- } catch {
1512
- // best-effort only
1513
- }
1514
- }
1515
- } else if (injected?.skipReason) {
1516
- // The retrieval already has an attempt. Withheld output is not a second retrieval.
1517
- if (injected.attemptId) confirmDelivery?.(injected.attemptId, "unknown", injected.evaluation);
1518
- } else if (injected?.text) {
1519
- reason = "budget_rejected";
1520
- if (injected.attemptId) confirmDelivery?.(injected.attemptId, "failed", injected.evaluation);
1521
- }
1522
- }
1523
- report(reason, newlyDelivered, duplicates);
1524
-
1525
- if (!canReplay) {
1526
- return Boolean(
1527
- Array.isArray(latestUser.entry.parts)
1528
- && latestUser.entry.parts.some(isCodememContextPart)
1529
- );
1530
- }
1531
-
1532
- return latestWasBuilt || retainedTokens > 0;
1533
- };
1534
-
1535
- const countRetainedInjectionTokens = (messages) => messages.reduce(
1536
- (sum, entry) => sum + (entry.parts || []).filter(isCodememContextPart)
1537
- .reduce((tokens, part) => tokens + estimateTokens(part.text), 0),
1538
- 0,
1539
- );
1540
-
1541
- const injectionDigest = (text) => createHash("sha256").update(text).digest("hex");
1542
-
1543
- const retainedPartRecall = (part, cachedParts = []) => {
1544
- const valid = (candidate) => candidate?.metadata?.codemem?.v === 1
1545
- && candidate.metadata.codemem.digest === injectionDigest(String(part.text || ""));
1546
- if (valid(part)) return part.metadata.codemem;
1547
- const cached = cachedParts.find((candidate) => candidate.id === part.id && candidate.text === part.text && valid(candidate));
1548
- return cached?.metadata?.codemem;
1549
- };
1550
-
1551
- const workingContextDigest = (messages, workingSet) => {
1552
- const scalar = (value) => typeof value === "string" ? value.slice(0, 256) : null;
1553
- const hash = createHash("sha256");
1554
- for (const path of [...workingSet].filter((value) => typeof value === "string").sort()) {
1555
- hash.update(JSON.stringify(["file", path.slice(0, MAX_WORKING_SET_PATH_CHARS)]));
1556
- }
1557
- for (const entry of messages) {
1558
- for (const part of entry.parts || []) {
1559
- if (part.type === "text") continue;
1560
- // Never traverse args, output, attachments, or arbitrary tool-state payloads.
1561
- hash.update(JSON.stringify([
1562
- scalar(entry.info?.id), scalar(part.type), scalar(part.id),
1563
- scalar(part.callID), scalar(part.state?.status),
1564
- ]));
1565
- }
1566
- }
1567
- return hash.digest("hex");
1568
- };
1569
-
1570
- const isContinuationOnly = (prompt, messages, workingContext, sessionCache) => {
1571
- if (!/^(continue|proceed|go on|keep going)[.!]?$/i.test(prompt.trim())) return false;
1572
- const latest = findLatestUserMessage(messages);
1573
- for (let index = (latest?.index ?? 0) - 1; index >= 0; index -= 1) {
1574
- const entry = messages[index];
1575
- if (!isUserMessageEntry(entry)) continue;
1576
- return (entry.parts || []).some((part) => {
1577
- const recall = retainedPartRecall(part, sessionCache?.get(resolveEntryMessageId(entry))?.parts);
1578
- return isCodememContextPart(part) && recall?.v === 1
1579
- && recall.digest === injectionDigest(String(part.text || ""))
1580
- && recall.workingContext === workingContext;
1581
- });
1582
- }
1583
- return false;
1584
- };
1585
-
1586
- const retainedMemoryFingerprints = (messages, sessionCache) => {
1587
- const retained = new Set();
1588
- for (const entry of messages) {
1589
- for (const part of (entry.parts || []).filter(isCodememContextPart)) {
1590
- const recall = retainedPartRecall(part, sessionCache?.get(resolveEntryMessageId(entry))?.parts);
1591
- if (recall?.v !== 1 || recall.digest !== injectionDigest(String(part.text || ""))) continue;
1592
- for (const item of Array.isArray(recall.items) ? recall.items : []) {
1593
- if (isRecord(item) && Number.isSafeInteger(item.id) && item.id > 0 && /^[a-f0-9]{64}$/.test(item.fingerprint)) {
1594
- retained.add(`${item.id}:${item.fingerprint}`);
1595
- }
1596
- }
1597
- }
1598
- }
1599
- return retained;
1600
- };
1601
-
1602
- const filterRetainedPack = (stdout, packText, retained = new Set()) => {
1603
- const fallback = { text: packText, items: [], duplicates: 0, packMetadata: "missing" };
1604
- let payload;
1605
- try { payload = JSON.parse(stdout); } catch { return fallback; }
1606
- const items = payload?.rendered_items;
1607
- if (items != null) fallback.packMetadata = "invalid";
1608
- // Old transports and malformed metadata remain eligible rather than guessing from prose.
1609
- if (payload?.pack_text !== packText || !Array.isArray(items) || !items.length) return fallback;
1610
- if (payload.metrics?.total_items !== items.length) return fallback;
1611
- const spans = [];
1612
- const seen = new Set();
1613
- for (const item of items) {
1614
- if (!isRecord(item) || !Number.isSafeInteger(item.id) || item.id <= 0 || seen.has(item.id)
1615
- || !/^[a-f0-9]{64}$/.test(item.fingerprint) || !Array.isArray(item.spans) || !item.spans.length) return fallback;
1616
- seen.add(item.id);
1617
- for (const span of item.spans) {
1618
- if (!isRecord(span) || !Number.isSafeInteger(span.start) || !Number.isSafeInteger(span.end)
1619
- || span.start < 0 || span.end <= span.start || span.end > packText.length) return fallback;
1620
- spans.push({ ...span, omit: retained.has(`${item.id}:${item.fingerprint}`) });
1621
- }
1622
- }
1623
- if (payload.items != null) {
1624
- if (!Array.isArray(payload.items) || payload.items.length !== seen.size) return fallback;
1625
- const selectedIds = new Set(payload.items.map((item) => item?.id));
1626
- if (selectedIds.size !== seen.size || [...selectedIds].some((id) => !seen.has(id))) return fallback;
1627
- }
1628
- for (const ids of [payload.item_ids, payload.metrics?.pack_item_ids]) {
1629
- if (ids == null) continue;
1630
- if (!Array.isArray(ids) || new Set(ids).size !== ids.length
1631
- || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)
1632
- || [...seen].some((id) => !ids.includes(id))) return fallback;
1633
- }
1634
- spans.sort((a, b) => a.start - b.start);
1635
- if (spans.some((span, index) => index > 0 && span.start < spans[index - 1].end)) return fallback;
1636
- const kept = items.filter((item) => !retained.has(`${item.id}:${item.fingerprint}`));
1637
- let text = packText;
1638
- for (const span of spans.reverse()) {
1639
- if (span.omit) text = text.slice(0, span.start) + text.slice(span.end);
1640
- }
1641
- return {
1642
- text: kept.length ? text : "",
1643
- items: kept.map(({ id, fingerprint }) => ({ id, fingerprint })),
1644
- duplicates: items.length - kept.length,
1645
- packMetadata: "valid",
1646
- };
1647
- };
1648
-
1649
- const retainedMetadataGaps = (messages, sessionCache) => {
1650
- let missingRetainedMetadata = false;
1651
- let invalidRetainedMetadata = false;
1652
- for (const entry of messages) {
1653
- for (const part of (entry.parts || []).filter(isCodememContextPart)) {
1654
- const recall = retainedPartRecall(part, sessionCache?.get(resolveEntryMessageId(entry))?.parts);
1655
- if (recall?.v === 1 && Array.isArray(recall.items) && recall.items.length > 0
1656
- && recall.items.every((item) => isRecord(item) && Number.isSafeInteger(item.id) && item.id > 0 && /^[a-f0-9]{64}$/.test(item.fingerprint))) continue;
1657
- if (part.metadata?.codemem != null || recall != null) invalidRetainedMetadata = true;
1658
- else missingRetainedMetadata = true;
1659
- }
1660
- }
1661
- return { missingRetainedMetadata, invalidRetainedMetadata };
1662
- };
1663
-
1664
- const mapOpencodeEventTypeToAdapterType = (eventType) => {
1665
- if (eventType === "user_prompt") {
1666
- return "prompt";
1667
- }
1668
- if (eventType === "assistant_message") {
1669
- return "assistant";
1670
- }
1671
- if (eventType === "tool.execute.after") {
1672
- return "tool_result";
1673
- }
1674
- return null;
1675
- };
1676
-
1677
- const buildOpencodeAdapterPayload = (event) => {
1678
- const eventType = event?.type;
1679
- if (eventType === "user_prompt") {
1680
- const text = String(event?.prompt_text || "").trim();
1681
- if (!text) {
1682
- return null;
1683
- }
1684
- return {
1685
- text,
1686
- prompt_number:
1687
- typeof event?.prompt_number === "number" ? event.prompt_number : null,
1688
- };
1689
- }
1690
-
1691
- if (eventType === "assistant_message") {
1692
- const text = String(event?.assistant_text || "").trim();
1693
- if (!text) {
1694
- return null;
1695
- }
1696
- return { text };
1697
- }
1698
-
1699
- if (eventType === "tool.execute.after") {
1700
- const toolName = String(event?.tool || "unknown");
1701
- return {
1702
- tool_name: toolName,
1703
- status: event?.error ? "error" : "ok",
1704
- tool_input: event?.args || {},
1705
- tool_output: event?.result ?? null,
1706
- error: event?.error ?? null,
1707
- };
1708
- }
1709
-
1710
- return null;
1711
- };
1712
-
1713
- const stableStringify = (value) => {
1714
- if (value === null || typeof value !== "object") {
1715
- return JSON.stringify(value);
1716
- }
1717
- if (Array.isArray(value)) {
1718
- return `[${value.map(stableStringify).join(",")}]`;
1719
- }
1720
- const keys = Object.keys(value).sort();
1721
- return `{${keys
1722
- .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
1723
- .join(",")}}`;
1724
- };
1725
-
1726
- const stableDigest = (value) =>
1727
- createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 20);
1728
-
1729
- const sanitizeIdPart = (value, fallback, maxChars) => {
1730
- const normalized = String(value || "")
1731
- .replace(/[^A-Za-z0-9._:-]/g, "_")
1732
- .slice(0, maxChars);
1733
- return normalized || fallback;
1734
- };
1735
-
1736
- const buildAdapterEventId = ({ sessionID, eventType, event, payload, ts }) => {
1737
- const safeSessionID = sanitizeIdPart(sessionID, "unknown", 48);
1738
- const safeType = sanitizeIdPart(eventType, "event", 24);
1739
- const rawTimestamp =
1740
- typeof event?.timestamp === "string" && event.timestamp.trim()
1741
- ? event.timestamp.trim()
1742
- : ts;
1743
- const digest = stableDigest({
1744
- session_id: String(sessionID || ""),
1745
- event_type: String(eventType || ""),
1746
- raw_event_type: String(event?.type || ""),
1747
- timestamp: rawTimestamp,
1748
- payload,
1749
- });
1750
- return `oc:${safeSessionID}:${safeType}:${digest}`.slice(0, 128);
1751
- };
1752
-
1753
- const buildOpencodeAdapterEvent = ({ sessionID, event }) => {
1754
- if (!sessionID || !event || typeof event !== "object") {
1755
- return null;
1756
- }
1757
- const adapterType = mapOpencodeEventTypeToAdapterType(event.type);
1758
- if (!adapterType) {
1759
- return null;
1760
- }
1761
- const payload = buildOpencodeAdapterPayload(event);
1762
- if (!payload) {
1763
- return null;
1764
- }
1765
- const ts = typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString();
1766
- return {
1767
- schema_version: "1.0",
1768
- source: "opencode",
1769
- session_id: String(sessionID),
1770
- event_id: buildAdapterEventId({
1771
- sessionID,
1772
- eventType: adapterType,
1773
- event,
1774
- payload,
1775
- ts,
1776
- }),
1777
- event_type: adapterType,
1778
- ts,
1779
- ordering_confidence: "low",
1780
- payload,
1781
- meta: {
1782
- original_event_type: String(event.type || "unknown"),
1783
- },
1784
- };
1785
- };
1786
-
1787
- const normalizeProjectLabel = (value) => {
1788
- if (typeof value !== "string") {
1789
- return null;
1790
- }
1791
- const cleaned = value.trim();
1792
- if (!cleaned) {
1793
- return null;
1794
- }
1795
- if (cleaned.includes("/") || cleaned.includes("\\")) {
1796
- const normalized = cleaned.replaceAll("\\", "/").replace(/\/+$/, "");
1797
- return basename(normalized) || null;
1798
- }
1799
- return cleaned;
1800
- };
1801
-
1802
- const inferredProjectByCwd = new Map();
1803
-
1804
- const inferProjectFromCwd = (cwd) => {
1805
- if (typeof cwd !== "string") {
1806
- return null;
1807
- }
1808
- const cleaned = cwd.trim();
1809
- if (!cleaned) {
1810
- return null;
1811
- }
1812
- if (inferredProjectByCwd.has(cleaned)) {
1813
- return inferredProjectByCwd.get(cleaned);
1814
- }
1815
-
1816
- let current = cleaned;
1817
- while (true) {
1818
- const gitPath = `${current}/.git`;
1819
- if (existsSync(gitPath)) {
1820
- try {
1821
- const text = readFileSync(gitPath, "utf8").trim();
1822
- if (text.startsWith("gitdir:")) {
1823
- const normalized = resolve(current, text.slice("gitdir:".length).trim()).replaceAll(
1824
- "\\",
1825
- "/",
1826
- );
1827
- const worktreeMarker = "/.git/worktrees/";
1828
- const worktreeIndex = normalized.indexOf(worktreeMarker);
1829
- if (worktreeIndex >= 0) {
1830
- const inferred = normalizeProjectLabel(normalized.slice(0, worktreeIndex));
1831
- inferredProjectByCwd.set(cleaned, inferred);
1832
- return inferred;
1833
- }
1834
- }
1835
- } catch {
1836
- // .git is a directory in normal repos; fall through to cwd basename.
1837
- }
1838
- const inferred = normalizeProjectLabel(current);
1839
- inferredProjectByCwd.set(cleaned, inferred);
1840
- return inferred;
1841
- }
1842
- const parent = dirname(current);
1843
- if (parent === current) {
1844
- const inferred = normalizeProjectLabel(cleaned);
1845
- inferredProjectByCwd.set(cleaned, inferred);
1846
- return inferred;
1847
- }
1848
- current = parent;
1849
- }
1850
- };
1851
-
1852
- const resolveProjectName = (project, cwd) =>
1853
- normalizeProjectLabel(process.env.CODEMEM_PROJECT) ||
1854
- normalizeProjectLabel(project?.name) ||
1855
- normalizeProjectLabel(project?.root) ||
1856
- inferProjectFromCwd(cwd) ||
1857
- null;
1858
-
1859
- const selectRawEventId = ({ payload, nextEventId }) => {
1860
- const fromPayload =
1861
- payload &&
1862
- typeof payload === "object" &&
1863
- payload._raw_event_id;
1864
- return String(fromPayload || nextEventId());
1865
- };
1866
-
1867
- const buildRawEventEnvelope = ({
1868
- sessionID,
1869
- type,
1870
- payload,
1871
- cwd,
1872
- project,
1873
- startedAt,
1874
- nowMs,
1875
- nowMono,
1876
- nextEventId,
1877
- }) => ({
1878
- session_stream_id: sessionID,
1879
- session_id: sessionID,
1880
- opencode_session_id: sessionID,
1881
- event_id: selectRawEventId({ payload, nextEventId }),
1882
- event_type: type,
1883
- ts_wall_ms: nowMs,
1884
- ts_mono_ms: nowMono,
1885
- payload,
1886
- cwd,
1887
- project,
1888
- started_at: startedAt,
1889
- });
1890
-
1891
- const trimEventQueue = ({ events, maxEvents, hardMaxEvents, onUnsentPressure, onForcedDrop }) => {
1892
- if (!Number.isFinite(maxEvents) || maxEvents <= 0) {
1893
- return;
1894
- }
1895
- while (events.length > maxEvents) {
1896
- const droppableIndex = events.findIndex(
1897
- (queued) => queued && typeof queued === "object" && queued._raw_enqueued
1898
- );
1899
- if (droppableIndex >= 0) {
1900
- events.splice(droppableIndex, 1);
1901
- continue;
1902
- }
1903
- if (typeof onUnsentPressure === "function") {
1904
- onUnsentPressure(events.length, maxEvents);
1905
- }
1906
- if (
1907
- Number.isFinite(hardMaxEvents) &&
1908
- hardMaxEvents > 0 &&
1909
- events.length > hardMaxEvents
1910
- ) {
1911
- const durableIndex = events.findIndex(
1912
- (queued) => queued && typeof queued === "object" && queued._raw_spooled
1913
- );
1914
- const dropIndex = durableIndex >= 0 ? durableIndex : 0;
1915
- const [dropped] = events.splice(dropIndex, 1);
1916
- if (typeof onForcedDrop === "function") {
1917
- onForcedDrop(dropped, events.length, hardMaxEvents);
1918
- }
1919
- continue;
1920
- }
1921
- break;
1922
- }
1923
- };
1924
-
1925
- const attachAdapterEvent = ({ sessionID, event }) => {
1926
- if (!event || typeof event !== "object") {
1927
- return event;
1928
- }
1929
- let adapterEvent = null;
1930
- try {
1931
- adapterEvent = buildOpencodeAdapterEvent({ sessionID, event });
1932
- } catch (err) {
1933
- return event;
1934
- }
1935
- if (!adapterEvent) {
1936
- return event;
1937
- }
1938
- return {
1939
- ...event,
1940
- _adapter: adapterEvent,
1941
- };
1942
- };
1943
-
1944
- const asNonNegativeCount = (value) => {
1945
- if (Array.isArray(value)) {
1946
- return value.length;
1947
- }
1948
- if (typeof value === "number" && Number.isFinite(value)) {
1949
- return Math.max(0, Math.trunc(value));
1950
- }
1951
- return null;
1952
- };
1953
-
1954
- const asFiniteNonNegativeInt = (value) => {
1955
- if (typeof value !== "number" || !Number.isFinite(value)) {
1956
- return null;
1957
- }
1958
- if (value < 0) {
1959
- return null;
1960
- }
1961
- return Math.trunc(value);
1962
- };
1963
-
1964
- const parsePositiveInt = (value, fallback) => {
1965
- const parsed = Number.parseInt(String(value ?? ""), 10);
1966
- if (!Number.isFinite(parsed) || parsed <= 0) {
1967
- return fallback;
1968
- }
1969
- return parsed;
1970
- };
1971
-
1972
- const estimateTokens = (text) => Math.ceil(String(text || "").length / 4);
1973
-
1974
- const resolveInjectTokenBudget = (value) =>
1975
- parsePositiveInt(value, DEFAULT_INJECT_TOKEN_BUDGET);
1976
-
1977
- const resolveRetainedTokenBudget = (value) => {
1978
- const text = String(value ?? "").trim();
1979
- const parsed = Number(text);
1980
- return /^\d+$/.test(text) && Number.isSafeInteger(parsed) && parsed > 0
1981
- ? parsed : DEFAULT_RETAINED_TOKEN_BUDGET;
1982
- };
1983
-
1984
- const reserveContextPrefixBudget = (tokenBudget) => {
1985
- const packBudget = tokenBudget - estimateTokens(CODEMEM_CONTEXT_PREFIX);
1986
- return packBudget > 0 ? packBudget : null;
1987
- };
1988
-
1989
- const wrapInjectedContext = (packText) => `${CODEMEM_CONTEXT_PREFIX}${packText}`;
1990
-
1991
- export const buildInjectionToastMessage = (metrics) => {
1992
- const items = asFiniteNonNegativeInt(metrics?.items) ?? asFiniteNonNegativeInt(metrics?.total_items);
1993
- const packTokens = asFiniteNonNegativeInt(metrics?.pack_tokens);
1994
- const avoided = asFiniteNonNegativeInt(metrics?.avoided_work_tokens);
1995
- const avoidedUnknown = asNonNegativeCount(metrics?.avoided_work_unknown_items);
1996
- const avoidedKnown = asNonNegativeCount(metrics?.avoided_work_known_items);
1997
- const addedCount = asNonNegativeCount(metrics?.added_ids);
1998
- const removedCount = asNonNegativeCount(metrics?.removed_ids);
1999
- const deltaAvailable = metrics?.pack_delta_available === true;
2000
-
2001
- const messageParts = ["codemem injected"];
2002
- if (items !== null) messageParts.push(`${items} items`);
2003
- if (packTokens !== null) messageParts.push(`~${packTokens} tokens`);
2004
- if (
2005
- avoided !== null
2006
- && avoided > 0
2007
- && avoidedKnown !== null
2008
- && avoidedUnknown !== null
2009
- && avoidedKnown >= avoidedUnknown
2010
- ) {
2011
- messageParts.push(`avoided work ~${avoided} tokens`);
2012
- }
2013
- if (deltaAvailable && (addedCount !== null || removedCount !== null)) {
2014
- messageParts.push(`delta +${addedCount || 0}/-${removedCount || 0}`);
2015
- }
2016
- return messageParts.join(" · ");
2017
- };
2018
-
2019
- const detectRunner = ({ cwd, envRunner }) => {
2020
- if (envRunner) {
2021
- return envRunner;
2022
- }
2023
- // Prefer the TS codemem if installed globally, fall back to npx
2024
- try {
2025
- const versionOutput = execSync("codemem --version", {
2026
- encoding: "utf-8",
2027
- timeout: 3000,
2028
- // Suppress shell "not found" noise when codemem is not on PATH; the
2029
- // catch below falls back to npx. Without this, stderr leaks to the
2030
- // terminal on every OpenCode startup for npx-only installs.
2031
- stdio: ["ignore", "pipe", "ignore"],
2032
- }).trim();
2033
- // Use a newer global CLI only when it remains on the plugin's release
2034
- // channel. This prevents both channel crossing and fallback to an older pin
2035
- // immediately after a successful prerelease auto-update.
2036
- if (isSameChannelVersionAtLeast(versionOutput, PINNED_BACKEND_VERSION)) {
2037
- return "codemem";
2038
- }
2039
- } catch {
2040
- // not on PATH or timed out
2041
- }
2042
- return "npx";
2043
- };
2044
-
2045
- /**
2046
- * Check if the TS CLI is available at the given path.
2047
- * Used by the "node" runner to verify the built CLI exists.
2048
- */
2049
- const tsCliAvailable = (cliPath) => {
2050
- try {
2051
- return require("fs").existsSync(cliPath);
2052
- } catch {
2053
- return false;
2054
- }
2055
- };
2056
-
2057
- // When an explicit npx override targets the codemem package, pair it with the
2058
- // matching @codemem/embeddings spec so the detached Viewer can resolve the
2059
- // optional runtime instead of silently falling back to lexical search. Returns
2060
- // null for any spec that is not a bare `codemem`/`codemem@<version>`, so custom
2061
- // package overrides pass through unchanged.
2062
- const pairedEmbeddingsForCodememSpec = (spec) => {
2063
- if (typeof spec !== "string") return null;
2064
- const trimmed = spec.trim();
2065
- if (trimmed === "codemem") return "@codemem/embeddings";
2066
- const match = /^codemem@([^\s]+)$/.exec(trimmed);
2067
- if (!match || !/^[A-Za-z0-9~^<>=*][A-Za-z0-9._~^<>=*|-]*$/.test(match[1])) return null;
2068
- return `@codemem/embeddings@${match[1]}`;
2069
- };
2070
-
2071
- const buildRunnerArgs = ({ runner, runnerFrom, runnerFromExplicit }) => {
2072
- if (runner === "codemem") {
2073
- return [];
2074
- }
2075
- if (runner === "npx") {
2076
- if (runnerFromExplicit) {
2077
- const pairedEmbeddings = pairedEmbeddingsForCodememSpec(runnerFrom);
2078
- if (pairedEmbeddings) {
2079
- return [
2080
- "-y",
2081
- "--package",
2082
- runnerFrom,
2083
- "--package",
2084
- pairedEmbeddings,
2085
- "codemem",
2086
- ];
2087
- }
2088
- return ["-y", runnerFrom];
2089
- }
2090
- return [
2091
- "-y",
2092
- "--package",
2093
- `codemem@${PINNED_BACKEND_VERSION}`,
2094
- "--package",
2095
- `@codemem/embeddings@${PINNED_BACKEND_VERSION}`,
2096
- "codemem",
2097
- ];
2098
- }
2099
- if (runner === "node") {
2100
- const cliPath = runnerFromExplicit
2101
- ? runnerFrom
2102
- : join(runnerFrom, "packages/cli/dist/index.js");
2103
- return [cliPath];
2104
- }
2105
- // Custom runner via CODEMEM_RUNNER env — pass through as-is
2106
- return runnerFromExplicit ? [runnerFrom] : [];
2107
- };
2108
-
2109
- export const CodememPlugin = async ({
2110
- project,
2111
- client,
2112
- directory,
2113
- worktree,
2114
- }) => {
2115
- const events = [];
2116
- const maxEvents = parsePositiveInt(process.env.CODEMEM_PLUGIN_MAX_EVENTS, 200);
2117
- const maxChars = Number.parseInt(
2118
- process.env.CODEMEM_PLUGIN_MAX_EVENT_CHARS || "8000",
2119
- 10
2120
- );
2121
- const cwd = worktree || directory || process.cwd();
2122
- const debug = envHasValue(process.env.CODEMEM_PLUGIN_DEBUG, TRUTHY_VALUES);
2123
- const debugExtraction = envHasValue(
2124
- process.env.CODEMEM_DEBUG_EXTRACTION,
2125
- TRUTHY_VALUES
2126
- );
2127
- const logTimeoutMs = Number.parseInt(
2128
- process.env.CODEMEM_PLUGIN_LOG_TIMEOUT_MS || "1500",
2129
- 10
2130
- );
2131
- const logPathEnvRaw = process.env.CODEMEM_PLUGIN_LOG || "";
2132
- const logPath = resolveLogPath(logPathEnvRaw, cwd, process.env.HOME);
2133
- const errorLogPath = resolveErrorLogPath(cwd, process.env.HOME);
2134
- const logLine = createLogLine(logPath);
2135
- const errorLogLine = createLogLine(errorLogPath);
2136
- const log = createDebugLogger({
2137
- debug,
2138
- client,
2139
- logTimeoutMs,
2140
- getLogLine: () => logLine,
2141
- getErrorLogLine: () => errorLogLine,
2142
- });
2143
- const pluginIgnored = envHasValue(
2144
- process.env.CODEMEM_PLUGIN_IGNORE,
2145
- TRUTHY_VALUES
2146
- );
2147
- if (pluginIgnored) {
2148
- return {};
2149
- }
2150
- const releasePluginRegistration = claimPluginRegistration(cwd);
2151
- if (!releasePluginRegistration) {
2152
- await log("warn", "codemem duplicate plugin registration skipped", {
2153
- next_action: "remove either the configured npm plugin or the project-local Codemem plugin",
2154
- });
2155
- return {};
2156
- }
2157
-
2158
- const runner = detectRunner({
2159
- cwd,
2160
- envRunner: process.env.CODEMEM_RUNNER,
2161
- });
2162
- const runnerFromExplicit = Boolean(String(process.env.CODEMEM_RUNNER_FROM || "").trim());
2163
- const runnerFrom = process.env.CODEMEM_RUNNER_FROM || cwd;
2164
- const runnerArgs = buildRunnerArgs({ runner, runnerFrom, runnerFromExplicit });
2165
- const viewerEnabled = envNotDisabled(process.env.CODEMEM_VIEWER || "1");
2166
- const viewerAutoStart = envNotDisabled(
2167
- process.env.CODEMEM_VIEWER_AUTO || "1"
2168
- );
2169
- const viewerAutoStop = envNotDisabled(
2170
- process.env.CODEMEM_VIEWER_AUTO_STOP || "1"
2171
- );
2172
- const viewerHost = process.env.CODEMEM_VIEWER_HOST || "127.0.0.1";
2173
- const viewerPort = process.env.CODEMEM_VIEWER_PORT || "38888";
2174
- const viewerDbPath = process.env.CODEMEM_DB || "";
2175
- const expandedViewerDbPath = viewerDbPath.startsWith("~/")
2176
- ? join(process.env.HOME?.trim() || homedir(), viewerDbPath.slice(2))
2177
- : viewerDbPath;
2178
- const promptPackDbPath = resolve(
2179
- cwd,
2180
- expandedViewerDbPath || join(homedir(), ".codemem", "mem.sqlite"),
2181
- );
2182
- const promptPackIdentityTarget = buildViewerIdentityTarget(process.env, cwd);
2183
- const viewerConfigPath = process.env.CODEMEM_CONFIG || "";
2184
- // A malformed value (e.g. "abc", "", "-1") falls back to the 20s default
2185
- // instead of NaN, which would silently disable the timeout. An explicit "0"
2186
- // is preserved as the intentional opt-out (disable the timeout, e.g. for slow
2187
- // first-run npx installs/backend updates) — the `commandTimeout > 0` guard at
2188
- // the call site skips installing the timer when it is 0.
2189
- const rawCommandTimeout = String(process.env.CODEMEM_PLUGIN_CMD_TIMEOUT ?? "").trim();
2190
- const commandTimeout =
2191
- rawCommandTimeout === "0" ? 0 : parsePositiveInt(rawCommandTimeout, 20000);
2192
- const promptPackHttpTimeout =
2193
- parsePositiveInt(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S || "2", 2) * 1000;
2194
- const backendUpdatePolicy = parseBackendUpdatePolicy(
2195
- process.env.CODEMEM_BACKEND_UPDATE_POLICY || "notify"
2196
- );
2197
-
2198
- const parseNumber = (value, fallback) => {
2199
- const parsed = Number.parseInt(value, 10);
2200
- return Number.isFinite(parsed) ? parsed : fallback;
2201
- };
2202
- const injectEnabled = envNotDisabled(
2203
- process.env.CODEMEM_INJECT_CONTEXT || "1"
2204
- );
2205
- const injectSurface = resolveInjectSurface(process.env.CODEMEM_INJECT_SURFACE);
2206
- // The item limit remains optional so the CLI can use its configured default.
2207
- const injectLimitEnv = process.env.CODEMEM_INJECT_LIMIT;
2208
- const injectLimit = injectLimitEnv ? parseNumber(injectLimitEnv, null) : null;
2209
- const injectTokenBudget = resolveInjectTokenBudget(
2210
- process.env.CODEMEM_INJECT_TOKEN_BUDGET
2211
- );
2212
- const injectPackTokenBudget = reserveContextPrefixBudget(injectTokenBudget);
2213
- const retainedTokenBudget = resolveRetainedTokenBudget(process.env.CODEMEM_INJECT_RETAINED_TOKEN_BUDGET);
2214
- if (injectEnabled && injectPackTokenBudget === null) {
2215
- await log(
2216
- "warn",
2217
- "codemem context injection skipped: token budget is too small",
2218
- {
2219
- inject_token_budget: injectTokenBudget,
2220
- required_prefix_tokens: estimateTokens(CODEMEM_CONTEXT_PREFIX),
2221
- next_action: "increase CODEMEM_INJECT_TOKEN_BUDGET or unset it to use the default",
2222
- }
2223
- );
2224
- }
2225
- const injectionToastShown = new Set();
2226
- const messageInjectionCache = new Map();
2227
- const compactionInjectionSkips = new Map();
2228
- const disabledInjectionRecorded = new Set();
2229
- const latestPolicySkips = new Map();
2230
- const attemptStartedAt = new Map();
2231
- const promptPackRetryCounts = new Map();
2232
- const successfulPromptPackArtifacts = new Map();
2233
- let sessionStartedAt = null;
2234
- let activeSessionID = null;
2235
- let viewerStarted = false;
2236
- let viewerStartInFlight = false;
2237
- let compatibilityAutoUpdateAttempted = false;
2238
- let promptCounter = 0;
2239
- let skippedAttemptCounter = 0;
2240
- let lastPromptText = null;
2241
- let lastAssistantText = null;
2242
- const assistantUsageCaptured = new Set();
2243
-
2244
- // Track message roles and accumulated text by messageID
2245
- const messageRoles = new Map();
2246
- const messageTexts = new Map();
2247
- let debugLogCount = 0;
2248
-
2249
- const rawEventsEnabled = envNotDisabled(
2250
- process.env.CODEMEM_RAW_EVENTS || "1"
2251
- );
2252
- const viewerUrlHost = viewerHost.includes(":") && !viewerHost.startsWith("[")
2253
- ? `[${viewerHost}]`
2254
- : viewerHost;
2255
- const rawEventsUrl = `http://${viewerUrlHost}:${viewerPort}/api/raw-events`;
2256
- const rawEventsStatusUrl = `http://${viewerUrlHost}:${viewerPort}/api/raw-events/status?limit=1`;
2257
- const packUrl = `http://${viewerUrlHost}:${viewerPort}/api/pack`;
2258
- const promptPackProfileUrl = `http://${viewerUrlHost}:${viewerPort}/api/prompt-pack-profile`;
2259
- const promptPackLedgerUrl = `http://${viewerUrlHost}:${viewerPort}/api/prompt-pack-ledger`;
2260
- const viewerHealthUrl = `http://${viewerUrlHost}:${viewerPort}/api/health`;
2261
- const rawEventsBackoffMs = parseNumber(
2262
- process.env.CODEMEM_RAW_EVENTS_BACKOFF_MS || "10000",
2263
- 10000
2264
- );
2265
- const rawEventsStatusCheckMs = parseNumber(
2266
- process.env.CODEMEM_RAW_EVENTS_STATUS_CHECK_MS || "30000",
2267
- 30000
2268
- );
2269
- const rawEventsHardMax = parseNumber(
2270
- process.env.CODEMEM_RAW_EVENTS_HARD_MAX || "2000",
2271
- 2000
2272
- );
2273
- const rawEventSpoolDrainLimit = parsePositiveInt(
2274
- process.env.CODEMEM_RAW_EVENT_SPOOL_DRAIN_LIMIT,
2275
- DEFAULT_DRAIN_LIMIT,
2276
- );
2277
- const rawEventSpoolMaxEntries = parsePositiveInt(
2278
- process.env.CODEMEM_RAW_EVENT_SPOOL_MAX_ENTRIES,
2279
- DEFAULT_MAX_ENTRIES,
2280
- );
2281
- const rawEventSpoolHome = process.env.HOME?.trim() || homedir();
2282
- const rawEventSpoolDirectory = resolveSpoolDirectory(rawEventSpoolHome);
2283
- // Memoize the exact serialized envelope by queued-object identity so retries
2284
- // cannot drift in timestamp, ID, property order, or bytes.
2285
- const rawEventEnvelopes = new WeakMap();
2286
- let streamUnavailableUntil = 0;
2287
- let spoolPersistenceFailureNoted = null;
2288
- let spoolLoadFailureNoted = false;
2289
- let lastStatusCheckAt = 0;
2290
- let lastStatusAvailable = true;
2291
- let promptPackTransportUnavailableUntil = 0;
2292
-
2293
- const nextEventId = () => {
2294
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
2295
- return crypto.randomUUID();
2296
- }
2297
- return `${Date.now()}-${Math.random()}`;
2298
- };
2299
-
2300
- const queueRawEventViaCli = async (body, serialized = JSON.stringify(body)) => {
2301
- const runFallback = () => runCli(["enqueue-raw-event"], {
2302
- stdinText: serialized,
2303
- });
2304
- let result = await runFallback();
2305
- let classification = classifyFallbackCommandResult(result);
2306
- let attemptedRetry = false;
2307
- if (result?.exitCode !== 0 && classification.retryable) {
2308
- attemptedRetry = true;
2309
- result = await runFallback();
2310
- classification = classifyFallbackCommandResult(result);
2311
- }
2312
- if (result?.exitCode !== 0) {
2313
- const retryExhausted = attemptedRetry && classification.retryable;
2314
- const error = new Error(
2315
- retryExhausted ? `${classification.cause} after retry` : classification.cause
2316
- );
2317
- error.retryable = classification.retryable;
2318
- throw error;
2319
- }
2320
- return true;
2321
- };
2322
-
2323
- const lastToastAtBySession = new Map();
2324
- const shouldToast = (sessionID, category = "general") => {
2325
- const now = Date.now();
2326
- const key = `${sessionID || "unknown"}:${category}`;
2327
- const last = lastToastAtBySession.get(key) || 0;
2328
- if (now - last < 60000) {
2329
- return false;
2330
- }
2331
- lastToastAtBySession.set(key, now);
2332
- return true;
2333
- };
2334
-
2335
- const warnSpoolPersistenceFailure = async (sessionID, reason) => {
2336
- const message = reason === "spool_full"
2337
- ? "codemem raw-event retry spool is full; repair Codemem, then archive retained entries"
2338
- : "codemem could not save a raw event for retry; it remains queued in memory";
2339
- try {
2340
- await client.app.log({
2341
- service: "codemem",
2342
- level: "error",
2343
- message,
2344
- extra: { category: "persistence", reason },
2345
- });
2346
- } catch {
2347
- // Best-effort app logging only.
2348
- }
2349
- if (spoolPersistenceFailureNoted === reason) return;
2350
- spoolPersistenceFailureNoted = reason;
2351
- if (!client.tui?.showToast || !shouldToast(sessionID, `persistence:${reason}`)) return;
2352
- try {
2353
- await client.tui.showToast({
2354
- body: { message: `codemem: ${message}`, variant: "error" },
2355
- });
2356
- } catch {
2357
- // Best-effort toast only.
2358
- }
2359
- };
2360
-
2361
- const persistRawEventForRetry = async ({ body, serialized, payload, sessionID }) => {
2362
- try {
2363
- await writeRawEventSpoolEntry({
2364
- envelope: body,
2365
- serialized,
2366
- homeDir: rawEventSpoolHome,
2367
- maxEntries: rawEventSpoolMaxEntries,
2368
- });
2369
- spoolPersistenceFailureNoted = null;
2370
- if (payload && typeof payload === "object") {
2371
- payload._raw_spooled = true;
2372
- }
2373
- return true;
2374
- } catch (error) {
2375
- const reason = error?.code === RAW_EVENT_SPOOL_FULL_CODE ? "spool_full" : "write_failed";
2376
- await logLine(`raw_events.spool.${reason} category=persistence`);
2377
- await warnSpoolPersistenceFailure(sessionID, reason);
2378
- return false;
2379
- }
2380
- };
2381
-
2382
- const removeRawEventFromSpool = async ({ eventId, payload }) => {
2383
- try {
2384
- await removeRawEventSpoolEntry({ eventId, homeDir: rawEventSpoolHome });
2385
- if (payload && typeof payload === "object") {
2386
- payload._raw_spooled = false;
2387
- }
2388
- } catch {
2389
- await logLine("raw_events.spool.cleanup_failed category=persistence");
2390
- }
2391
- };
2392
-
2393
- const notifyRawEventDelivery = async ({ category, delivered, durable, sessionID }) => {
2394
- const action = RAW_EVENT_FAILURE_ACTIONS[category] || RAW_EVENT_FAILURE_ACTIONS.connection;
2395
- let outcome = "was saved for retry";
2396
- if (delivered) {
2397
- outcome = "was queued via CLI";
2398
- } else if (!durable) {
2399
- outcome = "was not saved for retry; still queued in memory";
2400
- }
2401
- const message = `codemem raw event ${outcome}; ${action}`;
2402
- let delivery = "memory";
2403
- if (delivered) {
2404
- delivery = "cli";
2405
- } else if (durable) {
2406
- delivery = "spool";
2407
- }
2408
- try {
2409
- await client.app.log({
2410
- service: "codemem",
2411
- level: delivered ? "warn" : "error",
2412
- message,
2413
- extra: {
2414
- category,
2415
- delivery,
2416
- },
2417
- });
2418
- } catch {
2419
- // Best-effort app logging only.
2420
- }
2421
- if (!client.tui?.showToast || !shouldToast(sessionID, `${category}:${delivery}`)) return;
2422
- try {
2423
- await client.tui.showToast({
2424
- body: { message: `codemem: ${message}`, variant: delivered ? "warning" : "error" },
2425
- });
2426
- } catch {
2427
- // Best-effort toast only.
2428
- }
2429
- };
2430
-
2431
- const drainRawEventSpool = () => {
2432
- if (!rawEventsEnabled) {
2433
- return Promise.resolve();
2434
- }
2435
- const existingDrain = rawEventSpoolDrainsInFlight.get(rawEventSpoolDirectory);
2436
- if (existingDrain) {
2437
- return existingDrain;
2438
- }
2439
- const drainPromise = (async () => {
2440
- let loaded;
2441
- try {
2442
- loaded = await loadRawEventSpoolEntries({
2443
- homeDir: rawEventSpoolHome,
2444
- limit: rawEventSpoolDrainLimit,
2445
- });
2446
- } catch {
2447
- await logLine("raw_events.spool.load_failed category=persistence");
2448
- const message = "codemem could not read saved raw events; spool entries were left untouched";
2449
- try {
2450
- await client.app.log({
2451
- service: "codemem",
2452
- level: "error",
2453
- message,
2454
- extra: { category: "persistence", delivery: "load" },
2455
- });
2456
- } catch {
2457
- // Best-effort app logging only.
2458
- }
2459
- if (!spoolLoadFailureNoted) {
2460
- spoolLoadFailureNoted = true;
2461
- if (client.tui?.showToast && shouldToast(null, "persistence:load")) {
2462
- try {
2463
- await client.tui.showToast({
2464
- body: { message: `codemem: ${message}`, variant: "error" },
2465
- });
2466
- } catch {
2467
- // Best-effort toast only.
2468
- }
2469
- }
2470
- }
2471
- return;
2472
- }
2473
- spoolLoadFailureNoted = false;
2474
- if (loaded.corruptCount > 0) {
2475
- await logLine(`raw_events.spool.corrupt_retained count=${loaded.corruptCount}`);
2476
- }
2477
- for (const entry of loaded.entries) {
2478
- try {
2479
- await queueRawEventViaCli(entry.envelope, entry.serialized);
2480
- await removeRawEventFromSpool({ eventId: entry.eventId });
2481
- } catch (error) {
2482
- await logLine("raw_events.spool.drain_deferred category=fallback");
2483
- if (error?.retryable === true) {
2484
- break;
2485
- }
2486
- }
2487
- }
2488
- })();
2489
- const trackedDrain = drainPromise
2490
- .catch(async () => {
2491
- await logLine("raw_events.spool.drain_failed category=persistence");
2492
- })
2493
- .finally(() => {
2494
- if (rawEventSpoolDrainsInFlight.get(rawEventSpoolDirectory) === trackedDrain) {
2495
- rawEventSpoolDrainsInFlight.delete(rawEventSpoolDirectory);
2496
- }
2497
- });
2498
- rawEventSpoolDrainsInFlight.set(rawEventSpoolDirectory, trackedDrain);
2499
- return trackedDrain;
2500
- };
2501
-
2502
- const buildViewerCliArgs = (action) => {
2503
- const args = ["serve", action, "--host", viewerHost, "--port", viewerPort];
2504
- if (String(viewerDbPath || "").trim()) {
2505
- args.push("--db-path", viewerDbPath);
2506
- }
2507
- if (String(viewerConfigPath || "").trim()) {
2508
- args.push("--config", viewerConfigPath);
2509
- }
2510
- return args;
2511
- };
2512
-
2513
- const emitRawEvent = async ({ sessionID, type, payload }) => {
2514
- if (!rawEventsEnabled) {
2515
- return true;
2516
- }
2517
- if (!sessionID || !type) {
2518
- return false;
2519
- }
2520
- const now = Date.now();
2521
- let cachedEnvelope = payload && typeof payload === "object"
2522
- ? rawEventEnvelopes.get(payload)
2523
- : null;
2524
- if (!cachedEnvelope) {
2525
- const builtEnvelope = buildRawEventEnvelope({
2526
- sessionID,
2527
- type,
2528
- payload,
2529
- cwd,
2530
- project: resolveProjectName(project, cwd),
2531
- startedAt: sessionStartedAt,
2532
- nowMs: now,
2533
- nowMono:
2534
- typeof performance !== "undefined" && performance.now
2535
- ? performance.now()
2536
- : null,
2537
- nextEventId,
2538
- });
2539
- const serialized = JSON.stringify(builtEnvelope);
2540
- cachedEnvelope = { body: JSON.parse(serialized), serialized };
2541
- if (payload && typeof payload === "object") {
2542
- rawEventEnvelopes.set(payload, cachedEnvelope);
2543
- }
2544
- }
2545
- const { body, serialized } = cachedEnvelope;
2546
- if (now < streamUnavailableUntil) {
2547
- const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
2548
- try {
2549
- await queueRawEventViaCli(body, serialized);
2550
- await removeRawEventFromSpool({ eventId: body.event_id, payload });
2551
- if (payload && typeof payload === "object") {
2552
- payload._raw_enqueued = true;
2553
- }
2554
- await notifyRawEventDelivery({
2555
- category: "connection",
2556
- delivered: true,
2557
- durable,
2558
- sessionID,
2559
- });
2560
- return true;
2561
- } catch {
2562
- await logLine("raw_events.fallback.error category=connection");
2563
- await notifyRawEventDelivery({
2564
- category: "connection",
2565
- delivered: false,
2566
- durable,
2567
- sessionID,
2568
- });
2569
- return false;
2570
- }
2571
- }
2572
- try {
2573
- if (now - lastStatusCheckAt >= Math.max(1000, rawEventsStatusCheckMs)) {
2574
- const statusResp = await fetchRawEventsStatus(rawEventsStatusUrl);
2575
- if (!statusResp.ok) {
2576
- // Release the unread body before bailing into the backoff path.
2577
- discardResponseBody(statusResp);
2578
- throw new Error(`raw-events status failed (${statusResp.status})`);
2579
- }
2580
- const statusJson = await statusResp.json();
2581
- lastStatusAvailable = statusJson?.ingest?.available !== false;
2582
- lastStatusCheckAt = now;
2583
- }
2584
- if (!lastStatusAvailable) {
2585
- throw new Error("raw-events ingest unavailable");
2586
- }
2587
-
2588
- const postResp = await fetch(rawEventsUrl, {
2589
- method: "POST",
2590
- headers: { "Content-Type": "application/json" },
2591
- body: JSON.stringify({
2592
- ...body,
2593
- db_path: promptPackDbPath,
2594
- identity_target: promptPackIdentityTarget,
2595
- }),
2596
- });
2597
- if (!postResp.ok) {
2598
- let responseBody = null;
2599
- try {
2600
- responseBody = await postResp.json();
2601
- } catch {
2602
- // Generic connection guidance below remains the safe fallback.
2603
- }
2604
- const postError = new Error(`raw-events post failed (${postResp.status})`);
2605
- postError.rawEventFailureCategory = classifyRawEventViewerFailure(responseBody);
2606
- throw postError;
2607
- }
2608
- streamUnavailableUntil = 0;
2609
- lastStatusAvailable = true;
2610
- await removeRawEventFromSpool({ eventId: body.event_id, payload });
2611
- if (payload && typeof payload === "object") {
2612
- payload._raw_enqueued = true;
2613
- }
2614
- return true;
2615
- } catch (err) {
2616
- const category = err?.rawEventFailureCategory || "connection";
2617
- streamUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
2618
- await logLine(`raw_events.error category=${category}`);
2619
- const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
2620
-
2621
- let fallbackOk = false;
2622
- try {
2623
- await queueRawEventViaCli(body, serialized);
2624
- await removeRawEventFromSpool({ eventId: body.event_id, payload });
2625
- fallbackOk = true;
2626
- } catch {
2627
- await logLine(`raw_events.fallback.error category=${category}`);
2628
- }
2629
-
2630
- if (fallbackOk) {
2631
- if (payload && typeof payload === "object") {
2632
- payload._raw_enqueued = true;
2633
- }
2634
- await notifyRawEventDelivery({ category, delivered: true, durable, sessionID });
2635
- return true;
2636
- }
2637
-
2638
- await notifyRawEventDelivery({ category, delivered: false, durable, sessionID });
2639
- return false;
2640
- }
2641
- };
2642
-
2643
- const extractSessionID = (event) => {
2644
- if (!event) {
2645
- return null;
2646
- }
2647
- return event?.properties?.sessionID || null;
2648
- };
2649
-
2650
- const extractHookSessionID = (input) => {
2651
- if (!input || typeof input !== "object") {
2652
- return null;
2653
- }
2654
- return (
2655
- input.sessionID ||
2656
- input.sessionId ||
2657
- input.session?.id ||
2658
- input.session?.sessionID ||
2659
- input.properties?.sessionID ||
2660
- null
2661
- );
2662
- };
2663
-
2664
- // Session context tracking for comprehensive memories
2665
- const sessionContext = {
2666
- firstPrompt: null,
2667
- promptCount: 0,
2668
- toolCount: 0,
2669
- startTime: null,
2670
- filesModified: new Set(),
2671
- filesRead: new Set(),
2672
- };
2673
-
2674
- const resetSessionContext = () => {
2675
- sessionContext.firstPrompt = null;
2676
- sessionContext.promptCount = 0;
2677
- sessionContext.toolCount = 0;
2678
- sessionContext.startTime = null;
2679
- sessionContext.filesModified = new Set();
2680
- sessionContext.filesRead = new Set();
2681
- };
2682
-
2683
- // Check if we should force flush immediately (threshold-based)
2684
- const shouldForceFlush = () => {
2685
- const { toolCount, promptCount } = sessionContext;
2686
- // Force flush if we've accumulated a lot of work
2687
- if (toolCount >= 50 || promptCount >= 15) {
2688
- return true;
2689
- }
2690
- // Force flush if session has been running for 10+ minutes
2691
- if (sessionContext.startTime) {
2692
- const sessionDurationMs = Date.now() - sessionContext.startTime;
2693
- if (sessionDurationMs >= 600000) { // 10 minutes
2694
- return true;
2695
- }
2696
- }
2697
- return false;
2698
- };
2699
-
2700
-
2701
- const updateActivity = () => {};
2702
-
2703
- const extractPromptText = (event) => {
2704
- if (!event) {
2705
- return null;
2706
- }
2707
-
2708
- // For message.updated events, track the role and check if we have buffered text
2709
- if (event.type === "message.updated" && event.properties?.info) {
2710
- const info = event.properties.info;
2711
- if (info.id && info.role) {
2712
- messageRoles.set(info.id, info.role);
2713
-
2714
- // If we have buffered text for this message and it's a user message, return it
2715
- if (info.role === "user" && messageTexts.has(info.id)) {
2716
- const text = messageTexts.get(info.id);
2717
- messageTexts.delete(info.id); // Clean up
2718
- if (debugExtraction) {
2719
- logLine(
2720
- `user prompt captured from buffered text id=${info.id.slice(
2721
- -8
2722
- )} len=${text.length}`
2723
- );
2724
- }
2725
- return text;
2726
- }
2727
- }
2728
- return null;
2729
- }
2730
-
2731
- // For message.part.updated events, accumulate or return text based on known role
2732
- if (event.type === "message.part.updated" && event.properties?.part) {
2733
- const part = event.properties.part;
2734
- if (part.type !== "text" || !part.text) {
2735
- return null;
2736
- }
2737
-
2738
- const role = messageRoles.get(part.messageID);
2739
- if (role === "user") {
2740
- // We know it's a user message, return the text immediately
2741
- if (debugExtraction) {
2742
- logLine(
2743
- `user prompt captured immediately id=${part.messageID.slice(
2744
- -8
2745
- )} len=${part.text.length}`
2746
- );
2747
- }
2748
- return part.text.trim() || null;
2749
- } else if (!role) {
2750
- // Buffer this text until we know the role
2751
- const existing = messageTexts.get(part.messageID) || "";
2752
- messageTexts.set(part.messageID, existing + part.text);
2753
- if (debugExtraction) {
2754
- logLine(
2755
- `buffering text for unknown role id=${part.messageID.slice(
2756
- -8
2757
- )} len=${(existing + part.text).length}`
2758
- );
2759
- }
2760
- }
2761
- }
2762
-
2763
- return null;
2764
- };
2765
-
2766
- const extractAssistantText = (event) => {
2767
- if (!event) {
2768
- return null;
2769
- }
2770
-
2771
- // Only capture assistant messages when complete (message.updated with finish)
2772
- if (event.type === "message.updated" && event.properties?.info) {
2773
- const info = event.properties.info;
2774
- if (info.id && info.role) {
2775
- messageRoles.set(info.id, info.role);
2776
-
2777
- // Log when we see an assistant message.updated (debug only)
2778
- if (debugExtraction && info.role === "assistant") {
2779
- logLine(
2780
- `assistant message.updated id=${info.id.slice(
2781
- -8
2782
- )} finish=${!!info.finish} hasText=${messageTexts.has(
2783
- info.id
2784
- )} textLen=${messageTexts.get(info.id)?.length || 0}`
2785
- );
2786
- }
2787
-
2788
- // Only return assistant text when message is finished
2789
- if (
2790
- info.role === "assistant" &&
2791
- info.finish &&
2792
- messageTexts.has(info.id)
2793
- ) {
2794
- const text = messageTexts.get(info.id);
2795
- messageTexts.delete(info.id); // Clean up
2796
- return text.trim() || null;
2797
- }
2798
- }
2799
- return null;
2800
- }
2801
-
2802
- // For message.part.updated, store the latest text (don't capture yet)
2803
- // Store for ALL messages regardless of role - role might not be known yet
2804
- if (event.type === "message.part.updated" && event.properties?.part) {
2805
- const part = event.properties.part;
2806
- if (part.type === "text" && part.text) {
2807
- // Store latest text, will be captured on finish (for assistant) or on role discovery (for user)
2808
- if (debugExtraction) {
2809
- const prevLen = messageTexts.get(part.messageID)?.length || 0;
2810
- logLine(
2811
- `text part stored id=${part.messageID.slice(
2812
- -8
2813
- )} prevLen=${prevLen} newLen=${part.text.length} role=${
2814
- messageRoles.get(part.messageID) || "unknown"
2815
- }`
2816
- );
2817
- }
2818
- messageTexts.set(part.messageID, part.text);
2819
- }
2820
- }
2821
-
2822
- return null;
2823
- };
2824
-
2825
- const normalizeUsage = (usage) => {
2826
- if (!usage || typeof usage !== "object") {
2827
- return null;
2828
- }
2829
- const inputTokens = Number(usage.input_tokens || 0);
2830
- const outputTokens = Number(usage.output_tokens || 0);
2831
- const cacheCreationTokens = Number(usage.cache_creation_input_tokens || 0);
2832
- const cacheReadTokens = Number(usage.cache_read_input_tokens || 0);
2833
- const total = inputTokens + outputTokens + cacheCreationTokens;
2834
- if (!Number.isFinite(total) || total <= 0) {
2835
- return null;
2836
- }
2837
- return {
2838
- input_tokens: inputTokens,
2839
- output_tokens: outputTokens,
2840
- cache_creation_input_tokens: cacheCreationTokens,
2841
- cache_read_input_tokens: cacheReadTokens,
2842
- };
2843
- };
2844
-
2845
- const extractAssistantUsage = (event) => {
2846
- if (!event || event.type !== "message.updated" || !event.properties?.info) {
2847
- return null;
2848
- }
2849
- const info = event.properties.info;
2850
- if (!info.id || info.role !== "assistant" || !info.finish) {
2851
- return null;
2852
- }
2853
- if (assistantUsageCaptured.has(info.id)) {
2854
- return null;
2855
- }
2856
- const usage = normalizeUsage(
2857
- info.usage || event.properties?.usage || event.usage
2858
- );
2859
- if (!usage) {
2860
- return null;
2861
- }
2862
- assistantUsageCaptured.add(info.id);
2863
- return { usage, id: info.id };
2864
- };
2865
-
2866
- const startViewer = async () => {
2867
- if (!viewerEnabled || !viewerAutoStart || viewerStarted || viewerStartInFlight) {
2868
- if (viewerStarted) logLine("viewer already started, skipping auto-start").catch(() => {});
2869
- return;
2870
- }
2871
- viewerStartInFlight = true;
2872
- let existingViewer = false;
2873
- try {
2874
- const existing = await fetch(viewerHealthUrl, {
2875
- method: "GET",
2876
- redirect: "manual",
2877
- signal: AbortSignal.timeout(1_000),
2878
- });
2879
- existingViewer = existing.ok;
2880
- } catch {
2881
- // No live viewer responded; proceed with the plugin-owned start.
2882
- }
2883
- if (existingViewer) {
2884
- viewerStartInFlight = false;
2885
- logLine("viewer already running, skipping plugin-owned auto-start").catch(() => {});
2886
- return;
2887
- }
2888
- const viewerArgs = buildViewerCliArgs("start");
2889
- const cmd = [runner, ...runnerArgs, ...viewerArgs];
2890
- logLine(`auto-starting viewer: ${cmd.join(" ")}`).catch(() => {});
2891
- try {
2892
- const child = nodeSpawn(cmd[0], cmd.slice(1), {
2893
- cwd,
2894
- env: process.env,
2895
- detached: true,
2896
- stdio: "ignore",
2897
- });
2898
- child.once("spawn", () => {
2899
- viewerStarted = true;
2900
- viewerStartInFlight = false;
2901
- startHealthCheck();
2902
- });
2903
- child.on("error", (err) => {
2904
- viewerStartInFlight = false;
2905
- logLine(`viewer spawn error: ${err.message}`).catch(() => {});
2906
- });
2907
- child.unref();
2908
- } catch (err) {
2909
- viewerStartInFlight = false;
2910
- logLine(`viewer spawn failed: ${err}`).catch(() => {});
2911
- }
2912
- };
2913
-
2914
- const runCommand = async (cmd, options = {}) => {
2915
- const {
2916
- env = process.env,
2917
- stdinText = null,
2918
- timeoutMs = commandTimeout,
2919
- } = options;
2920
- const [command, ...args] = cmd;
2921
- return new Promise((resolve) => {
2922
- const proc = nodeSpawn(command, args, {
2923
- cwd,
2924
- env,
2925
- stdio: ["pipe", "pipe", "pipe"],
2926
- });
2927
- let stdout = "";
2928
- let stderr = "";
2929
- // Guard stdio access: a hard spawn failure (ENOENT) can hand back null
2930
- // streams, and a synchronous null-deref here would reject the promise
2931
- // that callers await for a resolved { exitCode, stdout, stderr }.
2932
- if (proc.stdout) proc.stdout.on("data", (chunk) => { stdout += chunk; });
2933
- if (proc.stderr) proc.stderr.on("data", (chunk) => { stderr += chunk; });
2934
- // Absorb async stream errors (e.g. EPIPE when the child exits mid-write,
2935
- // or a spawn failure surfacing on a pipe). Without these handlers an
2936
- // unhandled stream "error" event would crash the host process. The
2937
- // child-level proc.once("error") below still resolves the promise.
2938
- for (const stream of [proc.stdin, proc.stdout, proc.stderr]) {
2939
- if (stream && typeof stream.on === "function") stream.on("error", () => {});
2940
- }
2941
- if (typeof stdinText === "string") {
2942
- try {
2943
- proc.stdin.write(stdinText);
2944
- } catch (stdinErr) {
2945
- try { proc.kill(); } catch { /* ignore */ }
2946
- resolve({ exitCode: 1, stdout: "", stderr: `stdin write failed: ${String(stdinErr)}` });
2947
- return;
2948
- }
2949
- }
2950
- try {
2951
- proc.stdin.end();
2952
- } catch (stdinErr) {
2953
- try { proc.kill(); } catch { /* ignore */ }
2954
- resolve({ exitCode: 1, stdout: "", stderr: `stdin close failed: ${String(stdinErr)}` });
2955
- return;
2956
- }
2957
- let timer = null;
2958
- let killTimer = null;
2959
- let timedOut = false;
2960
- let settled = false;
2961
- const finish = (result) => {
2962
- if (settled) return;
2963
- settled = true;
2964
- if (timer) clearTimeout(timer);
2965
- if (killTimer) clearTimeout(killTimer);
2966
- resolve(result);
2967
- };
2968
- if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
2969
- timer = setTimeout(() => {
2970
- timedOut = true;
2971
- try { proc.kill("SIGTERM"); } catch { /* ignore */ }
2972
- killTimer = setTimeout(() => {
2973
- try { proc.kill("SIGKILL"); } catch { /* ignore */ }
2974
- }, 5_000);
2975
- if (killTimer.unref) killTimer.unref();
2976
- }, timeoutMs);
2977
- }
2978
- proc.once("exit", (exitCode) => {
2979
- finish({ exitCode: timedOut ? null : exitCode, stdout, stderr: timedOut ? "timeout" : stderr });
2980
- });
2981
- proc.once("error", (err) => {
2982
- finish({ exitCode: 1, stdout: "", stderr: String(err) });
2983
- });
2984
- });
2985
- };
2986
-
2987
- const runCli = async (args, options = {}) =>
2988
- runCommand([runner, ...runnerArgs, ...args], options);
2989
-
2990
- const postViewerJson = async ({ url, operation, payload, validate }) => {
2991
- if (!viewerEnabled) {
2992
- return {
2993
- ok: false,
2994
- classification: viewerFailureClassification(
2995
- `${operation} viewer transport disabled`,
2996
- "fallback",
2997
- ),
2998
- };
2999
- }
3000
- if (Date.now() < promptPackTransportUnavailableUntil) {
3001
- return {
3002
- ok: false,
3003
- classification: viewerFailureClassification(
3004
- `${operation} viewer transport in backoff`,
3005
- "fallback",
3006
- ),
3007
- };
3008
- }
3009
-
3010
- let response;
3011
- try {
3012
- const profileResponse = await fetch(promptPackProfileUrl, {
3013
- method: "GET",
3014
- redirect: "manual",
3015
- signal: AbortSignal.timeout(promptPackHttpTimeout),
3016
- });
3017
- let profileBody;
3018
- try {
3019
- profileBody = await profileResponse.json();
3020
- } catch {
3021
- profileBody = null;
3022
- }
3023
- if (!profileResponse.ok) {
3024
- const classification = classifyViewerHttpFailure({
3025
- operation: `${operation} profile`,
3026
- status: profileResponse.status,
3027
- body: profileBody,
3028
- });
3029
- if (classification.retryable) {
3030
- promptPackTransportUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
3031
- }
3032
- return { ok: false, classification };
3033
- }
3034
- const viewerProtocolRange = isRecord(profileBody)
3035
- ? normalizePromptTransportProtocolRange(
3036
- profileBody.protocol_version,
3037
- profileBody.min_supported_protocol_version,
3038
- )
3039
- : null;
3040
- let profileFailure = null;
3041
- if (!isRecord(profileBody) || profileBody.service !== "codemem-viewer") {
3042
- profileFailure = viewerFailureClassification(
3043
- `${operation} viewer profile malformed`,
3044
- classifyPromptTransportFailure({ kind: "profile_malformed" }),
3045
- );
3046
- } else if (!viewerProtocolRange) {
3047
- profileFailure = viewerFailureClassification(
3048
- `${operation} viewer protocol range malformed`,
3049
- classifyPromptTransportFailure({ kind: "profile_malformed" }),
3050
- );
3051
- } else if (!arePromptTransportProtocolRangesCompatible(
3052
- PROMPT_TRANSPORT_PROTOCOL_RANGE,
3053
- viewerProtocolRange,
3054
- )) {
3055
- profileFailure = viewerFailureClassification(
3056
- `${operation} viewer protocol range unsupported`,
3057
- classifyPromptTransportFailure({ kind: "protocol_range_mismatch" }),
3058
- );
3059
- } else if (profileBody.db_path !== promptPackDbPath) {
3060
- profileFailure = viewerFailureClassification(
3061
- `${operation} viewer database mismatch`,
3062
- classifyPromptTransportFailure({ kind: "database_mismatch" }),
3063
- );
3064
- } else if (
3065
- canonicalJson(profileBody.identity_target) !== canonicalJson(promptPackIdentityTarget)
3066
- ) {
3067
- profileFailure = viewerFailureClassification(
3068
- `${operation} viewer runtime identity mismatch`,
3069
- classifyPromptTransportFailure({ kind: "runtime_identity_mismatch" }),
3070
- );
3071
- }
3072
- if (profileFailure) {
3073
- promptPackTransportUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
3074
- return {
3075
- ok: false,
3076
- classification: profileFailure,
3077
- };
3078
- }
3079
- response = await fetch(url, {
3080
- method: "POST",
3081
- redirect: "manual",
3082
- headers: { "Content-Type": "application/json" },
3083
- body: JSON.stringify(payload),
3084
- signal: AbortSignal.timeout(promptPackHttpTimeout),
3085
- });
3086
- } catch (error) {
3087
- const classification = classifyViewerHttpFailure({ operation, error });
3088
- promptPackTransportUnavailableUntil =
3089
- Date.now() + Math.max(1000, rawEventsBackoffMs);
3090
- return { ok: false, classification };
3091
- }
3092
-
3093
- let body;
3094
- try {
3095
- body = await response.json();
3096
- } catch {
3097
- body = null;
3098
- }
3099
- if (!response.ok) {
3100
- const classification = classifyViewerHttpFailure({
3101
- operation,
3102
- status: response.status,
3103
- body,
3104
- compatibleProfile: true,
3105
- });
3106
- if (classification.retryable) {
3107
- promptPackTransportUnavailableUntil =
3108
- Date.now() + Math.max(1000, rawEventsBackoffMs);
3109
- }
3110
- return { ok: false, classification };
3111
- }
3112
-
3113
- if (!validate(body)) {
3114
- const classification = classifyViewerHttpFailure({ operation, malformed: true });
3115
- promptPackTransportUnavailableUntil =
3116
- Date.now() + Math.max(1000, rawEventsBackoffMs);
3117
- return { ok: false, classification };
3118
- }
3119
-
3120
- promptPackTransportUnavailableUntil = 0;
3121
- return { ok: true, body };
3122
- };
3123
-
3124
- const attemptMetadata = (identity, sessionID = null, promptNumber = promptCounter) => ({
3125
- attempt_id: identity.attemptId,
3126
- started_at: (() => {
3127
- const existing = attemptStartedAt.get(identity.attemptId);
3128
- if (existing) return existing;
3129
- const created = new Date().toISOString();
3130
- attemptStartedAt.set(identity.attemptId, created);
3131
- while (attemptStartedAt.size > 2000) {
3132
- const oldest = attemptStartedAt.keys().next().value;
3133
- if (!oldest) break;
3134
- attemptStartedAt.delete(oldest);
3135
- }
3136
- return created;
3137
- })(),
3138
- source: "opencode",
3139
- ...(sessionID ? { stream_id: String(sessionID), source_session_id: String(sessionID) } : {}),
3140
- ...(promptNumber > 0 ? { prompt_number: promptNumber } : {}),
3141
- request_id: identity.requestId,
3142
- });
3143
-
3144
- const runPromptPackLedger = async (payload, { viewerOnly = false } = {}) => {
3145
- const viewerPayload = {
3146
- ...payload,
3147
- db_path: promptPackDbPath,
3148
- identity_target: promptPackIdentityTarget,
3149
- };
3150
- const httpResult = await postViewerJson({
3151
- url: promptPackLedgerUrl,
3152
- operation: "prompt-pack-ledger",
3153
- payload: viewerPayload,
3154
- validate: isValidLedgerHttpPayload,
3155
- });
3156
- if (httpResult.ok) {
3157
- return {
3158
- exitCode: 0,
3159
- stdout: JSON.stringify(httpResult.body),
3160
- stderr: "",
3161
- transport: "viewer",
3162
- };
3163
- }
3164
-
3165
- const { cause, kind, retryable } = httpResult.classification;
3166
- await logLine(
3167
- `inject.ledger.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
3168
- );
3169
- if (!retryable || viewerOnly) {
3170
- return { exitCode: 1, stdout: "", stderr: cause, transport: "viewer", failureKind: kind };
3171
- }
3172
-
3173
- try {
3174
- const result = await runCli(["prompt-pack-ledger"], {
3175
- stdinText: JSON.stringify(payload),
3176
- });
3177
- return { ...result, transport: "cli" };
3178
- } catch {
3179
- return null;
3180
- }
3181
- };
3182
-
3183
- const skippedIdentity = (failureCode, sessionID, surface, eventKey) =>
3184
- promptPackIdentity({
3185
- sessionID: sessionID || "unknown",
3186
- requestKey: `${failureCode}:${eventKey}`,
3187
- surface,
3188
- promptNumber: promptCounter,
3189
- queryHash: hashPromptPackQuery(""),
3190
- });
3191
-
3192
- const recordSkippedPromptPack = (failureCode, sessionID = null, surface = injectSurface, requestKey = null) => {
3193
- const sessionKey = String(sessionID || "unknown");
3194
- const memoKey = `${surface}:${sessionKey}`;
3195
- if (requestKey) {
3196
- const signature = `${requestKey}:${failureCode}`;
3197
- if (latestPolicySkips.get(memoKey) === signature) return null;
3198
- latestPolicySkips.delete(memoKey);
3199
- latestPolicySkips.set(memoKey, signature);
3200
- while (latestPolicySkips.size > MAX_MESSAGE_INJECTION_CACHE_SESSIONS) {
3201
- latestPolicySkips.delete(latestPolicySkips.keys().next().value);
3202
- }
3203
- }
3204
- if (failureCode === "injection_disabled" && disabledInjectionRecorded.has(memoKey)) {
3205
- return null;
3206
- }
3207
- if (failureCode === "injection_disabled") disabledInjectionRecorded.add(memoKey);
3208
- const identity = skippedIdentity(
3209
- failureCode,
3210
- sessionID,
3211
- surface,
3212
- requestKey || (failureCode === "injection_disabled" ? "once" : `event-${++skippedAttemptCounter}`),
3213
- );
3214
- void runPromptPackLedger({
3215
- action: "record",
3216
- ...attemptMetadata(identity, sessionID),
3217
- retrieval_status: "skipped",
3218
- failure_code: failureCode,
3219
- failure_stage: "policy",
3220
- });
3221
- return identity.attemptId;
3222
- };
3223
-
3224
- const recordCachedPromptPack = (cached, { messageId, sessionID } = {}) => {
3225
- cached.reuseCount = (cached.reuseCount || 0) + 1;
3226
- const identity = promptPackIdentity({
3227
- sessionID: sessionID || "unknown",
3228
- requestKey: `${messageId || "unknown"}:cache:${cached.reuseCount}`,
3229
- surface: "message",
3230
- promptNumber: cached.promptNumber || promptCounter,
3231
- queryHash: cached.queryHash || hashPromptPackQuery(""),
3232
- });
3233
- const ready = runPromptPackLedger({
3234
- action: "cache_reuse",
3235
- ...attemptMetadata(identity, sessionID, cached.promptNumber || promptCounter),
3236
- original_attempt_id: cached.attemptId,
3237
- });
3238
- return { attemptId: identity.attemptId, ready };
3239
- };
3240
-
3241
- const confirmPromptPackDelivery = (attemptId, deliveryStatus = "handed_off", evaluation) => {
3242
- const delivery = {
3243
- action: "delivery", attempt_id: attemptId, delivery_status: deliveryStatus,
3244
- };
3245
- void runPromptPackLedger({
3246
- ...delivery,
3247
- ...evaluation,
3248
- }).then((result) => {
3249
- // Retry additive fields only after the transport identifies that exact compatibility case.
3250
- if (
3251
- evaluation
3252
- && result?.transport === "viewer"
3253
- && result.failureKind === "viewer_contract_unsupported"
3254
- ) {
3255
- return runPromptPackLedger(delivery, { viewerOnly: true });
3256
- }
3257
- // Older CLI fallbacks can reject optional fields. Preserve the original receipt;
3258
- // never retry a Viewer policy/auth rejection through a different transport.
3259
- if (evaluation && result?.transport === "cli" && result.exitCode !== 0) {
3260
- return runPromptPackLedger(delivery);
3261
- }
3262
- }).catch(() => {});
3263
- };
3264
-
3265
- const showToast = async (message, variant = "warning") => {
3266
- if (backendUpdatePolicy === "off") {
3267
- return;
3268
- }
3269
- if (!client.tui?.showToast) {
3270
- return;
3271
- }
3272
- try {
3273
- await client.tui.showToast({
3274
- body: {
3275
- message,
3276
- variant,
3277
- },
3278
- });
3279
- } catch (toastErr) {
3280
- // best-effort only
3281
- }
3282
- };
3283
-
3284
- const restartViewerAfterAutoUpdate = async () => {
3285
- if (!viewerEnabled || !viewerAutoStart || !viewerStarted) {
3286
- return { attempted: false, ok: false };
3287
- }
3288
- const restartResult = await runCli(buildViewerCliArgs("restart"), { timeoutMs: 60_000 });
3289
- if (restartResult?.exitCode === 0) {
3290
- await logLine("compat.auto_update_viewer_restart ok");
3291
- return { attempted: true, ok: true };
3292
- }
3293
- await logLine(
3294
- `compat.auto_update_viewer_restart_failed exit=${restartResult?.exitCode ?? "unknown"} stderr=${redactLog(
3295
- (restartResult?.stderr || "").trim()
3296
- )}`
3297
- );
3298
- return { attempted: true, ok: false };
3299
- };
3300
-
3301
- const verifyCliCompatibility = async () => {
3302
- const minVersion = process.env.CODEMEM_MIN_VERSION || "0.9.20";
3303
- const cacheKey = resolveCompatCheckCacheKey({
3304
- backendUpdatePolicy,
3305
- minVersion,
3306
- runner,
3307
- runnerFrom,
3308
- });
3309
- const cachedVersion = readCompatCheckCache(cacheKey);
3310
- if (cachedVersion && isVersionAtLeast(cachedVersion, minVersion)) {
3311
- await logLine(`compat.version_check_cached current=${cachedVersion} required=${minVersion}`);
3312
- return;
3313
- }
3314
-
3315
- const versionResult = await runCli(["version"]);
3316
- if (!versionResult || versionResult.exitCode !== 0) {
3317
- await logLine(
3318
- `compat.version_check_failed exit=${versionResult?.exitCode ?? "unknown"} stderr=${
3319
- versionResult?.stderr ? redactLog(versionResult.stderr.trim()) : ""
3320
- }`
3321
- );
3322
- return;
3323
- }
3324
-
3325
- const currentVersion = (versionResult.stdout || "").trim();
3326
- const parsedCurrent = parseSemver(currentVersion);
3327
- const parsedMinimum = parseSemver(minVersion);
3328
- if (!parsedCurrent || !parsedMinimum) {
3329
- const guidance = resolveUpgradeGuidance({ runner, runnerFrom });
3330
- await logLine(
3331
- `compat.version_unparsed current=${redactLog(currentVersion || "")} required=${redactLog(minVersion)}`
3332
- );
3333
- await log("warn", "codemem compatibility check could not parse versions", {
3334
- currentVersion,
3335
- minVersion,
3336
- runner,
3337
- runnerFromSet: Boolean(String(runnerFrom || "").trim()),
3338
- upgradeMode: guidance.mode,
3339
- });
3340
- await showToast(
3341
- `codemem compatibility check could not parse versions (cli='${currentVersion || "unknown"}', required='${minVersion}'). Suggested action: ${guidance.action}`,
3342
- "warning"
3343
- );
3344
- return;
3345
- }
3346
-
3347
- if (isVersionAtLeast(currentVersion, minVersion)) {
3348
- writeCompatCheckCache(cacheKey, currentVersion);
3349
- return;
3350
- }
3351
-
3352
- clearCompatCheckCache();
3353
-
3354
- const guidance = resolveUpgradeGuidance({ runner, runnerFrom });
3355
- const message = `codemem CLI ${currentVersion || "unknown"} is older than required ${minVersion}`;
3356
- await log("warn", message, {
3357
- currentVersion,
3358
- minVersion,
3359
- runner,
3360
- runnerFromSet: Boolean(String(runnerFrom || "").trim()),
3361
- upgradeMode: guidance.mode,
3362
- upgradeAction: guidance.action,
3363
- });
3364
- await logLine(
3365
- `compat.version_mismatch current=${currentVersion} required=${minVersion} mode=${guidance.mode} note=${redactLog(guidance.note)}`
3366
- );
3367
-
3368
- if (backendUpdatePolicy === "auto") {
3369
- compatibilityAutoUpdateAttempted = true;
3370
- const notification = parseReleaseNotification(
3371
- await runCli(["update", "check", "--json"])
3372
- );
3373
- if (!notification?.autoUpdateEligible) {
3374
- await logLine("compat.auto_update_skipped reason=release_not_eligible");
3375
- await showToast(
3376
- `${message}. Auto-update skipped (not eligible). Suggested action: ${guidance.action}`,
3377
- "warning"
3378
- );
3379
- return;
3380
- }
3381
- const autoPlan = resolveAutoUpdatePlan({
3382
- runner,
3383
- runnerFrom,
3384
- runnerFromExplicit,
3385
- targetVersion: notification.latestVersion,
3386
- });
3387
- if (!autoPlan.allowed) {
3388
- await logLine(`compat.auto_update_skipped reason=${autoPlan.reason || "not_eligible"}`);
3389
- await showToast(
3390
- `${message}. Auto-update skipped (not eligible). Suggested action: ${guidance.action}`,
3391
- "warning"
3392
- );
3393
- return;
3394
- }
3395
- await logLine(`compat.auto_update_start cmd=${autoPlan.commandText}`);
3396
- const updateResult = await runCommand(autoPlan.command, {
3397
- env: resolveAutoUpdateEnvironment(),
3398
- timeoutMs: 480_000,
3399
- });
3400
- if (updateResult?.exitCode === 0) {
3401
- await logLine(
3402
- `compat.auto_update_result exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog(
3403
- (updateResult?.stderr || "").trim()
3404
- )}`
3405
- );
3406
-
3407
- const refreshedResult = await runCli(["version"]);
3408
- const refreshedVersion = (refreshedResult?.stdout || "").trim();
3409
- if (
3410
- refreshedResult?.exitCode === 0
3411
- && refreshedVersion === notification.latestVersion
3412
- && isVersionAtLeast(refreshedVersion, minVersion)
3413
- ) {
3414
- writeCompatCheckCache(cacheKey, refreshedVersion);
3415
- const viewerRestart = await restartViewerAfterAutoUpdate();
3416
- await logLine(
3417
- `compat.auto_update_success before=${currentVersion} after=${refreshedVersion}`
3418
- );
3419
- await showToast(
3420
- `Updated codemem backend from ${currentVersion || "unknown"} to ${refreshedVersion}.`,
3421
- "success"
3422
- );
3423
- if (viewerRestart.attempted && !viewerRestart.ok) {
3424
- await showToast(
3425
- "Backend updated, but viewer restart failed. Run `codemem serve restart`.",
3426
- "warning"
3427
- );
3428
- }
3429
- return;
3430
- }
3431
- await logLine(
3432
- `compat.auto_update_verification_failed current=${redactLog(refreshedVersion || "unknown")} required=${redactLog(minVersion)}`
3433
- );
3434
- await showToast(
3435
- `${message}. Auto-update completed, but the active CLI failed verification. Suggested action: ${guidance.action}`,
3436
- "warning"
3437
- );
3438
- return;
3439
- }
3440
- await logLine(
3441
- `compat.auto_update_skipped reason=update_install_failed exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog((updateResult?.stderr || "").trim())}`
3442
- );
3443
- await showToast(
3444
- `${message}. Auto-update skipped (installation failed). Suggested action: ${guidance.action}`,
3445
- "warning"
3446
- );
3447
- return;
3448
- }
3449
-
3450
- await showToast(`${message}. Suggested action: ${guidance.action}`, "warning");
3451
- };
3452
-
3453
- const checkForReleaseUpdate = async () => {
3454
- if (backendUpdatePolicy === "off" || compatibilityAutoUpdateAttempted) return;
3455
- const notification = parseReleaseNotification(
3456
- await runCli(["update", "check", "--json"])
3457
- );
3458
- if (
3459
- !notification
3460
- || notifiedReleaseVersions.has(notification.latestVersion)
3461
- ) {
3462
- return;
3463
- }
3464
- notifiedReleaseVersions.add(notification.latestVersion);
3465
- if (
3466
- backendUpdatePolicy === "auto"
3467
- && notification.autoUpdateEligible
3468
- && !compatibilityAutoUpdateAttempted
3469
- ) {
3470
- const autoPlan = resolveAutoUpdatePlan({
3471
- runner,
3472
- runnerFrom,
3473
- runnerFromExplicit,
3474
- targetVersion: notification.latestVersion,
3475
- });
3476
- if (autoPlan.allowed) {
3477
- await logLine(`release.auto_update_start cmd=${autoPlan.commandText}`);
3478
- const installation = await runCommand(autoPlan.command, {
3479
- env: resolveAutoUpdateEnvironment(),
3480
- timeoutMs: 480_000,
3481
- });
3482
- if (installation?.exitCode === 0) {
3483
- const verification = await runCli(["version"]);
3484
- const installedVersion = (verification?.stdout || "").trim();
3485
- if (verification?.exitCode !== 0 || installedVersion !== notification.latestVersion) {
3486
- await logLine(
3487
- `release.auto_update_verification_failed current=${redactLog(installedVersion || "unknown")} expected=${notification.latestVersion}`
3488
- );
3489
- await showToast(
3490
- `codemem ${notification.latestVersion} was installed, but the active CLI failed verification.`,
3491
- "warning"
3492
- );
3493
- return;
3494
- }
3495
- const viewerRestart = await restartViewerAfterAutoUpdate();
3496
- await showToast(`Updated codemem to ${notification.latestVersion}.`, "success");
3497
- if (viewerRestart.attempted && !viewerRestart.ok) {
3498
- await showToast(
3499
- "Backend updated, but viewer restart failed. Run `codemem serve restart`.",
3500
- "warning"
3501
- );
3502
- }
3503
- return;
3504
- }
3505
- await logLine(
3506
- `release.auto_update_failed exit=${installation?.exitCode ?? "unknown"} stderr=${redactLog(
3507
- (installation?.stderr || "").trim()
3508
- )}`
3509
- );
3510
- }
3511
- }
3512
- await showToast(
3513
- `codemem ${notification.latestVersion} is available. ${notification.recommendedAction}`,
3514
- "warning"
3515
- );
3516
- };
3517
-
3518
- const resolveInjectQuery = (overrides = {}) => {
3519
- const firstPrompt = hasOwn(overrides, "firstPrompt")
3520
- ? overrides.firstPrompt
3521
- : sessionContext.firstPrompt;
3522
- const resolvedLastPromptText = hasOwn(overrides, "lastPromptText")
3523
- ? overrides.lastPromptText
3524
- : lastPromptText;
3525
- return buildInjectQuery({
3526
- firstPrompt,
3527
- lastPromptText: resolvedLastPromptText,
3528
- projectName: resolveProjectName(project, cwd),
3529
- filesModified: sessionContext.filesModified,
3530
- });
3531
- };
3532
-
3533
- const describeInjectQuery = (query, overrides = {}) => {
3534
- const safeQuery = redactLog((query || "").trim(), 240);
3535
- const projectName = resolveProjectName(project, cwd) || "";
3536
- const firstPrompt = hasOwn(overrides, "firstPrompt")
3537
- ? overrides.firstPrompt
3538
- : sessionContext.firstPrompt;
3539
- const resolvedLastPromptText = hasOwn(overrides, "lastPromptText")
3540
- ? overrides.lastPromptText
3541
- : lastPromptText;
3542
- return {
3543
- safeQuery,
3544
- firstPromptLen: firstPrompt?.trim()?.length || 0,
3545
- lastPromptLen: resolvedLastPromptText?.trim()?.length || 0,
3546
- projectName,
3547
- filesModifiedCount: sessionContext.filesModified.size,
3548
- };
3549
- };
3550
-
3551
- const redactLog = (value, limit = 400) => {
3552
- if (!value) return "";
3553
- const masked = String(value).replace(/(Bearer\s+)[^\s]+/gi, "$1[redacted]");
3554
- return masked.length > limit ? `${masked.slice(0, limit)}…` : masked;
3555
- };
3556
-
3557
- const advancePromptPackRetryIdentity = (attemptKey) => {
3558
- promptPackRetryCounts.set(
3559
- attemptKey,
3560
- (promptPackRetryCounts.get(attemptKey) || 0) + 1
3561
- );
3562
- while (promptPackRetryCounts.size > 2000) {
3563
- const oldest = promptPackRetryCounts.keys().next().value;
3564
- if (!oldest) break;
3565
- promptPackRetryCounts.delete(oldest);
3566
- }
3567
- };
3568
-
3569
- const rememberSuccessfulPromptPackArtifact = (
3570
- attemptKey,
3571
- retryCount,
3572
- fingerprint
3573
- ) => {
3574
- successfulPromptPackArtifacts.delete(attemptKey);
3575
- successfulPromptPackArtifacts.set(attemptKey, {
3576
- retryCount,
3577
- fingerprint,
3578
- });
3579
- while (successfulPromptPackArtifacts.size > 2000) {
3580
- const oldest = successfulPromptPackArtifacts.keys().next().value;
3581
- if (!oldest) break;
3582
- successfulPromptPackArtifacts.delete(oldest);
3583
- }
3584
- };
3585
-
3586
- let fallbackEvaluationSessionId = nextEventId();
3587
- const buildInjectedContext = async (query, context = {}) => {
3588
- const requestPackBudget = reserveContextPrefixBudget(context.tokenBudget ?? injectTokenBudget);
3589
- const queryHash = hashPromptPackQuery(query);
3590
- // Requester eligibility comes only from the transform's own session identity.
3591
- // activeSessionID tracks the latest event stream, which can belong to another
3592
- // overlapping session; inferring from it would authorize the wrong summaries.
3593
- const requesterHostSessionID = context.sessionID || null;
3594
- const sessionID = requesterHostSessionID || "unknown";
3595
- const surface = context.surface || injectSurface;
3596
- let requestKey = context.requestKey || "unknown";
3597
- if (context.fallbackTurn != null) {
3598
- requestKey = JSON.stringify([requestKey, fallbackEvaluationSessionId, promptCounter, context.fallbackTurn]);
3599
- }
3600
- const evaluationFields = (
3601
- beforeText,
3602
- afterText,
3603
- candidateItems,
3604
- duplicatesOmitted,
3605
- packMetadata,
3606
- artifactFingerprint,
3607
- ) => ({
3608
- evaluation_key: injectionDigest(JSON.stringify([
3609
- sessionID,
3610
- requestKey,
3611
- surface,
3612
- queryHash,
3613
- artifactFingerprint,
3614
- ])),
3615
- automatic_recall: {
3616
- v: 1, candidateItems, duplicatesOmitted,
3617
- beforeTokens: beforeText ? estimateTokens(wrapInjectedContext(beforeText)) : 0,
3618
- afterTokens: afterText ? estimateTokens(wrapInjectedContext(afterText)) : 0,
3619
- missingRetainedMetadata: context.retainedMetadata?.missingRetainedMetadata === true,
3620
- invalidRetainedMetadata: context.retainedMetadata?.invalidRetainedMetadata === true,
3621
- packMetadata,
3622
- },
3623
- });
3624
- const attemptKey = JSON.stringify([
3625
- sessionID,
3626
- requestKey,
3627
- surface,
3628
- promptCounter,
3629
- queryHash,
3630
- ]);
3631
- let retryCount = promptPackRetryCounts.get(attemptKey) || 0;
3632
- const resolveIdentity = () => promptPackIdentity({
3633
- sessionID,
3634
- requestKey: retryCount > 0
3635
- ? `${requestKey}:empty-retry:${retryCount}`
3636
- : requestKey,
3637
- surface,
3638
- promptNumber: promptCounter,
3639
- queryHash,
3640
- });
3641
- let identity = resolveIdentity();
3642
- let metadata = attemptMetadata(
3643
- identity,
3644
- requesterHostSessionID,
3645
- );
3646
- if (requestPackBudget === null) {
3647
- return {
3648
- text: "",
3649
- attemptId: identity.attemptId,
3650
- requestId: identity.requestId,
3651
- queryHash,
3652
- promptNumber: promptCounter,
3653
- };
3654
- }
3655
- const runPack = async () => {
3656
- let packArgs = buildPackArgs({
3657
- query,
3658
- filesModified: sessionContext.filesModified,
3659
- injectLimit,
3660
- injectTokenBudget: requestPackBudget,
3661
- internalLedger: true,
3662
- });
3663
- const httpResult = await postViewerJson({
3664
- url: packUrl,
3665
- operation: "pack",
3666
- payload: buildPackHttpBody({
3667
- query,
3668
- filesModified: sessionContext.filesModified,
3669
- injectLimit,
3670
- injectTokenBudget: requestPackBudget,
3671
- projectName: normalizeProjectLabel(process.env.CODEMEM_PROJECT),
3672
- cwd,
3673
- dbPath: promptPackDbPath,
3674
- identityTarget: promptPackIdentityTarget,
3675
- attempt: metadata,
3676
- automaticContext: requesterHostSessionID
3677
- ? { source: "opencode", host_session_id: requesterHostSessionID }
3678
- : null,
3679
- }),
3680
- validate: isValidPackHttpPayload,
3681
- });
3682
- if (httpResult.ok) {
3683
- return {
3684
- packArgs,
3685
- result: {
3686
- exitCode: 0,
3687
- stdout: JSON.stringify(httpResult.body),
3688
- stderr: "",
3689
- transport: "viewer",
3690
- },
3691
- };
3692
- }
3693
-
3694
- const { cause, retryable } = httpResult.classification;
3695
- await logLine(
3696
- `inject.pack.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
3697
- );
3698
- if (!retryable) {
3699
- return {
3700
- packArgs,
3701
- result: { exitCode: 1, stdout: "", stderr: cause, transport: "viewer" },
3702
- };
3703
- }
3704
-
3705
- const result = await runCli(packArgs, { stdinText: JSON.stringify(metadata) });
3706
- if (rejectsInternalLedgerFlag(result)) {
3707
- // An older CLI cannot enforce requester-session continuity. Do not retry
3708
- // through its generic pack path, which may inject an unrelated summary.
3709
- await logLine("inject.pack.cli_legacy_unsafe_fallback_blocked");
3710
- }
3711
- return { packArgs, result: { ...result, transport: "cli" } };
3712
- };
3713
- let { packArgs, result } = await runPack();
3714
- let { packText, conflictPackText, metrics, itemCount, ledgerConflict } = parsePackOutput(result);
3715
- let artifactFingerprint = packText || itemCount === 0
3716
- ? promptPackArtifactFingerprint(result.stdout, packText)
3717
- : "";
3718
- let injectedIdentity = identity;
3719
- let repairFallbackUsed = false;
3720
- if (ledgerConflict) {
3721
- // A restarted plugin has no artifact cache to predict this conflict. The
3722
- // ledger marker is authoritative: never attribute delivery to the stale
3723
- // identity, and retry once with a fresh deterministic identity.
3724
- await log("warn", "codemem prompt-pack ledger conflict; retrying with fresh identity", {
3725
- sessionID,
3726
- surface,
3727
- });
3728
- const fallback = conflictPackText
3729
- ? {
3730
- packArgs,
3731
- result,
3732
- packText: conflictPackText,
3733
- metrics,
3734
- itemCount,
3735
- artifactFingerprint: promptPackArtifactFingerprint(result.stdout, conflictPackText),
3736
- ledgerConflict,
3737
- }
3738
- : null;
3739
- advancePromptPackRetryIdentity(attemptKey);
3740
- retryCount = promptPackRetryCounts.get(attemptKey) || 0;
3741
- identity = resolveIdentity();
3742
- metadata = attemptMetadata(identity, requesterHostSessionID);
3743
- ({ packArgs, result } = await runPack());
3744
- // Repair-conflict bytes are never preferred over the original preserved pack.
3745
- ({ packText, metrics, itemCount, ledgerConflict } = parsePackOutput(result));
3746
- artifactFingerprint = packText || itemCount === 0
3747
- ? promptPackArtifactFingerprint(result.stdout, packText)
3748
- : "";
3749
- const repairFailed = ledgerConflict
3750
- || !result
3751
- || result.exitCode !== 0
3752
- || (!packText && itemCount !== 0);
3753
- if (fallback && repairFailed) {
3754
- if (ledgerConflict) {
3755
- await log("warn", "codemem prompt-pack fresh identity also conflicted", {
3756
- sessionID,
3757
- surface,
3758
- });
3759
- } else {
3760
- const malformedSuccess = result?.exitCode === 0;
3761
- const exitCode = result?.exitCode ?? "unknown";
3762
- const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
3763
- const cmd = redactPackCommand(runner, runnerArgs, packArgs);
3764
- await logLine(
3765
- `inject.pack.identity_repair_failed reason=${malformedSuccess ? "malformed_success" : "command_failed"} exit=${exitCode} cmd=${cmd}` +
3766
- `${stderr ? ` stderr=${stderr}` : ""}`
3767
- );
3768
- await log("warn", "codemem prompt-pack identity repair failed", {
3769
- reason: malformedSuccess ? "malformed_success" : "command_failed",
3770
- exitCode,
3771
- });
3772
- void runPromptPackLedger({
3773
- action: "record",
3774
- ...metadata,
3775
- retrieval_status: "failed",
3776
- failure_code: malformedSuccess
3777
- ? "pack_identity_repair_failed"
3778
- : "pack_command_failed",
3779
- failure_stage: malformedSuccess ? "decode" : "transport",
3780
- });
3781
- }
3782
- advancePromptPackRetryIdentity(attemptKey);
3783
- retryCount = promptPackRetryCounts.get(attemptKey) || 0;
3784
- ({
3785
- packArgs,
3786
- result,
3787
- packText,
3788
- metrics,
3789
- itemCount,
3790
- artifactFingerprint,
3791
- ledgerConflict,
3792
- } = fallback);
3793
- // Neither persisted identity represents fallback delivery: the first
3794
- // conflicted and the fresh repair failed. Keep the bytes fail-open but
3795
- // leave delivery and replay attribution empty.
3796
- injectedIdentity = null;
3797
- repairFallbackUsed = true;
3798
- } else {
3799
- injectedIdentity = identity;
3800
- }
3801
- }
3802
- if (packText) {
3803
- const previous = successfulPromptPackArtifacts.get(attemptKey);
3804
- if (
3805
- previous?.retryCount === retryCount
3806
- && previous.fingerprint !== artifactFingerprint
3807
- ) {
3808
- // The in-memory fingerprint detected a changed artifact before handoff.
3809
- // Defensively rebuild once with a fresh identity; older CLIs do not
3810
- // expose the persisted conflict marker used by the restart path above.
3811
- // Keep the usable changed bytes as a fail-open fallback: diagnostics
3812
- // identity repair must never suppress context injection.
3813
- const fallback = {
3814
- packArgs,
3815
- result,
3816
- packText,
3817
- metrics,
3818
- itemCount,
3819
- artifactFingerprint,
3820
- ledgerConflict,
3821
- };
3822
- advancePromptPackRetryIdentity(attemptKey);
3823
- retryCount = promptPackRetryCounts.get(attemptKey) || 0;
3824
- identity = resolveIdentity();
3825
- metadata = attemptMetadata(identity, requesterHostSessionID);
3826
- ({ packArgs, result } = await runPack());
3827
- ({ packText, metrics, itemCount, ledgerConflict } = parsePackOutput(result));
3828
- artifactFingerprint = packText || itemCount === 0
3829
- ? promptPackArtifactFingerprint(result.stdout, packText)
3830
- : "";
3831
-
3832
- if (ledgerConflict) {
3833
- // The repair identity is also persisted with different artifacts.
3834
- // Preserve the marker for the fail-closed return below; never use the
3835
- // stale changed bytes as the transport-failure fallback.
3836
- injectedIdentity = identity;
3837
- } else if (result?.exitCode === 0 && itemCount === 0) {
3838
- // A fresh identity can legitimately resolve to no results if the
3839
- // underlying memory set changed between rebuilds. The CLI already
3840
- // recorded that terminal outcome; do not misclassify it as a decode
3841
- // failure or fall back to stale non-empty context.
3842
- injectedIdentity = identity;
3843
- } else if (!result || result.exitCode !== 0 || !packText) {
3844
- const malformedSuccess = result?.exitCode === 0;
3845
- const exitCode = result?.exitCode ?? "unknown";
3846
- const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
3847
- const cmd = redactPackCommand(runner, runnerArgs, packArgs);
3848
- await logLine(
3849
- `inject.pack.identity_repair_failed reason=${malformedSuccess ? "malformed_success" : "command_failed"} exit=${exitCode} cmd=${cmd}` +
3850
- `${stderr ? ` stderr=${stderr}` : ""}`
3851
- );
3852
- await log("warn", "codemem prompt-pack identity repair failed", {
3853
- reason: malformedSuccess ? "malformed_success" : "command_failed",
3854
- exitCode,
3855
- });
3856
- void runPromptPackLedger({
3857
- action: "record",
3858
- ...metadata,
3859
- retrieval_status: "failed",
3860
- failure_code: malformedSuccess
3861
- ? "pack_identity_repair_failed"
3862
- : "pack_command_failed",
3863
- failure_stage: malformedSuccess ? "decode" : "transport",
3864
- });
3865
- advancePromptPackRetryIdentity(attemptKey);
3866
-
3867
- ({
3868
- packArgs,
3869
- result,
3870
- packText,
3871
- metrics,
3872
- itemCount,
3873
- artifactFingerprint,
3874
- ledgerConflict,
3875
- } = fallback);
3876
- // The stale attempt does not represent these changed bytes, and the
3877
- // fresh repair attempt failed. Inject without delivery attribution
3878
- // rather than falsely marking either ledger attempt handed off.
3879
- injectedIdentity = null;
3880
- repairFallbackUsed = true;
3881
- } else {
3882
- injectedIdentity = identity;
3883
- }
3884
- }
3885
- }
3886
- if (!result || result.exitCode !== 0) {
3887
- const exitCode = result?.exitCode ?? "unknown";
3888
- const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
3889
- const stdout = redactLog(result?.stdout ? result.stdout.trim() : "");
3890
- const cmd = redactPackCommand(runner, runnerArgs, packArgs);
3891
- await logLine(
3892
- `inject.pack.error ${exitCode} cmd=${cmd}` +
3893
- `${stderr ? ` stderr=${stderr}` : ""}` +
3894
- `${stdout ? ` stdout=${stdout}` : ""}`
3895
- );
3896
- void runPromptPackLedger({
3897
- action: "record",
3898
- ...metadata,
3899
- retrieval_status: "failed",
3900
- failure_code: "pack_command_failed",
3901
- failure_stage: "transport",
3902
- });
3903
- advancePromptPackRetryIdentity(attemptKey);
3904
- return {
3905
- text: "",
3906
- attemptId: identity.attemptId,
3907
- requestId: identity.requestId,
3908
- queryHash,
3909
- promptNumber: promptCounter,
3910
- };
3911
- }
3912
- if (ledgerConflict && !repairFallbackUsed) {
3913
- await log("warn", "codemem prompt-pack fresh identity also conflicted", {
3914
- sessionID,
3915
- surface,
3916
- });
3917
- advancePromptPackRetryIdentity(attemptKey);
3918
- return {
3919
- text: "",
3920
- attemptId: identity.attemptId,
3921
- requestId: identity.requestId,
3922
- queryHash,
3923
- promptNumber: promptCounter,
3924
- };
3925
- }
3926
- if (!packText) {
3927
- if (itemCount === 0) {
3928
- if (surface === "message") {
3929
- void runPromptPackLedger({ action: "recall", attempt_id: identity.attemptId,
3930
- ...evaluationFields("", "", 0, 0, "valid", artifactFingerprint) }).catch(() => {});
3931
- }
3932
- advancePromptPackRetryIdentity(attemptKey);
3933
- }
3934
- if (debug) {
3935
- const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
3936
- describeInjectQuery(query);
3937
- await logLine(
3938
- `inject.pack.empty query_len=${query ? query.length : 0} query=${JSON.stringify(safeQuery)} first_prompt_len=${firstPromptLen} last_prompt_len=${lastPromptLen} project=${JSON.stringify(projectName)} files_modified=${filesModifiedCount} stdout=${JSON.stringify(redactLog((result.stdout || "").trim(), 240))}`
3939
- );
3940
- }
3941
- return {
3942
- text: "",
3943
- attemptId: identity.attemptId,
3944
- requestId: identity.requestId,
3945
- queryHash,
3946
- promptNumber: promptCounter,
3947
- };
3948
- }
3949
- // The pack JSON exposes the item count as `total_items`; `metrics.items`
3950
- // does not exist on that payload, so reading it would always log 0.
3951
- const packTokens = Number.isFinite(Number(metrics?.pack_tokens))
3952
- ? Number(metrics.pack_tokens)
3953
- : 0;
3954
- await logLine(
3955
- `inject.pack.ok source=opencode items=${itemCount ?? 0} pack_tokens=${packTokens} query_len=${query ? query.length : 0}`
3956
- );
3957
- if (!repairFallbackUsed) {
3958
- rememberSuccessfulPromptPackArtifact(
3959
- attemptKey,
3960
- retryCount,
3961
- artifactFingerprint || promptPackArtifactFingerprint(result.stdout, packText)
3962
- );
3963
- }
3964
- const filtered = context.surface === "message"
3965
- ? filterRetainedPack(result.stdout, packText, context.retainedItems)
3966
- : { text: packText, items: [], duplicates: 0 };
3967
- const text = filtered.text ? wrapInjectedContext(filtered.text) : "";
3968
- const evaluation = surface === "message"
3969
- ? evaluationFields(
3970
- packText,
3971
- filtered.text,
3972
- itemCount,
3973
- filtered.duplicates,
3974
- filtered.packMetadata,
3975
- artifactFingerprint,
3976
- )
3977
- : undefined;
3978
- const recall = filtered.items.length
3979
- ? { v: 1, digest: injectionDigest(text), items: filtered.items, workingContext: context.workingContext }
3980
- : undefined;
3981
- let skipReason = null;
3982
- if (!text && filtered.duplicates > 0) {
3983
- skipReason = context.continuationOnly ? "continuation_only" : "unchanged_memories";
3984
- }
3985
- if (metrics) {
3986
- return {
3987
- text,
3988
- recall,
3989
- duplicates: filtered.duplicates,
3990
- evaluation,
3991
- skipReason,
3992
- metrics,
3993
- attemptId: injectedIdentity?.attemptId || null,
3994
- requestId: injectedIdentity?.requestId || null,
3995
- queryHash,
3996
- promptNumber: promptCounter,
3997
- };
3998
- }
3999
- return {
4000
- text,
4001
- recall,
4002
- duplicates: filtered.duplicates,
4003
- evaluation,
4004
- skipReason,
4005
- attemptId: injectedIdentity?.attemptId || null,
4006
- requestId: injectedIdentity?.requestId || null,
4007
- queryHash,
4008
- promptNumber: promptCounter,
4009
- };
4010
- };
4011
-
4012
- const stopViewer = async () => {
4013
- if (!viewerEnabled || !viewerAutoStop || !viewerStarted) {
4014
- return;
4015
- }
4016
- viewerStarted = false;
4017
- stopHealthCheck();
4018
- await logLine("viewer stop requested");
4019
- await runCli(buildViewerCliArgs("stop"));
4020
- };
4021
-
4022
- const viewerHealthMonitor = createViewerHealthMonitor({
4023
- viewerHealthUrl,
4024
- legacyStatusUrl: rawEventsStatusUrl,
4025
- isActive: () => viewerStarted && viewerEnabled,
4026
- restartViewer: () => runCli(buildViewerCliArgs("restart")),
4027
- logLine,
4028
- });
4029
- const startHealthCheck = viewerHealthMonitor.start;
4030
- const stopHealthCheck = viewerHealthMonitor.stop;
4031
-
4032
- // Get version info (commit hash) for debugging
4033
- let version = "unknown";
4034
- try {
4035
- version = execSync("git rev-parse --short HEAD", {
4036
- cwd: runnerFrom,
4037
- timeout: 500,
4038
- encoding: "utf-8",
4039
- // Suppress "fatal: not a git repository" when the working directory is
4040
- // not a git repo. The catch below leaves version as "unknown".
4041
- stdio: ["ignore", "pipe", "ignore"],
4042
- }).trim();
4043
- } catch (err) {
4044
- // Ignore - version will remain 'unknown'
4045
- }
4046
-
4047
- await log("info", "codemem plugin initialized", { cwd, version });
4048
- await logLine(`plugin initialized cwd=${cwd} version=${version}`);
4049
- void startViewer();
4050
- const updateCheckTimer = setTimeout(() => {
4051
- void (async () => {
4052
- try {
4053
- await verifyCliCompatibility();
4054
- } catch (err) {
4055
- await logLine(
4056
- `compat.version_check_error message=${String(err?.message || err || "unknown")}`
4057
- );
4058
- }
4059
- try {
4060
- await checkForReleaseUpdate();
4061
- } catch (err) {
4062
- await logLine(
4063
- `release.update_check_error message=${String(err?.message || err || "unknown")}`
4064
- );
4065
- }
4066
- })();
4067
- }, COMPAT_CHECK_DELAY_MS);
4068
- if (updateCheckTimer.unref) updateCheckTimer.unref();
4069
-
4070
- const truncate = (value) => {
4071
- if (value === undefined || value === null) {
4072
- return null;
4073
- }
4074
- const text = String(value);
4075
- if (Number.isNaN(maxChars) || maxChars <= 0) {
4076
- return "";
4077
- }
4078
- if (text.length <= maxChars) {
4079
- return text;
4080
- }
4081
- return `${text.slice(0, maxChars)}\n[codemem] event truncated\n`;
4082
- };
4083
-
4084
- const safeStringify = (value) => {
4085
- if (value === undefined || value === null) {
4086
- return null;
4087
- }
4088
- if (typeof value === "string") {
4089
- return value;
4090
- }
4091
- try {
4092
- return JSON.stringify(value);
4093
- } catch (err) {
4094
- return String(value);
4095
- }
4096
- };
4097
-
4098
- const recordEvent = (event) => {
4099
- events.push(event);
4100
- trimEventQueue({
4101
- events,
4102
- maxEvents,
4103
- hardMaxEvents: Math.max(maxEvents, rawEventsHardMax),
4104
- onUnsentPressure: (queuedCount, cap) => {
4105
- void logLine(`queue.pressure unsent_preserved queued=${queuedCount} max_events=${cap}`);
4106
- },
4107
- onForcedDrop: (dropped, queuedCount, hardCap) => {
4108
- const durableSpoolCoverage = dropped?._raw_spooled === true;
4109
- void logLine(
4110
- `queue.drop hard_cap durable_spool_coverage=${durableSpoolCoverage} queued=${queuedCount} hard_max=${hardCap}`
4111
- );
4112
- void log("error", "codemem raw event dropped at in-memory hard cap", {
4113
- category: "queue_capacity",
4114
- delivery: durableSpoolCoverage ? "spool" : "memory",
4115
- durable_spool_coverage: durableSpoolCoverage,
4116
- queued_count: queuedCount,
4117
- hard_max_events: hardCap,
4118
- });
4119
- },
4120
- });
4121
- };
4122
-
4123
- const captureEvent = (sessionID, event) => {
4124
- const normalizedSessionID =
4125
- typeof sessionID === "string" && sessionID.trim() ? sessionID.trim() : null;
4126
- if (normalizedSessionID) {
4127
- activeSessionID = normalizedSessionID;
4128
- }
4129
- const effectiveSessionID = normalizedSessionID || activeSessionID;
4130
- const resolvedSessionID =
4131
- effectiveSessionID || `missing:${Date.now()}:${String(nextEventId()).slice(0, 8)}`;
4132
- if (!effectiveSessionID) {
4133
- activeSessionID = resolvedSessionID;
4134
- void logLine(`capture.fallback_session_id ${resolvedSessionID}`);
4135
- }
4136
- const adapterAnnotatedEvent = attachAdapterEvent({
4137
- sessionID: resolvedSessionID,
4138
- event,
4139
- });
4140
- const rawEventId =
4141
- adapterAnnotatedEvent?._adapter?.event_id ||
4142
- (adapterAnnotatedEvent && adapterAnnotatedEvent._raw_event_id) ||
4143
- nextEventId();
4144
- const queuedEvent = {
4145
- ...adapterAnnotatedEvent,
4146
- _raw_event_id: rawEventId,
4147
- _raw_session_id: resolvedSessionID,
4148
- };
4149
- recordEvent(queuedEvent);
4150
- void emitRawEvent({
4151
- sessionID: resolvedSessionID,
4152
- type: queuedEvent?.type || "unknown",
4153
- payload: queuedEvent,
4154
- });
4155
- };
4156
-
4157
- const flushEvents = async () => {
4158
- if (!events.length) {
4159
- await drainRawEventSpool();
4160
- await logLine("flush.skip empty");
4161
- return;
4162
- }
4163
-
4164
- const batch = events.splice(0, events.length);
4165
- if (!batch.length) {
4166
- await logLine("flush.skip empty");
4167
- return;
4168
- }
4169
-
4170
- const failed = [];
4171
- for (const queuedEvent of batch) {
4172
- if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_enqueued) {
4173
- continue;
4174
- }
4175
- const queuedSessionID =
4176
- queuedEvent?._raw_session_id ||
4177
- queuedEvent?.properties?.sessionID ||
4178
- null;
4179
- const ok = await emitRawEvent({
4180
- sessionID: queuedSessionID,
4181
- type: queuedEvent?.type || "unknown",
4182
- payload: queuedEvent,
4183
- });
4184
- if (!ok) {
4185
- failed.push(queuedEvent);
4186
- }
4187
- }
4188
- if (failed.length) {
4189
- events.unshift(...failed);
4190
- await logLine(`flush.retry_deferred count=${failed.length}`);
4191
- await drainRawEventSpool();
4192
- return;
4193
- }
4194
-
4195
- // Calculate session duration
4196
- const durationMs = sessionContext.startTime
4197
- ? Date.now() - sessionContext.startTime
4198
- : 0;
4199
- await logLine(
4200
- `flush.stream_only finalize count=${batch.length} tools=${sessionContext.toolCount} prompts=${sessionContext.promptCount} duration=${Math.round(durationMs / 1000)}s`
4201
- );
4202
- await logLine(`flush.ok count=${batch.length} dropped=0`);
4203
- sessionStartedAt = null;
4204
- resetSessionContext();
4205
- await drainRawEventSpool();
4206
- };
4207
-
4208
- void drainRawEventSpool();
4209
-
4210
- return {
4211
- dispose: () => {
4212
- clearTimeout(updateCheckTimer);
4213
- stopHealthCheck();
4214
- releasePluginRegistration();
4215
- },
4216
- "experimental.session.compacting": async (input) => {
4217
- const sessionID = extractHookSessionID(input) || activeSessionID;
4218
- markCompactionInjectionSkip(compactionInjectionSkips, sessionID);
4219
- if (debug) {
4220
- await logLine(
4221
- `inject.compaction_skip_marked sessionID=${sessionID || "unknown"}`
4222
- );
4223
- }
4224
- },
4225
- "experimental.chat.messages.transform": async (input, output) => {
4226
- if (injectSurface === "system") {
4227
- return;
4228
- }
4229
- const hookMessages = Array.isArray(output?.messages)
4230
- ? scopeMessagesToSession(output.messages, input?.sessionID || null)
4231
- : [];
4232
- const latestUser = findLatestUserMessage(hookMessages);
4233
- const sessionID = input?.sessionID
4234
- || (latestUser ? resolveEntrySessionID(latestUser.entry) : null);
4235
- const latestPromptText = latestUser ? extractMessageText(latestUser.entry) : "";
4236
- if (debug) {
4237
- const query = resolveInjectQuery({ lastPromptText: latestPromptText });
4238
- const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
4239
- describeInjectQuery(query, { lastPromptText: latestPromptText });
4240
- await logLine(
4241
- `inject.messages_transform sessionID=${sessionID || "unknown"} query_len=${
4242
- query ? query.length : 0
4243
- } inject_enabled=${injectEnabled} tui_toast=${Boolean(client.tui?.showToast)} query=${JSON.stringify(safeQuery)} first_prompt_len=${firstPromptLen} last_prompt_len=${lastPromptLen} project=${JSON.stringify(projectName)} files_modified=${filesModifiedCount}`
4244
- );
4245
- }
4246
-
4247
- let applied = false;
4248
- try {
4249
- applied = await applyInjectedContextToMessages({
4250
- injectEnabled,
4251
- input,
4252
- output,
4253
- injectionToastShown,
4254
- showToast: client.tui?.showToast
4255
- ? async (message) => {
4256
- await client.tui.showToast({
4257
- body: {
4258
- message,
4259
- variant: "info",
4260
- },
4261
- });
4262
- }
4263
- : null,
4264
- resolveInjectQuery,
4265
- buildInjectedContext,
4266
- messageInjectionCache,
4267
- injectTokenBudget,
4268
- retainedTokenBudget,
4269
- workingSet: [...sessionContext.filesModified, ...sessionContext.filesRead],
4270
- recordMeasurement: (measurement) => logLine(`inject.recall ${JSON.stringify(measurement)}`),
4271
- compactionInjectionSkips,
4272
- confirmDelivery: confirmPromptPackDelivery,
4273
- recordCacheReuse: recordCachedPromptPack,
4274
- recordSkipped: (reason, sessionID) => recordSkippedPromptPack(
4275
- reason, sessionID, "message", latestUser ? resolveEntryMessageId(latestUser.entry) : null,
4276
- ),
4277
- });
4278
- } catch (err) {
4279
- await logLine(
4280
- `inject.messages_transform.error sessionID=${sessionID || "unknown"} message=${JSON.stringify(err instanceof Error ? err.message : String(err))}`
4281
- );
4282
- }
4283
- if (debug) {
4284
- const partsCount = Array.isArray(output?.messages)
4285
- ? output.messages.reduce(
4286
- (count, entry) => count + (Array.isArray(entry?.parts) ? entry.parts.length : 0),
4287
- 0
4288
- )
4289
- : 0;
4290
- await logLine(
4291
- `inject.messages_transform.result sessionID=${sessionID || "unknown"} applied=${Boolean(applied)} messages=${Array.isArray(output?.messages) ? output.messages.length : 0} parts=${partsCount}`
4292
- );
4293
- }
4294
- },
4295
- "experimental.chat.system.transform": async (input, output) => {
4296
- if (injectSurface !== "system") {
4297
- return;
4298
- }
4299
- const hookSessionID = input?.sessionID || activeSessionID;
4300
- if (consumeCompactionInjectionSkip(compactionInjectionSkips, hookSessionID)) {
4301
- recordSkippedPromptPack("compaction_skipped", hookSessionID, "system");
4302
- if (debug) {
4303
- await logLine(
4304
- `inject.transform.skip_compaction sessionID=${hookSessionID || "unknown"}`
4305
- );
4306
- }
4307
- return;
4308
- }
4309
- const query = resolveInjectQuery();
4310
- if (debug) {
4311
- const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
4312
- describeInjectQuery(query);
4313
- await logLine(
4314
- `inject.transform sessionID=${input.sessionID} query_len=${
4315
- query ? query.length : 0
4316
- } inject_enabled=${injectEnabled} tui_toast=${Boolean(client.tui?.showToast)} query=${JSON.stringify(safeQuery)} first_prompt_len=${firstPromptLen} last_prompt_len=${lastPromptLen} project=${JSON.stringify(projectName)} files_modified=${filesModifiedCount}`
4317
- );
4318
- }
4319
- // Without the old per-session cache, every transform call rebuilds the
4320
- // pack. Swallow rejections here so a single failed build (viewer failure,
4321
- // CLI fallback crash, sqlite lock, or network blip)
4322
- // can't take down the chat path.
4323
- let applied = false;
4324
- try {
4325
- applied = await applyInjectedContextToOutput({
4326
- injectEnabled,
4327
- input,
4328
- output,
4329
- injectionToastShown,
4330
- showToast: client.tui?.showToast
4331
- ? async (message) => {
4332
- await client.tui.showToast({
4333
- body: {
4334
- message,
4335
- variant: "info",
4336
- },
4337
- });
4338
- }
4339
- : null,
4340
- resolveInjectQuery,
4341
- buildInjectedContext,
4342
- confirmDelivery: confirmPromptPackDelivery,
4343
- recordSkipped: recordSkippedPromptPack,
4344
- });
4345
- } catch (err) {
4346
- await logLine(
4347
- `inject.transform.error sessionID=${input.sessionID} message=${JSON.stringify(err instanceof Error ? err.message : String(err))}`
4348
- );
4349
- }
4350
- if (debug) {
4351
- await logLine(
4352
- `inject.transform.result sessionID=${input.sessionID} applied=${Boolean(applied)} system_entries=${Array.isArray(output.system) ? output.system.length : 0}`
4353
- );
4354
- }
4355
- },
4356
- event: async ({ event }) => {
4357
- const eventType = event?.type || "unknown";
4358
- const sessionID = extractSessionID(event);
4359
-
4360
- // Always log session-related events for debugging /new
4361
- if (eventType.startsWith("session.")) {
4362
- await logLine(`SESSION EVENT: ${eventType}`);
4363
- }
4364
-
4365
- if (debugExtraction) {
4366
- await logLine(`event ${eventType}`);
4367
- }
4368
-
4369
- // Debug: log event structure for message events (only when debug enabled)
4370
- if (
4371
- debugExtraction &&
4372
- [
4373
- "message.updated",
4374
- "message.created",
4375
- "message.appended",
4376
- "message.part.updated",
4377
- ].includes(eventType)
4378
- ) {
4379
- // Log full event structure for debugging (only first few times per event type)
4380
- if (!global.eventLogCount) global.eventLogCount = {};
4381
- if (!global.eventLogCount[eventType])
4382
- global.eventLogCount[eventType] = 0;
4383
- if (global.eventLogCount[eventType] < 2) {
4384
- global.eventLogCount[eventType]++;
4385
- await logLine(
4386
- `FULL EVENT (${eventType}): ${JSON.stringify(
4387
- event,
4388
- null,
4389
- 2
4390
- ).substring(0, 3000)}`
4391
- );
4392
- }
4393
-
4394
- await logLine(
4395
- `event payload keys: ${Object.keys(event || {}).join(", ")}`
4396
- );
4397
- if (event?.properties) {
4398
- await logLine(
4399
- `event properties keys: ${Object.keys(event.properties).join(", ")}`
4400
- );
4401
- if (event.properties.role) {
4402
- await logLine(`event role: ${event.properties.role}`);
4403
- }
4404
- if (event.properties.message) {
4405
- await logLine(`event has properties.message`);
4406
- }
4407
- if (event.properties.info) {
4408
- const infoKeys = Object.keys(event.properties.info);
4409
- await logLine(`event properties.info keys: ${infoKeys.join(", ")}`);
4410
- if (event.properties.info.role) {
4411
- await logLine(`event info.role: ${event.properties.info.role}`);
4412
- }
4413
- }
4414
- }
4415
- }
4416
-
4417
- if (
4418
- [
4419
- "message.updated",
4420
- "message.created",
4421
- "message.appended",
4422
- "message.part.updated",
4423
- ].includes(eventType)
4424
- ) {
4425
- const promptText = extractPromptText(event);
4426
- if (promptText) {
4427
- // Update activity tracking
4428
- updateActivity();
4429
-
4430
- // Track session context
4431
- if (!sessionContext.firstPrompt) {
4432
- sessionContext.firstPrompt = promptText;
4433
- sessionContext.startTime = Date.now();
4434
- }
4435
- sessionContext.promptCount++;
4436
-
4437
- // Check for /new command and flush before session reset
4438
- if (
4439
- promptText.trim() === "/new" ||
4440
- promptText.trim().startsWith("/new ")
4441
- ) {
4442
- await logLine("detected /new command, flushing events");
4443
- await flushEvents();
4444
- }
4445
-
4446
- if (promptText !== lastPromptText) {
4447
- promptCounter += 1;
4448
- // promptCount incremented when capturing user_prompt
4449
-
4450
- lastPromptText = promptText;
4451
- captureEvent(sessionID, {
4452
- type: "user_prompt",
4453
- prompt_number: promptCounter,
4454
- prompt_text: promptText,
4455
- timestamp: new Date().toISOString(),
4456
- });
4457
- await logLine(
4458
- `user_prompt captured #${promptCounter}: ${promptText.substring(
4459
- 0,
4460
- 50
4461
- )}`
4462
- );
4463
-
4464
- // Check if we should force flush due to threshold
4465
- if (shouldForceFlush()) {
4466
- await logLine(`force flush triggered: tools=${sessionContext.toolCount}, prompts=${sessionContext.promptCount}, duration=${Math.round((Date.now() - (sessionContext.startTime || Date.now())) / 1000)}s`);
4467
- await flushEvents();
4468
- }
4469
- }
4470
- }
4471
-
4472
- const assistantText = extractAssistantText(event);
4473
- if (assistantText && assistantText !== lastAssistantText) {
4474
- updateActivity();
4475
- lastAssistantText = assistantText;
4476
- captureEvent(sessionID, {
4477
- type: "assistant_message",
4478
- assistant_text: assistantText,
4479
- timestamp: new Date().toISOString(),
4480
- });
4481
- await logLine(
4482
- `assistant_message captured: ${assistantText.substring(0, 50)}`
4483
- );
4484
- }
4485
-
4486
- const assistantUsage = extractAssistantUsage(event);
4487
- if (assistantUsage) {
4488
- updateActivity();
4489
- captureEvent(sessionID, {
4490
- type: "assistant_usage",
4491
- message_id: assistantUsage.id,
4492
- usage: assistantUsage.usage,
4493
- timestamp: new Date().toISOString(),
4494
- });
4495
- await logLine(
4496
- `assistant_usage captured id=${assistantUsage.id.slice(-8)}`
4497
- );
4498
- }
4499
- }
4500
-
4501
- // NEW ACCUMULATION STRATEGY
4502
- // Only flush on:
4503
- // - session.error (immediate error boundary)
4504
- // - session.idle AFTER delay (scheduled via timeout)
4505
- // - /new command (handled above)
4506
- // - session.created (session boundary)
4507
- //
4508
- // REMOVED: session.compacted, session.compacting (too frequent)
4509
- if (eventType === "session.error") {
4510
- await logLine("session.error detected, flushing immediately");
4511
- await flushEvents();
4512
- }
4513
-
4514
- if (eventType === "session.idle") {
4515
- await logLine(
4516
- `session.idle detected, flushing immediately (tools=${sessionContext.toolCount}, prompts=${sessionContext.promptCount})`
4517
- );
4518
- await flushEvents();
4519
- }
4520
-
4521
- if (eventType === "session.created") {
4522
- if (events.length) {
4523
- await flushEvents();
4524
- }
4525
- activeSessionID = sessionID || null;
4526
- sessionStartedAt = new Date().toISOString();
4527
- promptCounter = 0;
4528
- fallbackEvaluationSessionId = nextEventId();
4529
- skippedAttemptCounter = 0;
4530
- disabledInjectionRecorded.delete("message:unknown");
4531
- disabledInjectionRecorded.delete("system:unknown");
4532
- lastPromptText = null;
4533
- lastAssistantText = null;
4534
- resetSessionContext();
4535
- startViewer();
4536
- }
4537
- if (eventType === "session.deleted") {
4538
- activeSessionID = null;
4539
- if (sessionID) {
4540
- for (const key of lastToastAtBySession.keys()) {
4541
- if (key.startsWith(`${sessionID}:`)) {
4542
- lastToastAtBySession.delete(key);
4543
- }
4544
- }
4545
- injectionToastShown.delete(sessionID);
4546
- messageInjectionCache.delete(sessionID);
4547
- compactionInjectionSkips.delete(sessionID);
4548
- disabledInjectionRecorded.delete(`message:${sessionID}`);
4549
- disabledInjectionRecorded.delete(`system:${sessionID}`);
4550
- latestPolicySkips.delete(`message:${sessionID}`);
4551
- latestPolicySkips.delete(`system:${sessionID}`);
4552
- }
4553
- await stopViewer();
4554
- }
4555
- },
4556
- "tool.execute.after": async (input, output) => {
4557
- const args = output?.args ?? input?.args ?? {};
4558
- const result = output?.result ?? output?.output ?? output?.data ?? null;
4559
- const error = output?.error ?? null;
4560
- const toolName = input?.tool || output?.tool || "unknown";
4561
-
4562
- // Update activity and session context
4563
- updateActivity();
4564
- sessionContext.toolCount++;
4565
-
4566
- // Track files from tool events
4567
- const filePath = args.filePath || args.path;
4568
- if (filePath) {
4569
- const lowerTool = toolName.toLowerCase();
4570
- if (lowerTool === "edit" || lowerTool === "write") {
4571
- addWorkingSetPath(sessionContext.filesModified, filePath, cwd);
4572
- } else if (lowerTool === "read") {
4573
- addWorkingSetPath(sessionContext.filesRead, filePath, cwd);
4574
- }
4575
- }
4576
- if (toolName.toLowerCase() === "apply_patch") {
4577
- const patchPaths = extractApplyPatchPaths(args.patchText);
4578
- for (const path of patchPaths) {
4579
- addWorkingSetPath(sessionContext.filesModified, path, cwd);
4580
- }
4581
- }
4582
-
4583
- captureEvent(input?.sessionID || null, {
4584
- type: "tool.execute.after",
4585
- tool: toolName,
4586
- args,
4587
- result: truncate(safeStringify(result)),
4588
- error: truncate(safeStringify(error)),
4589
- timestamp: new Date().toISOString(),
4590
- });
4591
- await logLine(`tool.execute.after ${toolName} queued=${events.length} tools=${sessionContext.toolCount}`);
4592
-
4593
- // Check if we should force flush due to threshold
4594
- if (shouldForceFlush()) {
4595
- await logLine(`force flush triggered: tools=${sessionContext.toolCount}, prompts=${sessionContext.promptCount}, duration=${Math.round((Date.now() - (sessionContext.startTime || Date.now())) / 1000)}s`);
4596
- await flushEvents();
4597
- }
4598
- },
4599
- tool: {
4600
- "mem-status": tool({
4601
- description: "Show codemem stats and recent entries",
4602
- args: {},
4603
- async execute() {
4604
- const stats = await runCli(["stats"]);
4605
- const recent = await runCli(["recent", "--limit", "5"]);
4606
- const lines = [
4607
- `viewer: http://${viewerUrlHost}:${viewerPort}`,
4608
- `log: ${logPath || "disabled"}`,
4609
- ];
4610
- if (stats.exitCode === 0 && stats.stdout.trim()) {
4611
- lines.push("", "stats:", stats.stdout.trim());
4612
- }
4613
- if (recent.exitCode === 0 && recent.stdout.trim()) {
4614
- lines.push("", "recent:", recent.stdout.trim());
4615
- }
4616
- return lines.join("\n");
4617
- },
4618
- }),
4619
-
4620
- "mem-recent": tool({
4621
- description: "Show recent codemem entries",
4622
- args: {
4623
- limit: tool.schema.number().optional(),
4624
- },
4625
- async execute({ limit }) {
4626
- // Number.isFinite accepts floats and negatives; coerce to a positive
4627
- // integer (default 5) before forwarding to the CLI.
4628
- const safeLimit = String(parsePositiveInt(limit, 5));
4629
- const recent = await runCli(["recent", "--limit", safeLimit]);
4630
- if (recent.exitCode === 0) {
4631
- return recent.stdout.trim() || "No recent memories.";
4632
- }
4633
- return `Failed to fetch recent: ${recent.stderr || recent.exitCode}`;
4634
- },
4635
- }),
4636
-
4637
- "mem-stats": tool({
4638
- description: "Show codemem stats",
4639
- args: {},
4640
- async execute() {
4641
- const stats = await runCli(["stats"]);
4642
- if (stats.exitCode === 0) {
4643
- return stats.stdout.trim() || "No stats yet.";
4644
- }
4645
- return `Failed to fetch stats: ${stats.stderr || stats.exitCode}`;
4646
- },
4647
- }),
157
+ event: ({ event }) => dispatchV1Event(delegation, runtime, event),
158
+ "tool.execute.after": (input, output) => {
159
+ const translated = translateV1ToolResult(input, output);
160
+ return runtime.handleToolResult(translated.input, translated.output);
4648
161
  },
162
+ tool: memoryTools,
4649
163
  };
4650
164
  };
4651
165
 
166
+ export const CodememPlugin = createOpenCodeV1Adapter;
4652
167
  export default CodememPlugin;
4653
168
 
4654
169
  /**
@@ -4656,63 +171,12 @@ export default CodememPlugin;
4656
171
  * Keep this reference-identical: OpenCode deduplicates plugin exports by identity.
4657
172
  */
4658
173
  export const OpencodeMemPlugin = CodememPlugin;
4659
- export const __testUtils = {
4660
- PINNED_BACKEND_VERSION,
4661
- fetchRawEventsStatus,
4662
- inferProjectFromCwd,
4663
- normalizeProjectLabel,
4664
- resolveProjectName,
4665
- buildInjectQuery,
4666
- buildPackArgs,
4667
- deterministicUuid,
4668
- promptPackIdentity,
4669
- hashPromptPackQuery,
4670
- redactPackCommand,
4671
- rejectsInternalLedgerFlag,
4672
- classifyFallbackCommandResult,
4673
- classifyRawEventViewerFailure,
4674
- detectRunner,
4675
- PROMPT_TRANSPORT_PROTOCOL_RANGE,
4676
- normalizePromptTransportProtocolRange,
4677
- arePromptTransportProtocolRangesCompatible,
4678
- classifyPromptTransportFailure,
4679
- classifyViewerHttpFailure,
4680
- isValidPackHttpPayload,
4681
- isValidLedgerHttpPayload,
4682
- buildPackHttpBody,
4683
- parsePackText,
4684
- parsePackMetrics,
4685
- buildViewerIdentityTarget,
4686
- resolveInjectSurface,
4687
- applyInjectedContextToOutput,
4688
- applyInjectedContextToMessages,
4689
- extractMessageText,
4690
- isCodememContextPart,
4691
- buildRunnerArgs,
4692
- appendWorkingSetFileArgs,
4693
- extractApplyPatchPaths,
4694
- normalizeWorkingSetPath,
4695
- addWorkingSetPath,
4696
- mapOpencodeEventTypeToAdapterType,
4697
- buildOpencodeAdapterPayload,
4698
- buildOpencodeAdapterEvent,
4699
- attachAdapterEvent,
4700
- selectRawEventId,
4701
- buildRawEventEnvelope,
4702
- trimEventQueue,
4703
- parsePositiveInt,
4704
- resolveInjectTokenBudget,
4705
- reserveContextPrefixBudget,
4706
- wrapInjectedContext,
4707
- estimateTokens,
4708
- countRetainedInjectionTokens,
4709
- retainedMemoryFingerprints,
4710
- retainedMetadataGaps,
4711
- filterRetainedPack,
4712
- isContinuationOnly,
4713
- resolveRetainedTokenBudget,
4714
- workingContextDigest,
4715
- DEFAULT_INJECT_TOKEN_BUDGET,
4716
- CODEMEM_CONTEXT_PREFIX,
4717
- createViewerHealthMonitor,
174
+ export { __testUtils, buildInjectionToastMessage };
175
+ export const __v1AdapterTestUtils = {
176
+ createV1Tool,
177
+ dispatchV1Event,
178
+ extractV1HookSessionID,
179
+ extractV1SessionID,
180
+ translateV1Event,
181
+ translateV1ToolResult,
4718
182
  };