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.
Files changed (36) hide show
  1. package/dist/adapters/opencode/in-process-client.d.ts +2 -0
  2. package/dist/adapters/opencode/in-process-client.js +16 -0
  3. package/dist/adapters/opencode/opencode-host-config.d.ts +3 -0
  4. package/dist/adapters/opencode/opencode-host-config.js +2 -0
  5. package/dist/adapters/opencode/tui-recall.d.ts +68 -0
  6. package/dist/adapters/opencode/tui-recall.js +302 -0
  7. package/dist/config-template.d.ts +1 -1
  8. package/dist/config-template.js +5 -0
  9. package/dist/config.d.ts +2 -0
  10. package/dist/config.js +3 -0
  11. package/dist/core/recall-summary.d.ts +28 -0
  12. package/dist/core/recall-summary.js +71 -0
  13. package/dist/index.js +1 -1
  14. package/dist/services/single-flight.d.ts +2 -0
  15. package/dist/services/single-flight.js +12 -0
  16. package/dist/services/visible-recall-config.d.ts +34 -0
  17. package/dist/services/visible-recall-config.js +27 -0
  18. package/dist/services/web-ensure.d.ts +11 -0
  19. package/dist/services/web-ensure.js +33 -4
  20. package/dist/services/web-server.d.ts +3 -0
  21. package/dist/services/web-server.js +26 -8
  22. package/dist/v2/adapter.js +6 -1
  23. package/dist/v2/legacy-client.js +2 -1
  24. package/dist/v2/memory-bridge.d.ts +2 -0
  25. package/dist/v2/memory-bridge.js +4 -0
  26. package/dist/v2/recall-reporter.d.ts +24 -0
  27. package/dist/v2/recall-reporter.js +128 -0
  28. package/dist/v2/recall-rpc.d.ts +51 -0
  29. package/dist/v2/recall-rpc.js +38 -0
  30. package/dist/web/assets/index-CJ_K-hMG.js +170 -0
  31. package/dist/web/assets/index-ccH97VE2.css +2 -0
  32. package/dist/web/index.html +2 -2
  33. package/opencode/tui.tsx +22 -3
  34. package/package.json +1 -1
  35. package/dist/web/assets/index-D6UWwxhU.css +0 -2
  36. 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,2 @@
1
+ /** Like setInterval, but skips a tick while the previous run of the task is still pending. */
2
+ export declare function setSingleFlightInterval(task: () => Promise<void>, ms: number): NodeJS.Timeout;
@@ -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
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
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 = setInterval(() => void this.waiterTick(), 5000);
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(2000),
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
- if (endpoint === "/api/health") {
763
- return body.success === true && body.status === "ok";
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
  }
@@ -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
  }
@@ -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
  /**
@@ -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
+ };