om-memory-system 5.1.0 → 5.3.0
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/dist/adapters/opencode/in-process-client.d.ts +2 -0
- package/dist/adapters/opencode/in-process-client.js +16 -0
- package/dist/adapters/opencode/opencode-host-config.d.ts +3 -0
- package/dist/adapters/opencode/opencode-host-config.js +2 -0
- package/dist/adapters/opencode/tui-recall.d.ts +68 -0
- package/dist/adapters/opencode/tui-recall.js +302 -0
- package/dist/config-template.d.ts +1 -1
- package/dist/config-template.js +5 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/core/recall-summary.d.ts +28 -0
- package/dist/core/recall-summary.js +71 -0
- package/dist/index.js +1 -1
- package/dist/services/single-flight.d.ts +2 -0
- package/dist/services/single-flight.js +12 -0
- package/dist/services/visible-recall-config.d.ts +34 -0
- package/dist/services/visible-recall-config.js +27 -0
- package/dist/services/web-ensure.d.ts +11 -0
- package/dist/services/web-ensure.js +33 -4
- package/dist/services/web-server.d.ts +3 -0
- package/dist/services/web-server.js +26 -8
- package/dist/v2/adapter.js +6 -1
- package/dist/v2/legacy-client.js +2 -1
- package/dist/v2/memory-bridge.d.ts +2 -0
- package/dist/v2/memory-bridge.js +4 -0
- package/dist/v2/recall-reporter.d.ts +24 -0
- package/dist/v2/recall-reporter.js +128 -0
- package/dist/v2/recall-rpc.d.ts +51 -0
- package/dist/v2/recall-rpc.js +38 -0
- package/dist/web/assets/index-CJ_K-hMG.js +170 -0
- package/dist/web/assets/index-ccH97VE2.css +2 -0
- package/dist/web/index.html +2 -2
- package/opencode/tui.tsx +22 -3
- package/package.json +1 -1
- package/dist/web/assets/index-D6UWwxhU.css +0 -2
- package/dist/web/assets/index-DXmCX7Ga.js +0 -170
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const QUERY_CUT = 60;
|
|
2
|
+
const UNFOLDED_QUERY_CUT = 300;
|
|
3
|
+
const HEADLINE_CUT = 140;
|
|
4
|
+
/** Query on one line, cut to `max` characters. */
|
|
5
|
+
export function queryLine(query, max = QUERY_CUT) {
|
|
6
|
+
const line = query.replace(/\s+/g, " ").trim();
|
|
7
|
+
return line.length > max ? `${line.slice(0, max - 3)}...` : line;
|
|
8
|
+
}
|
|
9
|
+
// Entry shape comes from `formatContextForPrompt`: `<memory id="..." relevance="N%">`, with the
|
|
10
|
+
// id missing when the result had none. A body ends at its own `</memory>` when another entry, a
|
|
11
|
+
// section close or the end of the text follows it. The Claude Code hook can cut a block short and
|
|
12
|
+
// leave tags open, so a body also ends at a section close or at the end of the text. An opening
|
|
13
|
+
// tag inside the text, even at the start of a line, does not end it: a memory can quote the tag.
|
|
14
|
+
const SECTION_CLOSE = String.raw `<\/project_knowledge>|<\/memory_context>|<\/omms-retrieval>`;
|
|
15
|
+
const MEMORY_ENTRY = new RegExp(String.raw `<memory(?:\s+id="([^"]*)")?\s+relevance="(\d+)%">([\s\S]*?)` +
|
|
16
|
+
String.raw `(?=<\/memory>\s*(?:<memory\s|${SECTION_CLOSE}|$)|${SECTION_CLOSE}|$)`, "g");
|
|
17
|
+
/** One line that names a memory: the first line under `## Request`, else the first line. */
|
|
18
|
+
export function headline(body) {
|
|
19
|
+
const lines = body
|
|
20
|
+
.replace(/<\/memory>\s*$/, "")
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
const heading = lines.findIndex((line) => /^##\s*request\s*$/i.test(line));
|
|
25
|
+
const line = (heading >= 0 ? lines[heading + 1] : lines[0]) ?? "";
|
|
26
|
+
return line.length > HEADLINE_CUT ? `${line.slice(0, HEADLINE_CUT - 3)}...` : line;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Summarise a formatted retrieval section for a host to show. Returns null when the section holds
|
|
30
|
+
* no memory (a profile-only section included), so the host draws nothing. A `recent` recall has
|
|
31
|
+
* no query, whatever the caller passes.
|
|
32
|
+
*/
|
|
33
|
+
export function buildRecallSummary(section, mode, query) {
|
|
34
|
+
const items = [];
|
|
35
|
+
for (const match of section.matchAll(MEMORY_ENTRY)) {
|
|
36
|
+
const text = headline(match[3] ?? "");
|
|
37
|
+
if (!text)
|
|
38
|
+
continue;
|
|
39
|
+
const item = { relevance: Number(match[2]), headline: text };
|
|
40
|
+
if (match[1])
|
|
41
|
+
item.id = match[1];
|
|
42
|
+
items.push(item);
|
|
43
|
+
}
|
|
44
|
+
if (items.length === 0)
|
|
45
|
+
return null;
|
|
46
|
+
return { mode, query: mode === "recent" ? null : query, items };
|
|
47
|
+
}
|
|
48
|
+
/** Escape backslashes and double quotes so the quoted value in the title reads as one string. */
|
|
49
|
+
function escapeQuoted(text) {
|
|
50
|
+
return text.replace(/[\\"]/g, "\\$&");
|
|
51
|
+
}
|
|
52
|
+
/** `memory [mode=search, query="...", limit=4]`: the call's name and arguments, as a tool row shows them. */
|
|
53
|
+
export function callTitle(summary) {
|
|
54
|
+
const args = [`mode=${summary.mode}`];
|
|
55
|
+
if (summary.query)
|
|
56
|
+
args.push(`query="${escapeQuoted(queryLine(summary.query))}"`);
|
|
57
|
+
args.push(`limit=${summary.items.length}`);
|
|
58
|
+
return `memory [${args.join(", ")}]`;
|
|
59
|
+
}
|
|
60
|
+
/** Body of an unfolded row, one string per line, without indentation (the host indents). */
|
|
61
|
+
export function unfoldLines(summary) {
|
|
62
|
+
const lines = [`mode: ${summary.mode}`];
|
|
63
|
+
if (summary.query)
|
|
64
|
+
lines.push(`query: ${queryLine(summary.query, UNFOLDED_QUERY_CUT)}`);
|
|
65
|
+
lines.push(`limit: ${summary.items.length}`);
|
|
66
|
+
summary.items.forEach((item, index) => {
|
|
67
|
+
const id = item.id ? ` id=${item.id}` : "";
|
|
68
|
+
lines.push(`${index + 1}. ${item.relevance}%${id} ${item.headline}`);
|
|
69
|
+
});
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -161,7 +161,7 @@ export async function configureOpencodeHostTransport(ctx) {
|
|
|
161
161
|
if (hostConfig.fetch) {
|
|
162
162
|
setHostFetch(hostConfig.fetch);
|
|
163
163
|
}
|
|
164
|
-
else {
|
|
164
|
+
else if (!hostConfig.inProcess) {
|
|
165
165
|
log("OpenCode host fetch unavailable; falling back to global fetch", {
|
|
166
166
|
clientKeys: hostConfig.clientKeys,
|
|
167
167
|
sdkConfigCount: hostConfig.sdkConfigCount,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Like setInterval, but skips a tick while the previous run of the task is still pending. */
|
|
2
|
+
export function setSingleFlightInterval(task, ms) {
|
|
3
|
+
let running = false;
|
|
4
|
+
return setInterval(() => {
|
|
5
|
+
if (running)
|
|
6
|
+
return;
|
|
7
|
+
running = true;
|
|
8
|
+
void task().finally(() => {
|
|
9
|
+
running = false;
|
|
10
|
+
});
|
|
11
|
+
}, ms);
|
|
12
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `visibleRecall` setting (change add-opencode-v2-visible-recall), kept out
|
|
3
|
+
* of `src/config.ts` to save its lines. Pure on purpose: callers pass the file
|
|
4
|
+
* config and a log function, and the loaded `CONFIG` is never imported here,
|
|
5
|
+
* because many tests stub `src/config.js`.
|
|
6
|
+
*
|
|
7
|
+
* Read the effective value from `CONFIG.visibleRecall`. It is on by default,
|
|
8
|
+
* and only an explicit `false` turns it off.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The key as it appears in a config file. `OmmsConfig` in `src/config.ts` has
|
|
12
|
+
* no field for it, to save lines, so the functions take `FileConfigWithKey`,
|
|
13
|
+
* which any config object satisfies. A hand-edited file can hold any value,
|
|
14
|
+
* which is why they compare with `=== false`.
|
|
15
|
+
*/
|
|
16
|
+
export interface VisibleRecallFileKey {
|
|
17
|
+
/** Draw a row in the OpenCode TUI for each memory recall. Default true. A project can only turn it off. */
|
|
18
|
+
visibleRecall?: boolean;
|
|
19
|
+
}
|
|
20
|
+
type FileConfigWithKey = VisibleRecallFileKey & object;
|
|
21
|
+
/** Read the setting from a merged file config. Unset means on. */
|
|
22
|
+
export declare function visibleRecallFromFile(fileConfig: FileConfigWithKey): {
|
|
23
|
+
visibleRecall: boolean;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Apply the project rule to `overrides`, the copy of the project config that
|
|
27
|
+
* is merged over the global one. A project may turn the rows off, so a value
|
|
28
|
+
* of `false` stays. Any other project value is dropped, so the global value
|
|
29
|
+
* decides. A project value of `true` is also reported through `log`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function applyProjectVisibleRecall(project: FileConfigWithKey, overrides: FileConfigWithKey, directory: string, log: (message: string, data: {
|
|
32
|
+
directory: string;
|
|
33
|
+
}) => void): void;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `visibleRecall` setting (change add-opencode-v2-visible-recall), kept out
|
|
3
|
+
* of `src/config.ts` to save its lines. Pure on purpose: callers pass the file
|
|
4
|
+
* config and a log function, and the loaded `CONFIG` is never imported here,
|
|
5
|
+
* because many tests stub `src/config.js`.
|
|
6
|
+
*
|
|
7
|
+
* Read the effective value from `CONFIG.visibleRecall`. It is on by default,
|
|
8
|
+
* and only an explicit `false` turns it off.
|
|
9
|
+
*/
|
|
10
|
+
/** Read the setting from a merged file config. Unset means on. */
|
|
11
|
+
export function visibleRecallFromFile(fileConfig) {
|
|
12
|
+
return { visibleRecall: fileConfig.visibleRecall !== false };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Apply the project rule to `overrides`, the copy of the project config that
|
|
16
|
+
* is merged over the global one. A project may turn the rows off, so a value
|
|
17
|
+
* of `false` stays. Any other project value is dropped, so the global value
|
|
18
|
+
* decides. A project value of `true` is also reported through `log`.
|
|
19
|
+
*/
|
|
20
|
+
export function applyProjectVisibleRecall(project, overrides, directory, log) {
|
|
21
|
+
if (project.visibleRecall === false)
|
|
22
|
+
return;
|
|
23
|
+
delete overrides.visibleRecall;
|
|
24
|
+
if (project.visibleRecall === true) {
|
|
25
|
+
log("Project config cannot turn on visibleRecall; the value was ignored", { directory });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -70,6 +70,17 @@ export declare const START_LOCK_STALE_MS = 20000;
|
|
|
70
70
|
export declare function startLockPath(home?: string): string;
|
|
71
71
|
/** Lock files are private to the user (0600 in a 0700 folder), like the token file. */
|
|
72
72
|
export declare const nodeLockFs: LockFs;
|
|
73
|
+
/**
|
|
74
|
+
* `nodeLockFs.replace`: write the temp file, rename it over the target, and
|
|
75
|
+
* when the rename reports ENOENT for a temp file that was just written,
|
|
76
|
+
* rewrite it and try again, up to `rewrites` times.
|
|
77
|
+
*/
|
|
78
|
+
export declare function replaceRetrying(path: string, text: string, deps?: {
|
|
79
|
+
mkdir?: (dir: string) => void;
|
|
80
|
+
write?: (path: string, text: string) => void;
|
|
81
|
+
rename?: (from: string, to: string) => void;
|
|
82
|
+
rewrites?: number;
|
|
83
|
+
}, rewrites?: number): void;
|
|
73
84
|
/** `renameSync` that retries while Windows reports the target busy. */
|
|
74
85
|
export declare function renameRetrying(from: string, to: string, rename?: (from: string, to: string) => void, platform?: NodeJS.Platform, wait?: (ms: number) => void): void;
|
|
75
86
|
/** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
|
|
@@ -70,16 +70,45 @@ export const nodeLockFs = {
|
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
replace(path, text) {
|
|
73
|
-
|
|
74
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
75
|
-
writeFileSync(temp, text, { mode: 0o600 });
|
|
76
|
-
renameRetrying(temp, path);
|
|
73
|
+
replaceRetrying(path, text);
|
|
77
74
|
},
|
|
78
75
|
};
|
|
79
76
|
// Windows can refuse a link or rename while another process has the file open.
|
|
80
77
|
// Bound retries so a permanent permission error still reaches the caller.
|
|
81
78
|
const WINDOWS_FILE_RETRY_WAITS_MS = [1, 2, 5, 10, 20, 50, 100, 200];
|
|
82
79
|
const WINDOWS_FILE_BUSY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
80
|
+
/**
|
|
81
|
+
* How many times `replaceRetrying` rewrites its temp file when `rename`
|
|
82
|
+
* reports ENOENT for a file that was just written. Bun's rename can
|
|
83
|
+
* intermittently do that (see docs/tdr/030-intermittent-bun-rename-failure.md),
|
|
84
|
+
* which made tests/web-ensure.test.ts fail at random under load. Rewriting
|
|
85
|
+
* and retrying heals that without weakening a genuine failure, which keeps
|
|
86
|
+
* failing past this budget and still reaches the caller.
|
|
87
|
+
*/
|
|
88
|
+
const REPLACE_ENOENT_REWRITES = 3;
|
|
89
|
+
/**
|
|
90
|
+
* `nodeLockFs.replace`: write the temp file, rename it over the target, and
|
|
91
|
+
* when the rename reports ENOENT for a temp file that was just written,
|
|
92
|
+
* rewrite it and try again, up to `rewrites` times.
|
|
93
|
+
*/
|
|
94
|
+
export function replaceRetrying(path, text, deps = {}, rewrites = deps.rewrites ?? REPLACE_ENOENT_REWRITES) {
|
|
95
|
+
const mkdir = deps.mkdir ?? ((dir) => mkdirSync(dir, { recursive: true, mode: 0o700 }));
|
|
96
|
+
const write = deps.write ?? ((file, body) => writeFileSync(file, body, { mode: 0o600 }));
|
|
97
|
+
const rename = deps.rename ?? ((from, to) => renameRetrying(from, to));
|
|
98
|
+
mkdir(dirname(path));
|
|
99
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
100
|
+
for (let attempt = 0;; attempt++) {
|
|
101
|
+
write(temp, text);
|
|
102
|
+
try {
|
|
103
|
+
rename(temp, path);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error.code !== "ENOENT" || attempt >= rewrites)
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
83
112
|
/** `renameSync` that retries while Windows reports the target busy. */
|
|
84
113
|
export function renameRetrying(from, to, rename = renameSync, platform = process.platform, wait = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)) {
|
|
85
114
|
for (let attempt = 0;; attempt++) {
|
|
@@ -48,6 +48,7 @@ export declare class WebServer {
|
|
|
48
48
|
private holdOffTimer;
|
|
49
49
|
private portsExhaustedNotified;
|
|
50
50
|
private takeoverFailures;
|
|
51
|
+
private lastHealthFailure;
|
|
51
52
|
private readonly maxFallbackPort;
|
|
52
53
|
private settingsImportJobs?;
|
|
53
54
|
private backfillControlsInstance?;
|
|
@@ -99,6 +100,8 @@ export declare class WebServer {
|
|
|
99
100
|
isRunning(): boolean;
|
|
100
101
|
isServerOwner(): boolean;
|
|
101
102
|
getUrl(): string;
|
|
103
|
+
/** Why the last `checkServerAvailable` call said no, or null when it said yes. */
|
|
104
|
+
getLastHealthFailure(): string | null;
|
|
102
105
|
checkServerAvailable(): Promise<boolean>;
|
|
103
106
|
private handleRequest;
|
|
104
107
|
private contentTypeFor;
|
|
@@ -13,10 +13,13 @@ import { AUTH_HEADER, getOrCreateAuthToken, isAuthorizedApiRequest } from "./aut
|
|
|
13
13
|
import { WebAuth } from "./web-auth.js";
|
|
14
14
|
import { NODE_HTTP_IDLE_TIMEOUT_MS } from "./request-timeouts.js";
|
|
15
15
|
import { testReranker } from "./reranker-test.js";
|
|
16
|
+
import { setSingleFlightInterval } from "./single-flight.js";
|
|
16
17
|
import { handleListTags, handleListKeywords, handleListMemories, handleAddMemory, handleDeleteMemory, handleBulkDelete, handleUpdateMemory, handleSearch, handleStats, handlePinMemory, handleUnpinMemory, handleRunCleanup, handleRunDeduplication, handleDetectMigration, handleRunMigration, handleDetectTagMigration, handleRunTagMigrationBatch, handleGetTagMigrationProgress, handleDeletePrompt, handleBulkDeletePrompts, handleGetUserProfile, handleGetProfileChangelog, handleGetProfileSnapshot, handleRefreshProfile, handleUpdateProfileItem, } from "./api-handlers.js";
|
|
17
18
|
import { routeModules } from "./web-route-modules.js";
|
|
18
19
|
const isBun = typeof globalThis.Bun !== "undefined";
|
|
19
20
|
const MIN_FAILED_TAKEOVERS = 3;
|
|
21
|
+
/** A busy owner can answer late. Two seconds was short enough to call a live owner dead. */
|
|
22
|
+
const HEALTH_CHECK_TIMEOUT_MS = 8_000;
|
|
20
23
|
export function attachNodeDisconnectHandlers(req, res, onDisconnect) {
|
|
21
24
|
let disconnected = false;
|
|
22
25
|
const disconnectOnce = () => {
|
|
@@ -175,6 +178,7 @@ export class WebServer {
|
|
|
175
178
|
holdOffTimer = null;
|
|
176
179
|
portsExhaustedNotified = false;
|
|
177
180
|
takeoverFailures = 0;
|
|
181
|
+
lastHealthFailure = null;
|
|
178
182
|
maxFallbackPort;
|
|
179
183
|
settingsImportJobs;
|
|
180
184
|
backfillControlsInstance;
|
|
@@ -584,7 +588,7 @@ export class WebServer {
|
|
|
584
588
|
if (this.healthCheckInterval) {
|
|
585
589
|
return;
|
|
586
590
|
}
|
|
587
|
-
this.healthCheckInterval =
|
|
591
|
+
this.healthCheckInterval = setSingleFlightInterval(() => this.waiterTick(), 5000);
|
|
588
592
|
}
|
|
589
593
|
/** One check of a web app that waits for the port. */
|
|
590
594
|
async waiterTick() {
|
|
@@ -667,6 +671,8 @@ export class WebServer {
|
|
|
667
671
|
log("Web server port held by a non-responsive process; falling back to next port", {
|
|
668
672
|
previousPort: this.config.port,
|
|
669
673
|
newPort: nextPort,
|
|
674
|
+
lastFailure: this.lastHealthFailure,
|
|
675
|
+
healthTimeoutMs: HEALTH_CHECK_TIMEOUT_MS,
|
|
670
676
|
}, "warn");
|
|
671
677
|
this.config.port = nextPort;
|
|
672
678
|
}
|
|
@@ -743,6 +749,10 @@ export class WebServer {
|
|
|
743
749
|
getUrl() {
|
|
744
750
|
return webServerUrl(this.config.host, this.config.port);
|
|
745
751
|
}
|
|
752
|
+
/** Why the last `checkServerAvailable` call said no, or null when it said yes. */
|
|
753
|
+
getLastHealthFailure() {
|
|
754
|
+
return this.lastHealthFailure;
|
|
755
|
+
}
|
|
746
756
|
async checkServerAvailable() {
|
|
747
757
|
try {
|
|
748
758
|
// Only a web app on this machine answers here, so the local token file is enough.
|
|
@@ -751,20 +761,28 @@ export class WebServer {
|
|
|
751
761
|
const response = await fetch(`${this.getUrl()}${endpoint}`, {
|
|
752
762
|
method: "GET",
|
|
753
763
|
headers,
|
|
754
|
-
signal: AbortSignal.timeout(
|
|
764
|
+
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
|
755
765
|
});
|
|
756
|
-
if (!response.ok)
|
|
766
|
+
if (!response.ok) {
|
|
767
|
+
this.lastHealthFailure = `http ${response.status}`;
|
|
757
768
|
return false;
|
|
769
|
+
}
|
|
758
770
|
// Fallback spans 10 neighbor ports; any 2xx from an unrelated local
|
|
759
771
|
// service must not be mistaken for an omms owner. Require the
|
|
760
772
|
// response to carry our API envelope.
|
|
761
773
|
const body = (await response.json());
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
return body.success === true;
|
|
774
|
+
const ok = body.success === true && (endpoint !== "/api/health" || body.status === "ok");
|
|
775
|
+
this.lastHealthFailure = ok ? null : "unexpected response";
|
|
776
|
+
return ok;
|
|
766
777
|
}
|
|
767
|
-
catch {
|
|
778
|
+
catch (error) {
|
|
779
|
+
const name = error instanceof Error ? error.name : "";
|
|
780
|
+
this.lastHealthFailure =
|
|
781
|
+
name === "TimeoutError"
|
|
782
|
+
? "timeout"
|
|
783
|
+
: error instanceof Error
|
|
784
|
+
? error.message
|
|
785
|
+
: String(error);
|
|
768
786
|
return false;
|
|
769
787
|
}
|
|
770
788
|
}
|
package/dist/v2/adapter.js
CHANGED
|
@@ -4,6 +4,7 @@ import { SKILLS_TOOL_INPUT_SCHEMA } from "../core/skills-tool.js";
|
|
|
4
4
|
import { log } from "../services/logger.js";
|
|
5
5
|
import { utf8ByteLength } from "../utils/context-limit.js";
|
|
6
6
|
import { eventBelongsToLocation, legacyToolResult, toLegacyEvent } from "./legacy-client.js";
|
|
7
|
+
import { createRecallReporter } from "./recall-reporter.js";
|
|
7
8
|
const memoryInput = {
|
|
8
9
|
type: "object",
|
|
9
10
|
properties: {
|
|
@@ -75,7 +76,9 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
75
76
|
// until their message appears in a model request's history (the context
|
|
76
77
|
// hook), and are only then recorded for auto-capture and profile learning.
|
|
77
78
|
const pendingPrompts = new Map();
|
|
79
|
+
const recall = await createRecallReporter(ctx, memory, setBounded);
|
|
78
80
|
const forgetSession = (sessionID) => {
|
|
81
|
+
recall.forget(sessionID);
|
|
79
82
|
retrievals.delete(sessionID);
|
|
80
83
|
restored.delete(sessionID);
|
|
81
84
|
messageIDs.delete(sessionID);
|
|
@@ -186,7 +189,7 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
186
189
|
log("v2 prompt: memory retrieval failed", { sessionID, error: String(error) }, "error");
|
|
187
190
|
return null;
|
|
188
191
|
});
|
|
189
|
-
setBounded(retrievals, sessionID, { promise, budgetBytes });
|
|
192
|
+
setBounded(retrievals, sessionID, { promise, budgetBytes, query: text });
|
|
190
193
|
});
|
|
191
194
|
await ctx.session.hook("context", async (event) => {
|
|
192
195
|
const sessionID = event.sessionID;
|
|
@@ -201,6 +204,7 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
201
204
|
const section = await settledRetrieval(retrieval);
|
|
202
205
|
if (section) {
|
|
203
206
|
event.system.push({ type: "text", text: section });
|
|
207
|
+
recall.record(sessionID, retrieval, retrieval.query, section);
|
|
204
208
|
remainingBytes = retrieval.budgetBytes - utf8ByteLength(section);
|
|
205
209
|
}
|
|
206
210
|
else {
|
|
@@ -266,6 +270,7 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
266
270
|
restored.clear();
|
|
267
271
|
messageIDs.clear();
|
|
268
272
|
pendingPrompts.clear();
|
|
273
|
+
recall.clear();
|
|
269
274
|
await legacy.dispose?.();
|
|
270
275
|
};
|
|
271
276
|
}
|
package/dist/v2/legacy-client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { markInProcessClient } from "../adapters/opencode/in-process-client.js";
|
|
3
4
|
function textFromParts(parts = []) {
|
|
4
5
|
return parts
|
|
5
6
|
.filter((part) => part.type === "text")
|
|
@@ -216,7 +217,7 @@ export function createLegacyClient(ctx) {
|
|
|
216
217
|
},
|
|
217
218
|
},
|
|
218
219
|
};
|
|
219
|
-
return client;
|
|
220
|
+
return markInProcessClient(client);
|
|
220
221
|
}
|
|
221
222
|
export function legacyToolResult(value) {
|
|
222
223
|
if (typeof value === "string")
|
|
@@ -8,6 +8,8 @@ export interface V2MemoryBridge {
|
|
|
8
8
|
isConfigured(): boolean;
|
|
9
9
|
isInjectionEnabled(): boolean;
|
|
10
10
|
isCompactionEnabled(): boolean;
|
|
11
|
+
/** Effective `visibleRecall` setting for this project, from a fresh config read. */
|
|
12
|
+
isVisibleRecallEnabled(): boolean;
|
|
11
13
|
isInternalPrompt(sessionID: string, text: string): boolean;
|
|
12
14
|
recordPrompt(sessionID: string, messageID: string, text: string): Promise<void>;
|
|
13
15
|
/**
|
package/dist/v2/memory-bridge.js
CHANGED
|
@@ -11,6 +11,10 @@ export function createV2MemoryBridge(directory) {
|
|
|
11
11
|
isConfigured,
|
|
12
12
|
isInjectionEnabled: () => isConfigured() && Boolean(CONFIG.chatMessage.enabled),
|
|
13
13
|
isCompactionEnabled: () => isConfigured() && Boolean(CONFIG.compaction.enabled),
|
|
14
|
+
isVisibleRecallEnabled() {
|
|
15
|
+
refreshConfigIfChanged(directory);
|
|
16
|
+
return CONFIG.visibleRecall !== false;
|
|
17
|
+
},
|
|
14
18
|
isInternalPrompt,
|
|
15
19
|
async recordPrompt(sessionID, messageID, text) {
|
|
16
20
|
await recordUserPrompt(sessionID, messageID, directory, text);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Context } from "@opencode/plugin/promise/plugin";
|
|
2
|
+
import type { V2MemoryBridge } from "./memory-bridge.js";
|
|
3
|
+
/** Latest summaries kept per session. The TUI shows recent rows, so older ones are dropped. */
|
|
4
|
+
export declare const MAX_RECALL_SUMMARIES = 20;
|
|
5
|
+
/** Longest query kept per summary. The TUI shows at most 300 characters of it. */
|
|
6
|
+
export declare const MAX_RECALL_QUERY_CHARS = 300;
|
|
7
|
+
type Bound = <V>(map: Map<string, V>, key: string, value: V) => void;
|
|
8
|
+
export interface RecallReporter {
|
|
9
|
+
/**
|
|
10
|
+
* Record the summary of one prompt retrieval. `token` identifies the retrieval: model steps
|
|
11
|
+
* that reuse it record once. A section with no memory entries records nothing, and neither
|
|
12
|
+
* does a subagent session (one with a parent). The call does not wait for the session lookup.
|
|
13
|
+
*/
|
|
14
|
+
record(sessionID: string, token: object, query: string, section: string | null): void;
|
|
15
|
+
forget(sessionID: string): void;
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Keeps a bounded per-session ring of recall summaries and serves it over plugin RPC to the TUI
|
|
20
|
+
* entrypoint. It never throws and never touches what is injected. Logs carry the mode, count and
|
|
21
|
+
* session id only, never memory text or the query.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createRecallReporter(ctx: Context, memory: V2MemoryBridge, bound: Bound): Promise<RecallReporter>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { buildRecallSummary } from "../core/recall-summary.js";
|
|
2
|
+
import { log } from "../services/logger.js";
|
|
3
|
+
import { recallRpc } from "./recall-rpc.js";
|
|
4
|
+
/** Latest summaries kept per session. The TUI shows recent rows, so older ones are dropped. */
|
|
5
|
+
export const MAX_RECALL_SUMMARIES = 20;
|
|
6
|
+
/** Longest query kept per summary. The TUI shows at most 300 characters of it. */
|
|
7
|
+
export const MAX_RECALL_QUERY_CHARS = 300;
|
|
8
|
+
/**
|
|
9
|
+
* Keeps a bounded per-session ring of recall summaries and serves it over plugin RPC to the TUI
|
|
10
|
+
* entrypoint. It never throws and never touches what is injected. Logs carry the mode, count and
|
|
11
|
+
* session id only, never memory text or the query.
|
|
12
|
+
*/
|
|
13
|
+
export async function createRecallReporter(ctx, memory, bound) {
|
|
14
|
+
const rings = new Map();
|
|
15
|
+
// Last number given per session. It keeps rising when the ring drops old entries.
|
|
16
|
+
const sequences = new Map();
|
|
17
|
+
const recorded = new WeakSet();
|
|
18
|
+
// One lookup per session. The promise is cached so concurrent records keep their order.
|
|
19
|
+
const subagents = new Map();
|
|
20
|
+
// Records waiting on a session lookup. `forget` and `clear` cancel them, so a late lookup cannot
|
|
21
|
+
// store a summary for a session whose state is gone. Entries leave when their lookup settles.
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
let emit;
|
|
24
|
+
try {
|
|
25
|
+
const registration = await ctx.rpc.register(recallRpc, {
|
|
26
|
+
// The contract is plain JSON schema, so the host types the input as unknown.
|
|
27
|
+
list: async (input) => ({
|
|
28
|
+
enabled: memory.isVisibleRecallEnabled(),
|
|
29
|
+
items: [...(rings.get(input.sessionID) ?? [])],
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
emit = (sessionID) => registration.events.emit("recalled", { sessionID });
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
log("v2 recall: rpc registration failed", { error: String(error) }, "error");
|
|
36
|
+
}
|
|
37
|
+
const notify = (sessionID) => {
|
|
38
|
+
try {
|
|
39
|
+
emit?.(sessionID).catch((error) => log("v2 recall: push failed", { sessionID, error: String(error) }, "error"));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
log("v2 recall: push failed", { sessionID, error: String(error) }, "error");
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
// A session with a parent is a subagent session. A failed lookup counts as a root session, so
|
|
46
|
+
// the summary still shows.
|
|
47
|
+
const isSubagent = (sessionID) => {
|
|
48
|
+
const known = subagents.get(sessionID);
|
|
49
|
+
if (known)
|
|
50
|
+
return known;
|
|
51
|
+
const lookup = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const info = await ctx.session.get({ sessionID });
|
|
54
|
+
return Boolean(info?.parentID);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
log("v2 recall: session lookup failed", { sessionID, error: String(error) }, "error");
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
bound(subagents, sessionID, lookup);
|
|
62
|
+
return lookup;
|
|
63
|
+
};
|
|
64
|
+
const store = (sessionID, built) => {
|
|
65
|
+
const seq = (sequences.get(sessionID) ?? 0) + 1;
|
|
66
|
+
bound(sequences, sessionID, seq);
|
|
67
|
+
const summary = { ...built, seq };
|
|
68
|
+
const ring = [...(rings.get(sessionID) ?? []), summary].slice(-MAX_RECALL_SUMMARIES);
|
|
69
|
+
bound(rings, sessionID, ring);
|
|
70
|
+
log("v2 recall: summary recorded", {
|
|
71
|
+
sessionID,
|
|
72
|
+
mode: summary.mode,
|
|
73
|
+
count: summary.items.length,
|
|
74
|
+
});
|
|
75
|
+
notify(sessionID);
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
record(sessionID, token, query, section) {
|
|
79
|
+
if (recorded.has(token))
|
|
80
|
+
return;
|
|
81
|
+
recorded.add(token);
|
|
82
|
+
try {
|
|
83
|
+
const summary = section
|
|
84
|
+
? buildRecallSummary(section, "search", query.slice(0, MAX_RECALL_QUERY_CHARS))
|
|
85
|
+
: null;
|
|
86
|
+
if (!summary)
|
|
87
|
+
return;
|
|
88
|
+
const ticket = { cancelled: false };
|
|
89
|
+
const tickets = pending.get(sessionID) ?? new Set();
|
|
90
|
+
tickets.add(ticket);
|
|
91
|
+
pending.set(sessionID, tickets);
|
|
92
|
+
void isSubagent(sessionID).then((subagent) => {
|
|
93
|
+
tickets.delete(ticket);
|
|
94
|
+
if (tickets.size === 0 && pending.get(sessionID) === tickets)
|
|
95
|
+
pending.delete(sessionID);
|
|
96
|
+
if (subagent || ticket.cancelled)
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
store(sessionID, summary);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
log("v2 recall: could not record summary", { sessionID, error: String(error) }, "error");
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
log("v2 recall: could not record summary", { sessionID, error: String(error) }, "error");
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
forget: (sessionID) => {
|
|
111
|
+
for (const ticket of pending.get(sessionID) ?? [])
|
|
112
|
+
ticket.cancelled = true;
|
|
113
|
+
pending.delete(sessionID);
|
|
114
|
+
rings.delete(sessionID);
|
|
115
|
+
sequences.delete(sessionID);
|
|
116
|
+
subagents.delete(sessionID);
|
|
117
|
+
},
|
|
118
|
+
clear: () => {
|
|
119
|
+
for (const tickets of pending.values())
|
|
120
|
+
for (const ticket of tickets)
|
|
121
|
+
ticket.cancelled = true;
|
|
122
|
+
pending.clear();
|
|
123
|
+
rings.clear();
|
|
124
|
+
sequences.clear();
|
|
125
|
+
subagents.clear();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin RPC contract between the OpenCode v2 server entrypoint (records recall summaries) and the
|
|
3
|
+
* terminal TUI entrypoint (draws them). Plain data on purpose: no host SDK import, so both sides and
|
|
4
|
+
* the tests can load it. The TUI pulls `list` and treats `recalled` as a refresh hint only; a missing
|
|
5
|
+
* push never breaks the rows (TDR-060).
|
|
6
|
+
*/
|
|
7
|
+
export declare const RECALL_RPC_ID = "omms-recall";
|
|
8
|
+
export declare const recallRpc: {
|
|
9
|
+
readonly id: "omms-recall";
|
|
10
|
+
readonly methods: {
|
|
11
|
+
readonly list: {
|
|
12
|
+
readonly input: {
|
|
13
|
+
readonly type: "object";
|
|
14
|
+
readonly properties: {
|
|
15
|
+
readonly sessionID: {
|
|
16
|
+
readonly type: "string";
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
readonly required: readonly ["sessionID"];
|
|
20
|
+
};
|
|
21
|
+
readonly output: {
|
|
22
|
+
readonly type: "object";
|
|
23
|
+
readonly properties: {
|
|
24
|
+
readonly enabled: {
|
|
25
|
+
readonly type: "boolean";
|
|
26
|
+
};
|
|
27
|
+
readonly items: {
|
|
28
|
+
readonly type: "array";
|
|
29
|
+
readonly items: {
|
|
30
|
+
readonly type: "object";
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
readonly required: readonly ["enabled", "items"];
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
readonly events: {
|
|
39
|
+
readonly recalled: {
|
|
40
|
+
readonly schema: {
|
|
41
|
+
readonly type: "object";
|
|
42
|
+
readonly properties: {
|
|
43
|
+
readonly sessionID: {
|
|
44
|
+
readonly type: "string";
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
readonly required: readonly ["sessionID"];
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin RPC contract between the OpenCode v2 server entrypoint (records recall summaries) and the
|
|
3
|
+
* terminal TUI entrypoint (draws them). Plain data on purpose: no host SDK import, so both sides and
|
|
4
|
+
* the tests can load it. The TUI pulls `list` and treats `recalled` as a refresh hint only; a missing
|
|
5
|
+
* push never breaks the rows (TDR-060).
|
|
6
|
+
*/
|
|
7
|
+
export const RECALL_RPC_ID = "omms-recall";
|
|
8
|
+
export const recallRpc = {
|
|
9
|
+
id: RECALL_RPC_ID,
|
|
10
|
+
methods: {
|
|
11
|
+
// `enabled` is the effective `visibleRecall` setting for the server's project, so the TUI never
|
|
12
|
+
// reads config. Summaries are recorded and served even when it is false.
|
|
13
|
+
list: {
|
|
14
|
+
input: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: { sessionID: { type: "string" } },
|
|
17
|
+
required: ["sessionID"],
|
|
18
|
+
},
|
|
19
|
+
output: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
enabled: { type: "boolean" },
|
|
23
|
+
items: { type: "array", items: { type: "object" } },
|
|
24
|
+
},
|
|
25
|
+
required: ["enabled", "items"],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
events: {
|
|
30
|
+
recalled: {
|
|
31
|
+
schema: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: { sessionID: { type: "string" } },
|
|
34
|
+
required: ["sessionID"],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|