@codemem/opencode-plugin 0.44.0-alpha.1 → 0.44.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/lib/compat.js +11 -6
- package/.opencode/lib/raw-event-spool.js +197 -0
- package/.opencode/plugins/codemem.js +410 -213
- package/package.json +1 -1
package/.opencode/lib/compat.js
CHANGED
|
@@ -31,7 +31,7 @@ export const resolveUpgradeGuidance = ({ runner, runnerFrom }) => {
|
|
|
31
31
|
return {
|
|
32
32
|
mode: "global",
|
|
33
33
|
action:
|
|
34
|
-
"Run `npm install -g codemem
|
|
34
|
+
"Run `npm install -g codemem` to update the CLI and its optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
|
|
35
35
|
note: "detected global codemem runner mode",
|
|
36
36
|
};
|
|
37
37
|
}
|
|
@@ -40,7 +40,7 @@ export const resolveUpgradeGuidance = ({ runner, runnerFrom }) => {
|
|
|
40
40
|
return {
|
|
41
41
|
mode: "npx",
|
|
42
42
|
action:
|
|
43
|
-
"Run `npm install -g codemem
|
|
43
|
+
"Run `npm install -g codemem` to update the CLI and its optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
|
|
44
44
|
note: "detected npx runner mode",
|
|
45
45
|
};
|
|
46
46
|
}
|
|
@@ -117,6 +117,8 @@ const isPinnedGitSource = (runnerFrom) => {
|
|
|
117
117
|
};
|
|
118
118
|
|
|
119
119
|
const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
120
|
+
const UPDATE_TARGET_VERSION =
|
|
121
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc)(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
120
122
|
|
|
121
123
|
const createNpmUpdatePlan = (targetVersion) => {
|
|
122
124
|
const command = [
|
|
@@ -136,7 +138,7 @@ export const resolveAutoUpdatePlan = ({
|
|
|
136
138
|
runner,
|
|
137
139
|
runnerFrom,
|
|
138
140
|
runnerFromExplicit = false,
|
|
139
|
-
targetVersion
|
|
141
|
+
targetVersion,
|
|
140
142
|
platform = process.platform,
|
|
141
143
|
}) => {
|
|
142
144
|
const normalizedRunner = String(runner || "").trim();
|
|
@@ -145,9 +147,6 @@ export const resolveAutoUpdatePlan = ({
|
|
|
145
147
|
if (platform === "win32") {
|
|
146
148
|
return { allowed: false, reason: "unsupported-platform", command: null, commandText: null };
|
|
147
149
|
}
|
|
148
|
-
if (normalizedTargetVersion !== "latest" && !/^\d+\.\d+\.\d+$/.test(normalizedTargetVersion)) {
|
|
149
|
-
return { allowed: false, reason: "invalid-version", command: null, commandText: null };
|
|
150
|
-
}
|
|
151
150
|
if (isPinnedGitSource(source) || /^codemem@(?!latest$|next$|\*$)[^\s]+$/i.test(source)) {
|
|
152
151
|
return {
|
|
153
152
|
allowed: false,
|
|
@@ -175,6 +174,9 @@ export const resolveAutoUpdatePlan = ({
|
|
|
175
174
|
commandText: null,
|
|
176
175
|
};
|
|
177
176
|
}
|
|
177
|
+
if (!UPDATE_TARGET_VERSION.test(normalizedTargetVersion)) {
|
|
178
|
+
return { allowed: false, reason: "invalid-version", command: null, commandText: null };
|
|
179
|
+
}
|
|
178
180
|
return createNpmUpdatePlan(normalizedTargetVersion);
|
|
179
181
|
}
|
|
180
182
|
|
|
@@ -197,6 +199,9 @@ export const resolveAutoUpdatePlan = ({
|
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
if (normalizedRunner === "codemem") {
|
|
202
|
+
if (!UPDATE_TARGET_VERSION.test(normalizedTargetVersion)) {
|
|
203
|
+
return { allowed: false, reason: "invalid-version", command: null, commandText: null };
|
|
204
|
+
}
|
|
200
205
|
return createNpmUpdatePlan(normalizedTargetVersion);
|
|
201
206
|
}
|
|
202
207
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
link,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_DRAIN_LIMIT = 20;
|
|
16
|
+
const DEFAULT_MAX_ENTRIES = 2000;
|
|
17
|
+
const RAW_EVENT_SPOOL_FULL_CODE = "raw_event_spool_full";
|
|
18
|
+
const SPOOL_DIRECTORY_NAME = "opencode-raw-event-spool";
|
|
19
|
+
const spoolWritesInFlight = new Map();
|
|
20
|
+
|
|
21
|
+
const resolveSpoolDirectory = (homeDir = homedir()) =>
|
|
22
|
+
join(homeDir, ".codemem", SPOOL_DIRECTORY_NAME);
|
|
23
|
+
|
|
24
|
+
const spoolFilename = (eventId) => {
|
|
25
|
+
const digest = createHash("sha256").update(String(eventId)).digest("hex");
|
|
26
|
+
return `${digest}.json`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const requireEventId = (envelope) => {
|
|
30
|
+
const eventId = typeof envelope?.event_id === "string"
|
|
31
|
+
? envelope.event_id.trim()
|
|
32
|
+
: "";
|
|
33
|
+
if (!eventId) {
|
|
34
|
+
throw new Error("raw event spool requires event_id");
|
|
35
|
+
}
|
|
36
|
+
return eventId;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const ensurePrivateDirectory = async (directory) => {
|
|
40
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
41
|
+
await chmod(directory, 0o700);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const writeRawEventSpoolEntryUnlocked = async ({
|
|
45
|
+
envelope,
|
|
46
|
+
serialized,
|
|
47
|
+
homeDir = homedir(),
|
|
48
|
+
maxEntries = DEFAULT_MAX_ENTRIES,
|
|
49
|
+
}) => {
|
|
50
|
+
const eventId = requireEventId(envelope);
|
|
51
|
+
const directory = resolveSpoolDirectory(homeDir);
|
|
52
|
+
const bytes = typeof serialized === "string" ? serialized : JSON.stringify(envelope);
|
|
53
|
+
const normalizedMaxEntries = Number.isFinite(maxEntries) && maxEntries > 0
|
|
54
|
+
? Math.trunc(maxEntries)
|
|
55
|
+
: DEFAULT_MAX_ENTRIES;
|
|
56
|
+
const destination = join(directory, spoolFilename(eventId));
|
|
57
|
+
const temporary = join(
|
|
58
|
+
directory,
|
|
59
|
+
`.${spoolFilename(eventId)}.${process.pid}.${randomUUID()}.tmp`,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await ensurePrivateDirectory(directory);
|
|
63
|
+
try {
|
|
64
|
+
const existing = await readFile(destination, "utf8");
|
|
65
|
+
if (existing !== bytes) {
|
|
66
|
+
throw new Error("raw event spool entry conflicts with existing event_id");
|
|
67
|
+
}
|
|
68
|
+
await chmod(destination, 0o600);
|
|
69
|
+
return { eventId, serialized: existing };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.code !== "ENOENT") {
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const directoryEntries = await readdir(directory, { withFileTypes: true });
|
|
77
|
+
const entryCount = directoryEntries.filter(
|
|
78
|
+
(entry) => entry.isFile() && entry.name.endsWith(".json"),
|
|
79
|
+
).length;
|
|
80
|
+
if (entryCount >= normalizedMaxEntries) {
|
|
81
|
+
const error = new Error("raw event spool is full");
|
|
82
|
+
error.code = RAW_EVENT_SPOOL_FULL_CODE;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await writeFile(temporary, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
88
|
+
try {
|
|
89
|
+
await link(temporary, destination);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code !== "EEXIST") {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
const existing = await readFile(destination, "utf8");
|
|
95
|
+
if (existing !== bytes) {
|
|
96
|
+
throw new Error("raw event spool entry conflicts with existing event_id");
|
|
97
|
+
}
|
|
98
|
+
await chmod(destination, 0o600);
|
|
99
|
+
return { eventId, serialized: existing };
|
|
100
|
+
}
|
|
101
|
+
await chmod(destination, 0o600);
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { eventId, serialized: bytes };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const writeRawEventSpoolEntry = async (options) => {
|
|
110
|
+
const directory = resolveSpoolDirectory(options?.homeDir);
|
|
111
|
+
const previousWrite = spoolWritesInFlight.get(directory);
|
|
112
|
+
const writePromise = (previousWrite ? previousWrite.catch(() => {}) : Promise.resolve())
|
|
113
|
+
.then(() => writeRawEventSpoolEntryUnlocked(options));
|
|
114
|
+
const trackedWrite = writePromise.finally(() => {
|
|
115
|
+
if (spoolWritesInFlight.get(directory) === trackedWrite) {
|
|
116
|
+
spoolWritesInFlight.delete(directory);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
spoolWritesInFlight.set(directory, trackedWrite);
|
|
120
|
+
return trackedWrite;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const removeRawEventSpoolEntry = async ({ eventId, homeDir = homedir() }) => {
|
|
124
|
+
const normalizedEventId = requireEventId({ event_id: eventId });
|
|
125
|
+
await rm(
|
|
126
|
+
join(resolveSpoolDirectory(homeDir), spoolFilename(normalizedEventId)),
|
|
127
|
+
{ force: true },
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const loadRawEventSpoolEntries = async ({
|
|
132
|
+
homeDir = homedir(),
|
|
133
|
+
limit = DEFAULT_DRAIN_LIMIT,
|
|
134
|
+
} = {}) => {
|
|
135
|
+
const directory = resolveSpoolDirectory(homeDir);
|
|
136
|
+
let directoryEntries;
|
|
137
|
+
try {
|
|
138
|
+
directoryEntries = await readdir(directory, { withFileTypes: true });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error?.code === "ENOENT") {
|
|
141
|
+
return { entries: [], corruptCount: 0 };
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const candidates = [];
|
|
147
|
+
for (const directoryEntry of directoryEntries) {
|
|
148
|
+
if (!directoryEntry.isFile() || !directoryEntry.name.endsWith(".json")) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const path = join(directory, directoryEntry.name);
|
|
152
|
+
try {
|
|
153
|
+
const details = await stat(path);
|
|
154
|
+
candidates.push({ path, mtimeMs: details.mtimeMs, name: directoryEntry.name });
|
|
155
|
+
} catch {
|
|
156
|
+
// A concurrently removed entry needs no recovery attempt.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
candidates.sort(
|
|
160
|
+
(left, right) => left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const entries = [];
|
|
164
|
+
let corruptCount = 0;
|
|
165
|
+
const normalizedLimit = Number.isFinite(limit) && limit > 0
|
|
166
|
+
? Math.trunc(limit)
|
|
167
|
+
: 0;
|
|
168
|
+
for (const candidate of candidates) {
|
|
169
|
+
if (entries.length >= normalizedLimit) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const serialized = await readFile(candidate.path, "utf8");
|
|
174
|
+
const envelope = JSON.parse(serialized);
|
|
175
|
+
const eventId = requireEventId(envelope);
|
|
176
|
+
if (candidate.name !== spoolFilename(eventId)) {
|
|
177
|
+
corruptCount += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
entries.push({ eventId, envelope, serialized });
|
|
181
|
+
} catch {
|
|
182
|
+
corruptCount += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { entries, corruptCount };
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export {
|
|
190
|
+
DEFAULT_DRAIN_LIMIT,
|
|
191
|
+
DEFAULT_MAX_ENTRIES,
|
|
192
|
+
RAW_EVENT_SPOOL_FULL_CODE,
|
|
193
|
+
loadRawEventSpoolEntries,
|
|
194
|
+
removeRawEventSpoolEntry,
|
|
195
|
+
resolveSpoolDirectory,
|
|
196
|
+
writeRawEventSpoolEntry,
|
|
197
|
+
};
|
|
@@ -13,17 +13,26 @@ import {
|
|
|
13
13
|
resolveAutoUpdatePlan,
|
|
14
14
|
resolveUpgradeGuidance,
|
|
15
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";
|
|
16
25
|
|
|
17
26
|
const TRUTHY_VALUES = ["1", "true", "yes"];
|
|
18
27
|
const DISABLED_VALUES = ["0", "false", "off"];
|
|
19
|
-
const PINNED_BACKEND_VERSION = "0.44.0-
|
|
28
|
+
const PINNED_BACKEND_VERSION = "0.44.0-beta.1";
|
|
20
29
|
const COMPAT_CHECK_DELAY_MS = 1500;
|
|
21
30
|
const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
22
31
|
const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
|
|
23
32
|
const MAX_UPDATE_ACTION_CHARS = 1000;
|
|
24
33
|
const MAX_UPDATE_VERSION_CHARS = 128;
|
|
25
|
-
const
|
|
26
|
-
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
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-]+)*)?$/;
|
|
27
36
|
const CODEMEM_CONTEXT_PART_ID_PREFIX = "codemem-context-";
|
|
28
37
|
const MAX_MESSAGE_INJECTION_CACHE_SESSIONS = 20;
|
|
29
38
|
const MAX_MESSAGE_INJECTION_CACHE_MESSAGES = 100;
|
|
@@ -36,10 +45,29 @@ const VIEWER_HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
|
|
|
36
45
|
const RAW_EVENTS_STATUS_TIMEOUT_MS = 5_000;
|
|
37
46
|
const DEFAULT_EMBEDDING_MODEL = "Xenova/bge-small-en-v1.5";
|
|
38
47
|
const DEFAULT_EMBEDDING_REVISION = "ea104dacec62c0de699686887e3f920caeb4f3e3";
|
|
48
|
+
const PLUGIN_REGISTRATIONS_KEY = Symbol.for("codemem.opencode-plugin.registrations");
|
|
39
49
|
|
|
40
50
|
let compatCheckCache = null;
|
|
41
51
|
const notifiedReleaseVersions = new Set();
|
|
42
52
|
|
|
53
|
+
const claimPluginRegistration = (cwd) => {
|
|
54
|
+
let registrations = globalThis[PLUGIN_REGISTRATIONS_KEY];
|
|
55
|
+
if (!(registrations instanceof Map)) {
|
|
56
|
+
registrations = new Map();
|
|
57
|
+
globalThis[PLUGIN_REGISTRATIONS_KEY] = registrations;
|
|
58
|
+
}
|
|
59
|
+
const projectPath = resolve(cwd);
|
|
60
|
+
if (registrations.has(projectPath)) return null;
|
|
61
|
+
const registration = Symbol(projectPath);
|
|
62
|
+
registrations.set(projectPath, registration);
|
|
63
|
+
return () => {
|
|
64
|
+
if (registrations.get(projectPath) === registration) {
|
|
65
|
+
registrations.delete(projectPath);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
const rawEventSpoolDrainsInFlight = new Map();
|
|
70
|
+
|
|
43
71
|
// Release an unread response body without surfacing cancellation failures.
|
|
44
72
|
const discardResponseBody = (response) => {
|
|
45
73
|
try {
|
|
@@ -354,10 +382,74 @@ const clearCompatCheckCache = () => {
|
|
|
354
382
|
compatCheckCache = null;
|
|
355
383
|
};
|
|
356
384
|
|
|
357
|
-
const
|
|
358
|
-
if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return
|
|
359
|
-
const match =
|
|
360
|
-
|
|
385
|
+
const parseReleaseVersion = (value) => {
|
|
386
|
+
if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return null;
|
|
387
|
+
const match = RELEASE_VERSION.exec(value);
|
|
388
|
+
if (!match || !match.slice(1, 4).map(Number).every(Number.isSafeInteger)) return null;
|
|
389
|
+
const prerelease = match[4]?.split(".") || [];
|
|
390
|
+
if (prerelease.some((identifier) => /^0\d+$/.test(identifier))) return null;
|
|
391
|
+
return { core: match.slice(1, 4).map(Number), prerelease };
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const releaseChannelForVersion = (value) => {
|
|
395
|
+
const parsed = parseReleaseVersion(value);
|
|
396
|
+
if (!parsed) return null;
|
|
397
|
+
if (parsed.prerelease.length === 0) return "latest";
|
|
398
|
+
const channel = parsed.prerelease[0];
|
|
399
|
+
return channel === "alpha" || channel === "beta" || channel === "rc" ? channel : null;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const comparePrereleaseIdentifier = (left, right) => {
|
|
403
|
+
if (left === undefined) return right === undefined ? 0 : -1;
|
|
404
|
+
if (right === undefined) return 1;
|
|
405
|
+
if (left === right) return 0;
|
|
406
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
407
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
408
|
+
if (leftNumeric && rightNumeric && left.length !== right.length) {
|
|
409
|
+
return Math.sign(left.length - right.length);
|
|
410
|
+
}
|
|
411
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
412
|
+
return left < right ? -1 : 1;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const compareReleaseVersions = (left, right) => {
|
|
416
|
+
const leftVersion = parseReleaseVersion(left);
|
|
417
|
+
const rightVersion = parseReleaseVersion(right);
|
|
418
|
+
if (!leftVersion || !rightVersion) return null;
|
|
419
|
+
for (let index = 0; index < leftVersion.core.length; index += 1) {
|
|
420
|
+
const difference = leftVersion.core[index] - rightVersion.core[index];
|
|
421
|
+
if (difference !== 0) return Math.sign(difference);
|
|
422
|
+
}
|
|
423
|
+
if (leftVersion.prerelease.length === 0) {
|
|
424
|
+
return rightVersion.prerelease.length === 0 ? 0 : 1;
|
|
425
|
+
}
|
|
426
|
+
if (rightVersion.prerelease.length === 0) return -1;
|
|
427
|
+
const length = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length);
|
|
428
|
+
for (let index = 0; index < length; index += 1) {
|
|
429
|
+
const comparison = comparePrereleaseIdentifier(
|
|
430
|
+
leftVersion.prerelease[index],
|
|
431
|
+
rightVersion.prerelease[index],
|
|
432
|
+
);
|
|
433
|
+
if (comparison !== 0) return comparison;
|
|
434
|
+
}
|
|
435
|
+
return 0;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const isSameChannelVersionAtLeast = (currentVersion, minimumVersion) => {
|
|
439
|
+
const currentChannel = releaseChannelForVersion(currentVersion);
|
|
440
|
+
if (!currentChannel || currentChannel !== releaseChannelForVersion(minimumVersion)) return false;
|
|
441
|
+
const comparison = compareReleaseVersions(currentVersion, minimumVersion);
|
|
442
|
+
return comparison !== null && comparison >= 0;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const PINNED_RELEASE_CHANNEL = releaseChannelForVersion(PINNED_BACKEND_VERSION);
|
|
446
|
+
|
|
447
|
+
const guidanceMatchesRelease = (guidance, latestVersion) => {
|
|
448
|
+
const versions =
|
|
449
|
+
String(guidance || "").match(
|
|
450
|
+
/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?/g,
|
|
451
|
+
) || [];
|
|
452
|
+
return versions.length === 0 || versions.every((version) => version === latestVersion);
|
|
361
453
|
};
|
|
362
454
|
|
|
363
455
|
const parseReleaseNotification = (result) => {
|
|
@@ -368,18 +460,24 @@ const parseReleaseNotification = (result) => {
|
|
|
368
460
|
const status = JSON.parse(stdout);
|
|
369
461
|
if (!status || typeof status !== "object" || Array.isArray(status)) return null;
|
|
370
462
|
if (status.update_available !== true) return null;
|
|
371
|
-
if (
|
|
463
|
+
if (
|
|
464
|
+
!PINNED_RELEASE_CHANNEL
|
|
465
|
+
|| status.channel !== PINNED_RELEASE_CHANNEL
|
|
466
|
+
|| releaseChannelForVersion(status.latest_version) !== PINNED_RELEASE_CHANNEL
|
|
467
|
+
) {
|
|
372
468
|
return null;
|
|
373
469
|
}
|
|
374
470
|
if (
|
|
375
471
|
typeof status.recommended_action !== "string"
|
|
376
472
|
|| !status.recommended_action.trim()
|
|
377
473
|
|| status.recommended_action.length > MAX_UPDATE_ACTION_CHARS
|
|
474
|
+
|| !guidanceMatchesRelease(status.recommended_action, status.latest_version)
|
|
378
475
|
) {
|
|
379
476
|
return null;
|
|
380
477
|
}
|
|
381
478
|
return {
|
|
382
479
|
latestVersion: status.latest_version,
|
|
480
|
+
channel: PINNED_RELEASE_CHANNEL,
|
|
383
481
|
recommendedAction: status.recommended_action.trim(),
|
|
384
482
|
autoUpdateEligible: status.auto_update_eligible === true,
|
|
385
483
|
};
|
|
@@ -761,6 +859,20 @@ const isViewerInvalidRequestPayload = (payload) =>
|
|
|
761
859
|
&& isRecord(payload.error)
|
|
762
860
|
&& payload.error.code === "invalid_request";
|
|
763
861
|
|
|
862
|
+
const classifyRawEventViewerFailure = (payload) => {
|
|
863
|
+
if (isViewerDbMismatchPayload(payload)) return "database";
|
|
864
|
+
if (isViewerIdentityMismatchPayload(payload)) return "identity";
|
|
865
|
+
if (isViewerContractUnsupportedPayload(payload)) return "contract";
|
|
866
|
+
return "connection";
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const RAW_EVENT_FAILURE_ACTIONS = Object.freeze({
|
|
870
|
+
database: "restart the viewer from the same workspace/config",
|
|
871
|
+
identity: "restart Codemem and OpenCode with the same environment",
|
|
872
|
+
contract: "update Codemem on the installed channel, then restart OpenCode",
|
|
873
|
+
connection: "check or restart the viewer",
|
|
874
|
+
});
|
|
875
|
+
|
|
764
876
|
// Dependency-free port of @codemem/core prompt-transport semantics. Keep the
|
|
765
877
|
// range and classifier parity pinned by the plugin injection tests.
|
|
766
878
|
const PROMPT_TRANSPORT_PROTOCOL_RANGE = Object.freeze({
|
|
@@ -1594,7 +1706,11 @@ const trimEventQueue = ({ events, maxEvents, hardMaxEvents, onUnsentPressure, on
|
|
|
1594
1706
|
hardMaxEvents > 0 &&
|
|
1595
1707
|
events.length > hardMaxEvents
|
|
1596
1708
|
) {
|
|
1597
|
-
const
|
|
1709
|
+
const durableIndex = events.findIndex(
|
|
1710
|
+
(queued) => queued && typeof queued === "object" && queued._raw_spooled
|
|
1711
|
+
);
|
|
1712
|
+
const dropIndex = durableIndex >= 0 ? durableIndex : 0;
|
|
1713
|
+
const [dropped] = events.splice(dropIndex, 1);
|
|
1598
1714
|
if (typeof onForcedDrop === "function") {
|
|
1599
1715
|
onForcedDrop(dropped, events.length, hardMaxEvents);
|
|
1600
1716
|
}
|
|
@@ -1693,22 +1809,10 @@ const detectRunner = ({ cwd, envRunner }) => {
|
|
|
1693
1809
|
// terminal on every OpenCode startup for npx-only installs.
|
|
1694
1810
|
stdio: ["ignore", "pipe", "ignore"],
|
|
1695
1811
|
}).trim();
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1698
|
-
//
|
|
1699
|
-
|
|
1700
|
-
// Accept a global codemem only when it is the EXACT pinned version, or a
|
|
1701
|
-
// clean (non-prerelease) release at least as new as the pin. isVersionAtLeast
|
|
1702
|
-
// ignores prerelease suffixes, so without this a prerelease like
|
|
1703
|
-
// "0.36.1-alpha.0" would wrongly compare >= the "0.36.1" release even though
|
|
1704
|
-
// SemVer orders it below. Requiring an exact match for prereleases also
|
|
1705
|
-
// handles a prerelease PIN correctly (the release script can set one): only
|
|
1706
|
-
// the identical prerelease is trusted, never an older/different one.
|
|
1707
|
-
const isPrerelease = versionOutput.includes("-");
|
|
1708
|
-
const matchesOrCleanNewer =
|
|
1709
|
-
versionOutput === PINNED_BACKEND_VERSION ||
|
|
1710
|
-
(!isPrerelease && isVersionAtLeast(versionOutput, PINNED_BACKEND_VERSION));
|
|
1711
|
-
if (matchesOrCleanNewer) {
|
|
1812
|
+
// Use a newer global CLI only when it remains on the plugin's release
|
|
1813
|
+
// channel. This prevents both channel crossing and fallback to an older pin
|
|
1814
|
+
// immediately after a successful prerelease auto-update.
|
|
1815
|
+
if (isSameChannelVersionAtLeast(versionOutput, PINNED_BACKEND_VERSION)) {
|
|
1712
1816
|
return "codemem";
|
|
1713
1817
|
}
|
|
1714
1818
|
} catch {
|
|
@@ -1822,6 +1926,13 @@ export const CodememPlugin = async ({
|
|
|
1822
1926
|
if (pluginIgnored) {
|
|
1823
1927
|
return {};
|
|
1824
1928
|
}
|
|
1929
|
+
const releasePluginRegistration = claimPluginRegistration(cwd);
|
|
1930
|
+
if (!releasePluginRegistration) {
|
|
1931
|
+
await log("warn", "codemem duplicate plugin registration skipped", {
|
|
1932
|
+
next_action: "remove either the configured npm plugin or the project-local Codemem plugin",
|
|
1933
|
+
});
|
|
1934
|
+
return {};
|
|
1935
|
+
}
|
|
1825
1936
|
|
|
1826
1937
|
const runner = detectRunner({
|
|
1827
1938
|
cwd,
|
|
@@ -1923,9 +2034,22 @@ export const CodememPlugin = async ({
|
|
|
1923
2034
|
process.env.CODEMEM_RAW_EVENTS_HARD_MAX || "2000",
|
|
1924
2035
|
2000
|
|
1925
2036
|
);
|
|
2037
|
+
const rawEventSpoolDrainLimit = parsePositiveInt(
|
|
2038
|
+
process.env.CODEMEM_RAW_EVENT_SPOOL_DRAIN_LIMIT,
|
|
2039
|
+
DEFAULT_DRAIN_LIMIT,
|
|
2040
|
+
);
|
|
2041
|
+
const rawEventSpoolMaxEntries = parsePositiveInt(
|
|
2042
|
+
process.env.CODEMEM_RAW_EVENT_SPOOL_MAX_ENTRIES,
|
|
2043
|
+
DEFAULT_MAX_ENTRIES,
|
|
2044
|
+
);
|
|
2045
|
+
const rawEventSpoolHome = process.env.HOME?.trim() || homedir();
|
|
2046
|
+
const rawEventSpoolDirectory = resolveSpoolDirectory(rawEventSpoolHome);
|
|
2047
|
+
// Memoize the exact serialized envelope by queued-object identity so retries
|
|
2048
|
+
// cannot drift in timestamp, ID, property order, or bytes.
|
|
2049
|
+
const rawEventEnvelopes = new WeakMap();
|
|
1926
2050
|
let streamUnavailableUntil = 0;
|
|
1927
|
-
let
|
|
1928
|
-
let
|
|
2051
|
+
let spoolPersistenceFailureNoted = null;
|
|
2052
|
+
let spoolLoadFailureNoted = false;
|
|
1929
2053
|
let lastStatusCheckAt = 0;
|
|
1930
2054
|
let lastStatusAvailable = true;
|
|
1931
2055
|
let promptPackTransportUnavailableUntil = 0;
|
|
@@ -1937,9 +2061,9 @@ export const CodememPlugin = async ({
|
|
|
1937
2061
|
return `${Date.now()}-${Math.random()}`;
|
|
1938
2062
|
};
|
|
1939
2063
|
|
|
1940
|
-
const queueRawEventViaCli = async (body) => {
|
|
2064
|
+
const queueRawEventViaCli = async (body, serialized = JSON.stringify(body)) => {
|
|
1941
2065
|
const runFallback = () => runCli(["enqueue-raw-event"], {
|
|
1942
|
-
stdinText:
|
|
2066
|
+
stdinText: serialized,
|
|
1943
2067
|
});
|
|
1944
2068
|
let result = await runFallback();
|
|
1945
2069
|
let classification = classifyFallbackCommandResult(result);
|
|
@@ -1954,23 +2078,191 @@ export const CodememPlugin = async ({
|
|
|
1954
2078
|
const error = new Error(
|
|
1955
2079
|
retryExhausted ? `${classification.cause} after retry` : classification.cause
|
|
1956
2080
|
);
|
|
1957
|
-
error.retryable = classification.retryable
|
|
2081
|
+
error.retryable = classification.retryable;
|
|
1958
2082
|
throw error;
|
|
1959
2083
|
}
|
|
1960
2084
|
return true;
|
|
1961
2085
|
};
|
|
1962
2086
|
|
|
1963
2087
|
const lastToastAtBySession = new Map();
|
|
1964
|
-
const shouldToast = (sessionID) => {
|
|
2088
|
+
const shouldToast = (sessionID, category = "general") => {
|
|
1965
2089
|
const now = Date.now();
|
|
1966
|
-
const
|
|
2090
|
+
const key = `${sessionID || "unknown"}:${category}`;
|
|
2091
|
+
const last = lastToastAtBySession.get(key) || 0;
|
|
1967
2092
|
if (now - last < 60000) {
|
|
1968
2093
|
return false;
|
|
1969
2094
|
}
|
|
1970
|
-
lastToastAtBySession.set(
|
|
2095
|
+
lastToastAtBySession.set(key, now);
|
|
1971
2096
|
return true;
|
|
1972
2097
|
};
|
|
1973
2098
|
|
|
2099
|
+
const warnSpoolPersistenceFailure = async (sessionID, reason) => {
|
|
2100
|
+
const message = reason === "spool_full"
|
|
2101
|
+
? "codemem raw-event retry spool is full; repair Codemem, then archive retained entries"
|
|
2102
|
+
: "codemem could not save a raw event for retry; it remains queued in memory";
|
|
2103
|
+
try {
|
|
2104
|
+
await client.app.log({
|
|
2105
|
+
service: "codemem",
|
|
2106
|
+
level: "error",
|
|
2107
|
+
message,
|
|
2108
|
+
extra: { category: "persistence", reason },
|
|
2109
|
+
});
|
|
2110
|
+
} catch {
|
|
2111
|
+
// Best-effort app logging only.
|
|
2112
|
+
}
|
|
2113
|
+
if (spoolPersistenceFailureNoted === reason) return;
|
|
2114
|
+
spoolPersistenceFailureNoted = reason;
|
|
2115
|
+
if (!client.tui?.showToast || !shouldToast(sessionID, `persistence:${reason}`)) return;
|
|
2116
|
+
try {
|
|
2117
|
+
await client.tui.showToast({
|
|
2118
|
+
body: { message: `codemem: ${message}`, variant: "error" },
|
|
2119
|
+
});
|
|
2120
|
+
} catch {
|
|
2121
|
+
// Best-effort toast only.
|
|
2122
|
+
}
|
|
2123
|
+
};
|
|
2124
|
+
|
|
2125
|
+
const persistRawEventForRetry = async ({ body, serialized, payload, sessionID }) => {
|
|
2126
|
+
try {
|
|
2127
|
+
await writeRawEventSpoolEntry({
|
|
2128
|
+
envelope: body,
|
|
2129
|
+
serialized,
|
|
2130
|
+
homeDir: rawEventSpoolHome,
|
|
2131
|
+
maxEntries: rawEventSpoolMaxEntries,
|
|
2132
|
+
});
|
|
2133
|
+
spoolPersistenceFailureNoted = null;
|
|
2134
|
+
if (payload && typeof payload === "object") {
|
|
2135
|
+
payload._raw_spooled = true;
|
|
2136
|
+
}
|
|
2137
|
+
return true;
|
|
2138
|
+
} catch (error) {
|
|
2139
|
+
const reason = error?.code === RAW_EVENT_SPOOL_FULL_CODE ? "spool_full" : "write_failed";
|
|
2140
|
+
await logLine(`raw_events.spool.${reason} category=persistence`);
|
|
2141
|
+
await warnSpoolPersistenceFailure(sessionID, reason);
|
|
2142
|
+
return false;
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
|
|
2146
|
+
const removeRawEventFromSpool = async ({ eventId, payload }) => {
|
|
2147
|
+
try {
|
|
2148
|
+
await removeRawEventSpoolEntry({ eventId, homeDir: rawEventSpoolHome });
|
|
2149
|
+
if (payload && typeof payload === "object") {
|
|
2150
|
+
payload._raw_spooled = false;
|
|
2151
|
+
}
|
|
2152
|
+
} catch {
|
|
2153
|
+
await logLine("raw_events.spool.cleanup_failed category=persistence");
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
|
|
2157
|
+
const notifyRawEventDelivery = async ({ category, delivered, durable, sessionID }) => {
|
|
2158
|
+
const action = RAW_EVENT_FAILURE_ACTIONS[category] || RAW_EVENT_FAILURE_ACTIONS.connection;
|
|
2159
|
+
let outcome = "was saved for retry";
|
|
2160
|
+
if (delivered) {
|
|
2161
|
+
outcome = "was queued via CLI";
|
|
2162
|
+
} else if (!durable) {
|
|
2163
|
+
outcome = "was not saved for retry; still queued in memory";
|
|
2164
|
+
}
|
|
2165
|
+
const message = `codemem raw event ${outcome}; ${action}`;
|
|
2166
|
+
let delivery = "memory";
|
|
2167
|
+
if (delivered) {
|
|
2168
|
+
delivery = "cli";
|
|
2169
|
+
} else if (durable) {
|
|
2170
|
+
delivery = "spool";
|
|
2171
|
+
}
|
|
2172
|
+
try {
|
|
2173
|
+
await client.app.log({
|
|
2174
|
+
service: "codemem",
|
|
2175
|
+
level: delivered ? "warn" : "error",
|
|
2176
|
+
message,
|
|
2177
|
+
extra: {
|
|
2178
|
+
category,
|
|
2179
|
+
delivery,
|
|
2180
|
+
},
|
|
2181
|
+
});
|
|
2182
|
+
} catch {
|
|
2183
|
+
// Best-effort app logging only.
|
|
2184
|
+
}
|
|
2185
|
+
if (!client.tui?.showToast || !shouldToast(sessionID, `${category}:${delivery}`)) return;
|
|
2186
|
+
try {
|
|
2187
|
+
await client.tui.showToast({
|
|
2188
|
+
body: { message: `codemem: ${message}`, variant: delivered ? "warning" : "error" },
|
|
2189
|
+
});
|
|
2190
|
+
} catch {
|
|
2191
|
+
// Best-effort toast only.
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
|
|
2195
|
+
const drainRawEventSpool = () => {
|
|
2196
|
+
if (!rawEventsEnabled) {
|
|
2197
|
+
return Promise.resolve();
|
|
2198
|
+
}
|
|
2199
|
+
const existingDrain = rawEventSpoolDrainsInFlight.get(rawEventSpoolDirectory);
|
|
2200
|
+
if (existingDrain) {
|
|
2201
|
+
return existingDrain;
|
|
2202
|
+
}
|
|
2203
|
+
const drainPromise = (async () => {
|
|
2204
|
+
let loaded;
|
|
2205
|
+
try {
|
|
2206
|
+
loaded = await loadRawEventSpoolEntries({
|
|
2207
|
+
homeDir: rawEventSpoolHome,
|
|
2208
|
+
limit: rawEventSpoolDrainLimit,
|
|
2209
|
+
});
|
|
2210
|
+
} catch {
|
|
2211
|
+
await logLine("raw_events.spool.load_failed category=persistence");
|
|
2212
|
+
const message = "codemem could not read saved raw events; spool entries were left untouched";
|
|
2213
|
+
try {
|
|
2214
|
+
await client.app.log({
|
|
2215
|
+
service: "codemem",
|
|
2216
|
+
level: "error",
|
|
2217
|
+
message,
|
|
2218
|
+
extra: { category: "persistence", delivery: "load" },
|
|
2219
|
+
});
|
|
2220
|
+
} catch {
|
|
2221
|
+
// Best-effort app logging only.
|
|
2222
|
+
}
|
|
2223
|
+
if (!spoolLoadFailureNoted) {
|
|
2224
|
+
spoolLoadFailureNoted = true;
|
|
2225
|
+
if (client.tui?.showToast && shouldToast(null, "persistence:load")) {
|
|
2226
|
+
try {
|
|
2227
|
+
await client.tui.showToast({
|
|
2228
|
+
body: { message: `codemem: ${message}`, variant: "error" },
|
|
2229
|
+
});
|
|
2230
|
+
} catch {
|
|
2231
|
+
// Best-effort toast only.
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
spoolLoadFailureNoted = false;
|
|
2238
|
+
if (loaded.corruptCount > 0) {
|
|
2239
|
+
await logLine(`raw_events.spool.corrupt_retained count=${loaded.corruptCount}`);
|
|
2240
|
+
}
|
|
2241
|
+
for (const entry of loaded.entries) {
|
|
2242
|
+
try {
|
|
2243
|
+
await queueRawEventViaCli(entry.envelope, entry.serialized);
|
|
2244
|
+
await removeRawEventFromSpool({ eventId: entry.eventId });
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
await logLine("raw_events.spool.drain_deferred category=fallback");
|
|
2247
|
+
if (error?.retryable === true) {
|
|
2248
|
+
break;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
})();
|
|
2253
|
+
const trackedDrain = drainPromise
|
|
2254
|
+
.catch(async () => {
|
|
2255
|
+
await logLine("raw_events.spool.drain_failed category=persistence");
|
|
2256
|
+
})
|
|
2257
|
+
.finally(() => {
|
|
2258
|
+
if (rawEventSpoolDrainsInFlight.get(rawEventSpoolDirectory) === trackedDrain) {
|
|
2259
|
+
rawEventSpoolDrainsInFlight.delete(rawEventSpoolDirectory);
|
|
2260
|
+
}
|
|
2261
|
+
});
|
|
2262
|
+
rawEventSpoolDrainsInFlight.set(rawEventSpoolDirectory, trackedDrain);
|
|
2263
|
+
return trackedDrain;
|
|
2264
|
+
};
|
|
2265
|
+
|
|
1974
2266
|
const buildViewerCliArgs = (action) => {
|
|
1975
2267
|
const args = ["serve", action, "--host", viewerHost, "--port", viewerPort];
|
|
1976
2268
|
if (String(viewerDbPath || "").trim()) {
|
|
@@ -1990,66 +2282,54 @@ export const CodememPlugin = async ({
|
|
|
1990
2282
|
return false;
|
|
1991
2283
|
}
|
|
1992
2284
|
const now = Date.now();
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2285
|
+
let cachedEnvelope = payload && typeof payload === "object"
|
|
2286
|
+
? rawEventEnvelopes.get(payload)
|
|
2287
|
+
: null;
|
|
2288
|
+
if (!cachedEnvelope) {
|
|
2289
|
+
const builtEnvelope = buildRawEventEnvelope({
|
|
2290
|
+
sessionID,
|
|
2291
|
+
type,
|
|
2292
|
+
payload,
|
|
2293
|
+
cwd,
|
|
2294
|
+
project: resolveProjectName(project, cwd),
|
|
2295
|
+
startedAt: sessionStartedAt,
|
|
2296
|
+
nowMs: now,
|
|
2297
|
+
nowMono:
|
|
2298
|
+
typeof performance !== "undefined" && performance.now
|
|
2299
|
+
? performance.now()
|
|
2300
|
+
: null,
|
|
2301
|
+
nextEventId,
|
|
2302
|
+
});
|
|
2303
|
+
const serialized = JSON.stringify(builtEnvelope);
|
|
2304
|
+
cachedEnvelope = { body: JSON.parse(serialized), serialized };
|
|
2305
|
+
if (payload && typeof payload === "object") {
|
|
2306
|
+
rawEventEnvelopes.set(payload, cachedEnvelope);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
const { body, serialized } = cachedEnvelope;
|
|
2008
2310
|
if (now < streamUnavailableUntil) {
|
|
2311
|
+
const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
|
|
2009
2312
|
try {
|
|
2010
|
-
await queueRawEventViaCli(body);
|
|
2011
|
-
|
|
2313
|
+
await queueRawEventViaCli(body, serialized);
|
|
2314
|
+
await removeRawEventFromSpool({ eventId: body.event_id, payload });
|
|
2012
2315
|
if (payload && typeof payload === "object") {
|
|
2013
2316
|
payload._raw_enqueued = true;
|
|
2014
2317
|
}
|
|
2318
|
+
await notifyRawEventDelivery({
|
|
2319
|
+
category: "connection",
|
|
2320
|
+
delivered: true,
|
|
2321
|
+
durable,
|
|
2322
|
+
sessionID,
|
|
2323
|
+
});
|
|
2015
2324
|
return true;
|
|
2016
|
-
} catch
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
);
|
|
2025
|
-
if (!fallbackFailureNoted) {
|
|
2026
|
-
fallbackFailureNoted = true;
|
|
2027
|
-
try {
|
|
2028
|
-
await client.app.log({
|
|
2029
|
-
service: "codemem",
|
|
2030
|
-
level: "error",
|
|
2031
|
-
message: "codemem fallback enqueue failed during stream backoff",
|
|
2032
|
-
extra: {
|
|
2033
|
-
sessionID,
|
|
2034
|
-
backoffMs: rawEventsBackoffMs,
|
|
2035
|
-
},
|
|
2036
|
-
});
|
|
2037
|
-
} catch (logErr) {
|
|
2038
|
-
// best-effort logging only
|
|
2039
|
-
}
|
|
2040
|
-
if (client.tui?.showToast && shouldToast(sessionID)) {
|
|
2041
|
-
try {
|
|
2042
|
-
await client.tui.showToast({
|
|
2043
|
-
body: {
|
|
2044
|
-
message: "codemem: fallback enqueue failed while stream is down",
|
|
2045
|
-
variant: "error",
|
|
2046
|
-
},
|
|
2047
|
-
});
|
|
2048
|
-
} catch (toastErr) {
|
|
2049
|
-
// best-effort only
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2052
|
-
}
|
|
2325
|
+
} catch {
|
|
2326
|
+
await logLine("raw_events.fallback.error category=connection");
|
|
2327
|
+
await notifyRawEventDelivery({
|
|
2328
|
+
category: "connection",
|
|
2329
|
+
delivered: false,
|
|
2330
|
+
durable,
|
|
2331
|
+
sessionID,
|
|
2332
|
+
});
|
|
2053
2333
|
return false;
|
|
2054
2334
|
}
|
|
2055
2335
|
}
|
|
@@ -2080,130 +2360,46 @@ export const CodememPlugin = async ({
|
|
|
2080
2360
|
});
|
|
2081
2361
|
if (!postResp.ok) {
|
|
2082
2362
|
let responseBody = null;
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
// Generic failure handling below remains the safe fallback.
|
|
2088
|
-
}
|
|
2363
|
+
try {
|
|
2364
|
+
responseBody = await postResp.json();
|
|
2365
|
+
} catch {
|
|
2366
|
+
// Generic connection guidance below remains the safe fallback.
|
|
2089
2367
|
}
|
|
2090
|
-
const targetMismatch = isViewerDbMismatchPayload(responseBody)
|
|
2091
|
-
|| isViewerIdentityMismatchPayload(responseBody)
|
|
2092
|
-
|| isViewerContractUnsupportedPayload(responseBody);
|
|
2093
2368
|
const postError = new Error(`raw-events post failed (${postResp.status})`);
|
|
2094
|
-
postError.
|
|
2369
|
+
postError.rawEventFailureCategory = classifyRawEventViewerFailure(responseBody);
|
|
2095
2370
|
throw postError;
|
|
2096
2371
|
}
|
|
2097
2372
|
streamUnavailableUntil = 0;
|
|
2098
|
-
streamErrorNoted = false;
|
|
2099
|
-
fallbackFailureNoted = false;
|
|
2100
2373
|
lastStatusAvailable = true;
|
|
2374
|
+
await removeRawEventFromSpool({ eventId: body.event_id, payload });
|
|
2101
2375
|
if (payload && typeof payload === "object") {
|
|
2102
2376
|
payload._raw_enqueued = true;
|
|
2103
2377
|
}
|
|
2104
2378
|
return true;
|
|
2105
2379
|
} catch (err) {
|
|
2380
|
+
const category = err?.rawEventFailureCategory || "connection";
|
|
2106
2381
|
streamUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
|
|
2107
|
-
await logLine(`raw_events.error
|
|
2108
|
-
|
|
2109
|
-
await client.app.log({
|
|
2110
|
-
service: "codemem",
|
|
2111
|
-
level: "error",
|
|
2112
|
-
message: "Failed to stream raw events to codemem viewer",
|
|
2113
|
-
extra: {
|
|
2114
|
-
sessionID,
|
|
2115
|
-
type,
|
|
2116
|
-
viewerHost,
|
|
2117
|
-
viewerPort,
|
|
2118
|
-
viewerTargetMismatch: err?.viewerTargetMismatch === true,
|
|
2119
|
-
error: String(err),
|
|
2120
|
-
},
|
|
2121
|
-
});
|
|
2122
|
-
} catch (logErr) {
|
|
2123
|
-
// best-effort logging only
|
|
2124
|
-
}
|
|
2382
|
+
await logLine(`raw_events.error category=${category}`);
|
|
2383
|
+
const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
|
|
2125
2384
|
|
|
2126
2385
|
let fallbackOk = false;
|
|
2127
2386
|
try {
|
|
2128
|
-
await queueRawEventViaCli(body);
|
|
2387
|
+
await queueRawEventViaCli(body, serialized);
|
|
2388
|
+
await removeRawEventFromSpool({ eventId: body.event_id, payload });
|
|
2129
2389
|
fallbackOk = true;
|
|
2130
|
-
} catch
|
|
2131
|
-
|
|
2132
|
-
payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
|
|
2133
|
-
}
|
|
2134
|
-
await logLine(
|
|
2135
|
-
`raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
|
|
2136
|
-
fallbackErr
|
|
2137
|
-
).slice(0, 200)}`
|
|
2138
|
-
);
|
|
2390
|
+
} catch {
|
|
2391
|
+
await logLine(`raw_events.fallback.error category=${category}`);
|
|
2139
2392
|
}
|
|
2140
2393
|
|
|
2141
2394
|
if (fallbackOk) {
|
|
2142
|
-
fallbackFailureNoted = false;
|
|
2143
2395
|
if (payload && typeof payload === "object") {
|
|
2144
2396
|
payload._raw_enqueued = true;
|
|
2145
2397
|
}
|
|
2146
|
-
|
|
2147
|
-
streamErrorNoted = true;
|
|
2148
|
-
try {
|
|
2149
|
-
await client.app.log({
|
|
2150
|
-
service: "codemem",
|
|
2151
|
-
level: "warn",
|
|
2152
|
-
message: "codemem stream unavailable; queued raw event via CLI fallback",
|
|
2153
|
-
extra: {
|
|
2154
|
-
sessionID,
|
|
2155
|
-
backoffMs: rawEventsBackoffMs,
|
|
2156
|
-
},
|
|
2157
|
-
});
|
|
2158
|
-
} catch (logErr) {
|
|
2159
|
-
// best-effort logging only
|
|
2160
|
-
}
|
|
2161
|
-
}
|
|
2162
|
-
if (client.tui?.showToast && shouldToast(sessionID)) {
|
|
2163
|
-
try {
|
|
2164
|
-
await client.tui.showToast({
|
|
2165
|
-
body: {
|
|
2166
|
-
message: "codemem: viewer stream unavailable; queue fallback active",
|
|
2167
|
-
variant: "warning",
|
|
2168
|
-
},
|
|
2169
|
-
});
|
|
2170
|
-
} catch (toastErr) {
|
|
2171
|
-
// best-effort only
|
|
2172
|
-
}
|
|
2173
|
-
}
|
|
2398
|
+
await notifyRawEventDelivery({ category, delivered: true, durable, sessionID });
|
|
2174
2399
|
return true;
|
|
2175
2400
|
}
|
|
2176
2401
|
|
|
2177
|
-
|
|
2178
|
-
streamErrorNoted = true;
|
|
2179
|
-
fallbackFailureNoted = true;
|
|
2180
|
-
try {
|
|
2181
|
-
await client.app.log({
|
|
2182
|
-
service: "codemem",
|
|
2183
|
-
level: "error",
|
|
2184
|
-
message: "codemem stream unavailable; fallback enqueue failed",
|
|
2185
|
-
extra: {
|
|
2186
|
-
sessionID,
|
|
2187
|
-
backoffMs: rawEventsBackoffMs,
|
|
2188
|
-
},
|
|
2189
|
-
});
|
|
2190
|
-
} catch (logErr) {
|
|
2191
|
-
// best-effort logging only
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
if (client.tui?.showToast && shouldToast(sessionID)) {
|
|
2196
|
-
try {
|
|
2197
|
-
await client.tui.showToast({
|
|
2198
|
-
body: {
|
|
2199
|
-
message: `codemem: stream unavailable (${viewerHost}:${viewerPort}); fallback failed`,
|
|
2200
|
-
variant: "error",
|
|
2201
|
-
},
|
|
2202
|
-
});
|
|
2203
|
-
} catch (toastErr) {
|
|
2204
|
-
// best-effort only
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2402
|
+
await notifyRawEventDelivery({ category, delivered: false, durable, sessionID });
|
|
2207
2403
|
return false;
|
|
2208
2404
|
}
|
|
2209
2405
|
};
|
|
@@ -3575,9 +3771,17 @@ export const CodememPlugin = async ({
|
|
|
3575
3771
|
void logLine(`queue.pressure unsent_preserved queued=${queuedCount} max_events=${cap}`);
|
|
3576
3772
|
},
|
|
3577
3773
|
onForcedDrop: (dropped, queuedCount, hardCap) => {
|
|
3774
|
+
const durableSpoolCoverage = dropped?._raw_spooled === true;
|
|
3578
3775
|
void logLine(
|
|
3579
|
-
`queue.drop hard_cap
|
|
3776
|
+
`queue.drop hard_cap durable_spool_coverage=${durableSpoolCoverage} queued=${queuedCount} hard_max=${hardCap}`
|
|
3580
3777
|
);
|
|
3778
|
+
void log("error", "codemem raw event dropped at in-memory hard cap", {
|
|
3779
|
+
category: "queue_capacity",
|
|
3780
|
+
delivery: durableSpoolCoverage ? "spool" : "memory",
|
|
3781
|
+
durable_spool_coverage: durableSpoolCoverage,
|
|
3782
|
+
queued_count: queuedCount,
|
|
3783
|
+
hard_max_events: hardCap,
|
|
3784
|
+
});
|
|
3581
3785
|
},
|
|
3582
3786
|
});
|
|
3583
3787
|
};
|
|
@@ -3607,7 +3811,6 @@ export const CodememPlugin = async ({
|
|
|
3607
3811
|
...adapterAnnotatedEvent,
|
|
3608
3812
|
_raw_event_id: rawEventId,
|
|
3609
3813
|
_raw_session_id: resolvedSessionID,
|
|
3610
|
-
_raw_retry_count: 0,
|
|
3611
3814
|
};
|
|
3612
3815
|
recordEvent(queuedEvent);
|
|
3613
3816
|
void emitRawEvent({
|
|
@@ -3619,6 +3822,7 @@ export const CodememPlugin = async ({
|
|
|
3619
3822
|
|
|
3620
3823
|
const flushEvents = async () => {
|
|
3621
3824
|
if (!events.length) {
|
|
3825
|
+
await drainRawEventSpool();
|
|
3622
3826
|
await logLine("flush.skip empty");
|
|
3623
3827
|
return;
|
|
3624
3828
|
}
|
|
@@ -3630,18 +3834,10 @@ export const CodememPlugin = async ({
|
|
|
3630
3834
|
}
|
|
3631
3835
|
|
|
3632
3836
|
const failed = [];
|
|
3633
|
-
let droppedCount = 0;
|
|
3634
3837
|
for (const queuedEvent of batch) {
|
|
3635
3838
|
if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_enqueued) {
|
|
3636
3839
|
continue;
|
|
3637
3840
|
}
|
|
3638
|
-
if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_fallback_terminal) {
|
|
3639
|
-
droppedCount += 1;
|
|
3640
|
-
await logLine(
|
|
3641
|
-
`flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
|
|
3642
|
-
);
|
|
3643
|
-
continue;
|
|
3644
|
-
}
|
|
3645
3841
|
const queuedSessionID =
|
|
3646
3842
|
queuedEvent?._raw_session_id ||
|
|
3647
3843
|
queuedEvent?.properties?.sessionID ||
|
|
@@ -3652,27 +3848,13 @@ export const CodememPlugin = async ({
|
|
|
3652
3848
|
payload: queuedEvent,
|
|
3653
3849
|
});
|
|
3654
3850
|
if (!ok) {
|
|
3655
|
-
|
|
3656
|
-
droppedCount += 1;
|
|
3657
|
-
await logLine(
|
|
3658
|
-
`flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
|
|
3659
|
-
);
|
|
3660
|
-
continue;
|
|
3661
|
-
}
|
|
3662
|
-
const currentRetry =
|
|
3663
|
-
typeof queuedEvent?._raw_retry_count === "number" && Number.isFinite(queuedEvent._raw_retry_count)
|
|
3664
|
-
? queuedEvent._raw_retry_count
|
|
3665
|
-
: 0;
|
|
3666
|
-
const nextRetry = currentRetry + 1;
|
|
3667
|
-
failed.push({
|
|
3668
|
-
...queuedEvent,
|
|
3669
|
-
_raw_retry_count: nextRetry,
|
|
3670
|
-
});
|
|
3851
|
+
failed.push(queuedEvent);
|
|
3671
3852
|
}
|
|
3672
3853
|
}
|
|
3673
3854
|
if (failed.length) {
|
|
3674
3855
|
events.unshift(...failed);
|
|
3675
3856
|
await logLine(`flush.retry_deferred count=${failed.length}`);
|
|
3857
|
+
await drainRawEventSpool();
|
|
3676
3858
|
return;
|
|
3677
3859
|
}
|
|
3678
3860
|
|
|
@@ -3683,12 +3865,20 @@ export const CodememPlugin = async ({
|
|
|
3683
3865
|
await logLine(
|
|
3684
3866
|
`flush.stream_only finalize count=${batch.length} tools=${sessionContext.toolCount} prompts=${sessionContext.promptCount} duration=${Math.round(durationMs / 1000)}s`
|
|
3685
3867
|
);
|
|
3686
|
-
await logLine(`flush.ok count=${batch.length
|
|
3868
|
+
await logLine(`flush.ok count=${batch.length} dropped=0`);
|
|
3687
3869
|
sessionStartedAt = null;
|
|
3688
3870
|
resetSessionContext();
|
|
3871
|
+
await drainRawEventSpool();
|
|
3689
3872
|
};
|
|
3690
3873
|
|
|
3874
|
+
void drainRawEventSpool();
|
|
3875
|
+
|
|
3691
3876
|
return {
|
|
3877
|
+
dispose: () => {
|
|
3878
|
+
clearTimeout(updateCheckTimer);
|
|
3879
|
+
stopHealthCheck();
|
|
3880
|
+
releasePluginRegistration();
|
|
3881
|
+
},
|
|
3692
3882
|
"experimental.session.compacting": async (input) => {
|
|
3693
3883
|
const sessionID = extractHookSessionID(input) || activeSessionID;
|
|
3694
3884
|
markCompactionInjectionSkip(compactionInjectionSkips, sessionID);
|
|
@@ -4004,6 +4194,11 @@ export const CodememPlugin = async ({
|
|
|
4004
4194
|
if (eventType === "session.deleted") {
|
|
4005
4195
|
activeSessionID = null;
|
|
4006
4196
|
if (sessionID) {
|
|
4197
|
+
for (const key of lastToastAtBySession.keys()) {
|
|
4198
|
+
if (key.startsWith(`${sessionID}:`)) {
|
|
4199
|
+
lastToastAtBySession.delete(key);
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4007
4202
|
injectionToastShown.delete(sessionID);
|
|
4008
4203
|
messageInjectionCache.delete(sessionID);
|
|
4009
4204
|
compactionInjectionSkips.delete(sessionID);
|
|
@@ -4130,6 +4325,8 @@ export const __testUtils = {
|
|
|
4130
4325
|
redactPackCommand,
|
|
4131
4326
|
rejectsInternalLedgerFlag,
|
|
4132
4327
|
classifyFallbackCommandResult,
|
|
4328
|
+
classifyRawEventViewerFailure,
|
|
4329
|
+
detectRunner,
|
|
4133
4330
|
PROMPT_TRANSPORT_PROTOCOL_RANGE,
|
|
4134
4331
|
normalizePromptTransportProtocolRange,
|
|
4135
4332
|
arePromptTransportProtocolRangesCompatible,
|