om-memory-system 5.2.0 → 5.4.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 (69) hide show
  1. package/README.md +4 -0
  2. package/dist/adapters/claude-code/hook-client.d.ts +1 -1
  3. package/dist/adapters/claude-code/hook-client.js +15 -2
  4. package/dist/adapters/opencode/in-process-client.d.ts +2 -0
  5. package/dist/adapters/opencode/in-process-client.js +16 -0
  6. package/dist/adapters/opencode/opencode-host-config.d.ts +3 -0
  7. package/dist/adapters/opencode/opencode-host-config.js +2 -0
  8. package/dist/adapters/opencode/tui-recall.d.ts +68 -0
  9. package/dist/adapters/opencode/tui-recall.js +312 -0
  10. package/dist/adapters/pi/extension.d.ts +1 -0
  11. package/dist/adapters/pi/extension.js +28 -11
  12. package/dist/adapters/pi/recall-rows.d.ts +31 -0
  13. package/dist/adapters/pi/recall-rows.js +148 -0
  14. package/dist/adapters/pi/session-context.d.ts +22 -0
  15. package/dist/adapters/pi/session-context.js +125 -0
  16. package/dist/config-template.d.ts +1 -1
  17. package/dist/config-template.js +20 -0
  18. package/dist/config.d.ts +8 -0
  19. package/dist/config.js +8 -4
  20. package/dist/core/context-budget.d.ts +2 -0
  21. package/dist/core/context-budget.js +2 -1
  22. package/dist/core/opening-context.d.ts +45 -0
  23. package/dist/core/opening-context.js +60 -0
  24. package/dist/core/recall-summary.d.ts +32 -0
  25. package/dist/core/recall-summary.js +77 -0
  26. package/dist/core/retrieval.d.ts +16 -0
  27. package/dist/core/retrieval.js +33 -11
  28. package/dist/importer/claude-hook-api.d.ts +5 -4
  29. package/dist/importer/claude-hook-api.js +58 -41
  30. package/dist/importer/claude-hook-context.d.ts +49 -0
  31. package/dist/importer/claude-hook-context.js +264 -0
  32. package/dist/index.js +1 -1
  33. package/dist/services/ai/local-api-url.d.ts +2 -0
  34. package/dist/services/ai/local-api-url.js +12 -0
  35. package/dist/services/ai/provider-config.d.ts +2 -0
  36. package/dist/services/ai/provider-config.js +3 -1
  37. package/dist/services/ai/providers/google-gemini.js +2 -1
  38. package/dist/services/ai/providers/openai-responses.js +2 -1
  39. package/dist/services/chat/chat-runtime.js +5 -1
  40. package/dist/services/chat/chat-status.js +4 -1
  41. package/dist/services/global-config-writer.js +39 -14
  42. package/dist/services/injection-options-config.d.ts +53 -0
  43. package/dist/services/injection-options-config.js +56 -0
  44. package/dist/services/settings-snapshot.js +44 -12
  45. package/dist/services/single-flight.d.ts +2 -0
  46. package/dist/services/single-flight.js +12 -0
  47. package/dist/services/visible-recall-config.d.ts +39 -0
  48. package/dist/services/visible-recall-config.js +28 -0
  49. package/dist/services/web-server.d.ts +3 -0
  50. package/dist/services/web-server.js +26 -8
  51. package/dist/utils/memory-limits.d.ts +2 -1
  52. package/dist/utils/memory-limits.js +6 -3
  53. package/dist/v2/adapter.d.ts +2 -0
  54. package/dist/v2/adapter.js +110 -31
  55. package/dist/v2/legacy-client.js +2 -1
  56. package/dist/v2/memory-bridge.d.ts +15 -0
  57. package/dist/v2/memory-bridge.js +21 -0
  58. package/dist/v2/recall-reporter.d.ts +27 -0
  59. package/dist/v2/recall-reporter.js +105 -0
  60. package/dist/v2/recall-rpc.d.ts +51 -0
  61. package/dist/v2/recall-rpc.js +38 -0
  62. package/dist/v2/session-parent.d.ts +14 -0
  63. package/dist/v2/session-parent.js +26 -0
  64. package/dist/web/assets/{index-CJ_K-hMG.js → index-BhQR88tU.js} +16 -16
  65. package/dist/web/assets/index-BmFUZbGr.css +2 -0
  66. package/dist/web/index.html +2 -2
  67. package/opencode/tui.tsx +22 -3
  68. package/package.json +1 -1
  69. package/dist/web/assets/index-ccH97VE2.css +0 -2
@@ -5,7 +5,8 @@ import { CONFIG, getGlobalConfigSourcePath } from "../config.js";
5
5
  import { hasUnexpiredApiToken } from "./api-tokens.js";
6
6
  import { resolveClaudeFolder } from "./claude-folder.js";
7
7
  import { readGlobalConfigRevision } from "./global-config-writer.js";
8
- import { getMemoryLimitRule, MEMORY_LIMIT_SETTINGS, } from "../utils/memory-limits.js";
8
+ import { injectionOptionsFromFile } from "./injection-options-config.js";
9
+ import { getMemoryLimitRule, MEMORY_LIMIT_SETTINGS } from "../utils/memory-limits.js";
9
10
  import { getAutoCaptureProviderStatus, isExternalModelReady, resolveClaudeCodeLiveModel, resolvePiLiveModel, } from "./ai/live-model-choice.js";
10
11
  const editable = [
11
12
  "opencodeProvider",
@@ -37,8 +38,20 @@ const editable = [
37
38
  "skillLearning",
38
39
  "skillLearningRedact",
39
40
  "skillFolders",
41
+ "openingContext",
42
+ "openingContextQuery",
43
+ "subagentMemory",
44
+ "compaction.enabled",
40
45
  ...MEMORY_LIMIT_SETTINGS,
41
46
  ];
47
+ /**
48
+ * The memory injection settings (change align-recall-host-parity). They are
49
+ * global-only on the page, so each shows the global file's value read with the
50
+ * runtime rules, never the runtime value, which can hold a project's.
51
+ */
52
+ const injectionKeys = ["openingContext", "openingContextQuery", "subagentMemory"];
53
+ /** `DEFAULTS.compaction.enabled` in src/config.ts. */
54
+ const COMPACTION_ENABLED_DEFAULT = true;
42
55
  /** Keys a project config cannot override, so the page always shows the global value. */
43
56
  const globalOnly = [
44
57
  "captureRetryRetentionHours",
@@ -77,14 +90,14 @@ function projectFile(directory) {
77
90
  .find((path) => existsSync(path));
78
91
  }
79
92
  /**
80
- * Snapshot entry for a nested limit (chatMessage.maxMemories). The runtime
81
- * merge is shallow, so a project chatMessage object replaces the global one:
82
- * a missing count means the default even when the global file sets a value,
83
- * and the source must not be inferred from dotted-key presence alone.
93
+ * Snapshot entry for a nested setting (chatMessage.maxMemories, compaction.*).
94
+ * The runtime merge is shallow, so a project chatMessage object replaces the
95
+ * global one: a missing count means the default even when the global file sets
96
+ * a value, and the source must not be inferred from dotted-key presence alone.
84
97
  */
85
- function nestedMemorySetting(rule, global, project) {
86
- const parent = rule.path[0];
87
- const leaf = rule.path[1];
98
+ function nestedSetting(path, defaultValue, global, project) {
99
+ const parent = path[0];
100
+ const leaf = path[1];
88
101
  const container = (source) => {
89
102
  const raw = source[parent];
90
103
  return raw && typeof raw === "object" && !Array.isArray(raw)
@@ -97,10 +110,10 @@ function nestedMemorySetting(rule, global, project) {
97
110
  const hasValue = effective !== undefined && Object.hasOwn(effective, leaf);
98
111
  const hasGlobal = globalContainer !== undefined && Object.hasOwn(globalContainer, leaf);
99
112
  return {
100
- value: hasValue ? effective[leaf] : rule.default,
113
+ value: hasValue ? effective[leaf] : defaultValue,
101
114
  source: hasValue ? (projectHasParent ? "project" : "global") : "default",
102
- globalValue: hasGlobal ? globalContainer[leaf] : rule.default,
103
- default: rule.default,
115
+ globalValue: hasGlobal ? globalContainer[leaf] : defaultValue,
116
+ default: defaultValue,
104
117
  // Whether the leaf exists in the global file: the Reset action needs it.
105
118
  hasGlobal,
106
119
  };
@@ -111,10 +124,29 @@ export function getSettingsSnapshot(directory) {
111
124
  // Dotted limit identifiers (chatMessage.maxMemories) are resolved by the
112
125
  // nested rule above, so the runtime lookup only handles flat keys.
113
126
  const runtime = CONFIG;
127
+ const injection = injectionOptionsFromFile(global);
128
+ const injectionDefaults = injectionOptionsFromFile({});
114
129
  const settings = Object.fromEntries(editable.map((key) => {
115
130
  const limitRule = getMemoryLimitRule(key);
116
131
  if (limitRule && limitRule.path.length > 1) {
117
- return [key, nestedMemorySetting(limitRule, global, project)];
132
+ return [key, nestedSetting(limitRule.path, limitRule.default, global, project)];
133
+ }
134
+ if (key === "compaction.enabled") {
135
+ return [key, nestedSetting(key.split("."), COMPACTION_ENABLED_DEFAULT, global, project)];
136
+ }
137
+ const injectionKey = injectionKeys.find((name) => name === key);
138
+ if (injectionKey) {
139
+ const hasGlobal = Object.hasOwn(global, key);
140
+ return [
141
+ key,
142
+ {
143
+ value: injection[injectionKey],
144
+ source: hasGlobal ? "global" : "default",
145
+ globalValue: injection[injectionKey],
146
+ default: injectionDefaults[injectionKey],
147
+ hasGlobal,
148
+ },
149
+ ];
118
150
  }
119
151
  const projectOverrides = Object.hasOwn(project, key) &&
120
152
  // A project may turn these off, never on.
@@ -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,39 @@
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. It applies to OpenCode, Pi and
9
+ * Claude Code.
10
+ */
11
+ /**
12
+ * The key as it appears in a config file. `OmmsConfig` in `src/config.ts` has
13
+ * no field for it, to save lines, so the functions take `FileConfigWithKey`,
14
+ * which any config object satisfies. A hand-edited file can hold any value,
15
+ * which is why they compare with `=== false`.
16
+ */
17
+ export interface VisibleRecallFileKey {
18
+ /**
19
+ * Draw a row in the OpenCode TUI, the Pi terminal and the Claude Code reply for each memory
20
+ * recall. On Claude Code the hook marks each injected section when rows are off, because the
21
+ * recall rows read only the injected text. Default true. A project can only turn it off.
22
+ */
23
+ visibleRecall?: boolean;
24
+ }
25
+ type FileConfigWithKey = VisibleRecallFileKey & object;
26
+ /** Read the setting from a merged file config. Unset means on. */
27
+ export declare function visibleRecallFromFile(fileConfig: FileConfigWithKey): {
28
+ visibleRecall: boolean;
29
+ };
30
+ /**
31
+ * Apply the project rule to `overrides`, the copy of the project config that
32
+ * is merged over the global one. A project may turn the rows off, so a value
33
+ * of `false` stays. Any other project value is dropped, so the global value
34
+ * decides. A project value of `true` is also reported through `log`.
35
+ */
36
+ export declare function applyProjectVisibleRecall(project: FileConfigWithKey, overrides: FileConfigWithKey, directory: string, log: (message: string, data: {
37
+ directory: string;
38
+ }) => void): void;
39
+ export {};
@@ -0,0 +1,28 @@
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. It applies to OpenCode, Pi and
9
+ * Claude Code.
10
+ */
11
+ /** Read the setting from a merged file config. Unset means on. */
12
+ export function visibleRecallFromFile(fileConfig) {
13
+ return { visibleRecall: fileConfig.visibleRecall !== false };
14
+ }
15
+ /**
16
+ * Apply the project rule to `overrides`, the copy of the project config that
17
+ * is merged over the global one. A project may turn the rows off, so a value
18
+ * of `false` stays. Any other project value is dropped, so the global value
19
+ * decides. A project value of `true` is also reported through `log`.
20
+ */
21
+ export function applyProjectVisibleRecall(project, overrides, directory, log) {
22
+ if (project.visibleRecall === false)
23
+ return;
24
+ delete overrides.visibleRecall;
25
+ if (project.visibleRecall === true) {
26
+ log("Project config cannot turn on visibleRecall; the value was ignored", { directory });
27
+ }
28
+ }
@@ -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
  }
@@ -21,8 +21,9 @@ export declare const MEMORY_LIMITS: {
21
21
  readonly userProfileMaxContextBytes: MemoryLimitRule;
22
22
  readonly retrievalMaxTokens: MemoryLimitRule;
23
23
  readonly similarityThreshold: MemoryLimitRule;
24
+ readonly "compaction.memoryLimit": MemoryLimitRule;
24
25
  };
25
- /** The six settings identifiers, in Memory card order. */
26
+ /** The settings identifiers: the six Memory card limits in card order, then the restore limit. */
26
27
  export declare const MEMORY_LIMIT_SETTINGS: readonly string[];
27
28
  export declare function getMemoryLimitRule(setting: string): MemoryLimitRule | undefined;
28
29
  /**
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Shared validation rules for the six memory-limit settings
3
- * (openspec change memory-context-controls). Pure functions only: values
2
+ * Shared validation rules for the six memory-limit settings of the Memory
3
+ * card (openspec change memory-context-controls), and the restore limit
4
+ * `compaction.memoryLimit`, which the Memory injection section edits
5
+ * (change align-recall-host-parity). Pure functions only: values
4
6
  * are passed in, so config.ts, the settings writer, and the settings
5
7
  * snapshot share one rule set without importing CONFIG. Do not import
6
8
  * CONFIG here: many tests stub src/config.js with a partial module.
@@ -26,8 +28,9 @@ export const MEMORY_LIMITS = {
26
28
  userProfileMaxContextBytes: rule("userProfileMaxContextBytes", ["userProfileMaxContextBytes"], 32768, "UTF-8 bytes", "integers from 1024 to 16777216", integerInRange(1024, 16777216)),
27
29
  retrievalMaxTokens: rule("retrievalMaxTokens", ["retrievalMaxTokens"], 2000, "approximate tokens", "integers from 256 to 65536", integerInRange(256, 65536)),
28
30
  similarityThreshold: rule("similarityThreshold", ["similarityThreshold"], 0.6, "similarity score", "numbers from 0 to 1", decimalFrom0To1, defaultSimilarityThreshold),
31
+ "compaction.memoryLimit": rule("compaction.memoryLimit", ["compaction", "memoryLimit"], 10, "memories", "positive safe integers", isPositiveSafeInteger),
29
32
  };
30
- /** The six settings identifiers, in Memory card order. */
33
+ /** The settings identifiers: the six Memory card limits in card order, then the restore limit. */
31
34
  export const MEMORY_LIMIT_SETTINGS = Object.keys(MEMORY_LIMITS);
32
35
  const RULES = MEMORY_LIMITS;
33
36
  export function getMemoryLimitRule(setting) {
@@ -2,4 +2,6 @@ import type { Context } from "@opencode/plugin/promise/plugin";
2
2
  import type { V2MemoryBridge } from "./memory-bridge.js";
3
3
  /** Per-session state is bounded so long-running servers cannot grow it without limit. */
4
4
  export declare const MAX_TRACKED_SESSIONS = 256;
5
+ /** Session ids remembered as already seen by this process, for the first-prompt rule. */
6
+ export declare const MAX_SEEN_SESSIONS = 500;
5
7
  export declare function registerV2Adapter(ctx: Context, legacy: any, memory: V2MemoryBridge): Promise<() => Promise<void>>;
@@ -4,6 +4,8 @@ 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";
8
+ import { createSessionParents } from "./session-parent.js";
7
9
  const memoryInput = {
8
10
  type: "object",
9
11
  properties: {
@@ -40,14 +42,21 @@ const memoryInput = {
40
42
  };
41
43
  /** Per-session state is bounded so long-running servers cannot grow it without limit. */
42
44
  export const MAX_TRACKED_SESSIONS = 256;
45
+ /** Session ids remembered as already seen by this process, for the first-prompt rule. */
46
+ export const MAX_SEEN_SESSIONS = 500;
43
47
  /** Unadmitted prompts kept per session before the oldest is dropped. */
44
48
  const MAX_PENDING_PROMPTS = 16;
45
49
  /** Compaction is restored natively through the `compaction` hook, never via the V1 event path. */
46
50
  const COMPACTION_EVENT_TYPES = new Set(["session.compacted", "session.compaction.ended"]);
47
- function setBounded(map, key, value) {
51
+ const NO_SECTIONS = {
52
+ promptSection: null,
53
+ openingSection: null,
54
+ openingQuery: null,
55
+ };
56
+ function setBounded(map, key, value, limit = MAX_TRACKED_SESSIONS) {
48
57
  map.delete(key);
49
58
  map.set(key, value);
50
- while (map.size > MAX_TRACKED_SESSIONS) {
59
+ while (map.size > limit) {
51
60
  const oldest = map.keys().next().value;
52
61
  if (oldest === undefined)
53
62
  break;
@@ -70,12 +79,23 @@ function deletedSessionID(event) {
70
79
  export async function registerV2Adapter(ctx, legacy, memory) {
71
80
  const retrievals = new Map();
72
81
  const restored = new Map();
82
+ // Sessions this process has seen a prompt or compaction for. The first prompt of a session not
83
+ // in it is a first prompt (new session) or a resume, decided in the context hook.
84
+ const seen = new Map();
85
+ // Per session whose first request is still to come: ids of the prompts this process admitted.
86
+ const firstPending = new Map();
73
87
  const messageIDs = new Map();
74
88
  // The prompt hook runs before OpenCode admits a prompt, so prompts wait here
75
89
  // until their message appears in a model request's history (the context
76
90
  // hook), and are only then recorded for auto-capture and profile learning.
77
91
  const pendingPrompts = new Map();
92
+ const parents = createSessionParents(ctx, setBounded);
93
+ const recall = await createRecallReporter(ctx, memory, setBounded, parents);
78
94
  const forgetSession = (sessionID) => {
95
+ recall.forget(sessionID);
96
+ parents.forget(sessionID);
97
+ seen.delete(sessionID);
98
+ firstPending.delete(sessionID);
79
99
  retrievals.delete(sessionID);
80
100
  restored.delete(sessionID);
81
101
  messageIDs.delete(sessionID);
@@ -108,11 +128,47 @@ export async function registerV2Adapter(ctx, legacy, memory) {
108
128
  // reuses the same section, even if the search finishes after the timeout.
109
129
  const settledRetrieval = async (retrieval) => {
110
130
  if (!retrieval.settled) {
111
- const value = await withTimeout(retrieval.promise, memory.retrievalTimeoutMs, null);
131
+ const value = await withTimeout(retrieval.promise, memory.retrievalTimeoutMs, NO_SECTIONS);
112
132
  retrieval.settled ??= { value };
113
133
  }
114
134
  return retrieval.settled.value;
115
135
  };
136
+ // Load the session's own memories once; the restored section is formatted when a request
137
+ // emits it, against that request's captured budget. Each load gets a new object, which keys
138
+ // its one row.
139
+ const loadRestored = async (sessionID, timeoutMs) => {
140
+ try {
141
+ const loading = memory.restoreSession(sessionID);
142
+ const memories = await (timeoutMs === undefined
143
+ ? loading
144
+ : withTimeout(loading, timeoutMs, null));
145
+ if (memories)
146
+ setBounded(restored, sessionID, { memories });
147
+ else
148
+ restored.delete(sessionID);
149
+ }
150
+ catch (error) {
151
+ log("v2 restore: failed to load session memories", { sessionID, error: String(error) }, "error");
152
+ }
153
+ };
154
+ // The first request of a session's first prompt in this process decides new or resumed:
155
+ // an earlier user message in the history means resumed.
156
+ const decideFirstRequest = async (sessionID, messages, retrieval) => {
157
+ const ours = firstPending.get(sessionID);
158
+ if (!firstPending.delete(sessionID))
159
+ return;
160
+ // History is a user message with an id that none of this process's prompts own.
161
+ const earlier = messages.some((message) => message.role === "user" && typeof message.id === "string" && !ours?.has(message.id));
162
+ if (!earlier)
163
+ return;
164
+ if (retrieval)
165
+ retrieval.resumed = true;
166
+ if (!memory.isCompactionEnabled())
167
+ return;
168
+ const subagent = await withTimeout(parents.isSubagent(sessionID), memory.retrievalTimeoutMs, false);
169
+ if (!subagent)
170
+ await loadRestored(sessionID, memory.retrievalTimeoutMs);
171
+ };
116
172
  // Each V1 tool becomes a direct V2 tool, like OpenCode's own read/grep/skill, not a
117
173
  // Code Mode catalog entry, so the model can always see and call it (as on V1 and Pi).
118
174
  const legacyTool = (name, input) => ({
@@ -178,15 +234,36 @@ export async function registerV2Adapter(ctx, legacy, memory) {
178
234
  ].slice(-MAX_PENDING_PROMPTS);
179
235
  setBounded(pendingPrompts, sessionID, pending);
180
236
  }
181
- if (!memory.isInjectionEnabled() || !text.trim())
237
+ if (!text.trim())
238
+ return;
239
+ const first = !seen.has(sessionID);
240
+ if (first) {
241
+ setBounded(seen, sessionID, true, MAX_SEEN_SESSIONS);
242
+ setBounded(firstPending, sessionID, new Set());
243
+ }
244
+ firstPending.get(sessionID)?.add(event.messageID);
245
+ if (!memory.isInjectionEnabled())
246
+ return;
247
+ // A subagent session gets the prompt recall only when `subagentMemory` is on, and never the
248
+ // opening section. A failed or slow lookup counts as a root session.
249
+ const subagent = await withTimeout(parents.isSubagent(sessionID), memory.retrievalTimeoutMs, false);
250
+ if (subagent && !memory.isSubagentMemoryEnabled())
182
251
  return;
183
252
  // Start the search now with the entry snapshot, so it overlaps prompt admission.
184
253
  // An edit during retrieval must not change this request's allowance.
185
- const promise = memory.retrieve(text, sessionID, budgetBytes).catch((error) => {
254
+ const search = async () => first && !subagent
255
+ ? memory.retrieveFirstPrompt(text, sessionID, budgetBytes)
256
+ : { ...NO_SECTIONS, promptSection: await memory.retrieve(text, sessionID, budgetBytes) };
257
+ const promise = search().catch((error) => {
186
258
  log("v2 prompt: memory retrieval failed", { sessionID, error: String(error) }, "error");
187
- return null;
259
+ return NO_SECTIONS;
260
+ });
261
+ setBounded(retrievals, sessionID, {
262
+ promise,
263
+ budgetBytes,
264
+ query: text,
265
+ openingToken: {},
188
266
  });
189
- setBounded(retrievals, sessionID, { promise, budgetBytes });
190
267
  });
191
268
  await ctx.session.hook("context", async (event) => {
192
269
  const sessionID = event.sessionID;
@@ -194,24 +271,31 @@ export async function registerV2Adapter(ctx, legacy, memory) {
194
271
  const budgetBytes = retrieval?.budgetBytes ?? memory.snapshotRequestBudget();
195
272
  // Before chat.params, which stores the model on the recorded prompt row.
196
273
  await recordAdmittedPrompts(sessionID, event.messages);
197
- // The retrieval and the restored section share one request budget: the
274
+ await decideFirstRequest(sessionID, event.messages, retrieval);
275
+ // The retrieval sections and the restored section share one request budget: the
198
276
  // restored section is formatted here, within what the retrieval left.
199
277
  let remainingBytes = null;
200
278
  if (retrieval) {
201
- const section = await settledRetrieval(retrieval);
202
- if (section) {
203
- event.system.push({ type: "text", text: section });
204
- remainingBytes = retrieval.budgetBytes - utf8ByteLength(section);
279
+ const { promptSection, openingSection, openingQuery } = await settledRetrieval(retrieval);
280
+ remainingBytes = retrieval.budgetBytes;
281
+ if (promptSection) {
282
+ event.system.push({ type: "text", text: promptSection });
283
+ recall.record(sessionID, retrieval, "search", retrieval.query, promptSection);
284
+ remainingBytes -= utf8ByteLength(promptSection);
205
285
  }
206
- else {
207
- remainingBytes = retrieval.budgetBytes;
286
+ if (openingSection && !retrieval.resumed) {
287
+ event.system.push({ type: "text", text: openingSection });
288
+ recall.record(sessionID, retrieval.openingToken, "opening", openingQuery, openingSection);
289
+ remainingBytes -= utf8ByteLength(openingSection);
208
290
  }
209
291
  }
210
- const restoredMemories = restored.get(sessionID);
211
- if (restoredMemories) {
212
- const section = memory.formatRestoredSession(restoredMemories, remainingBytes ?? budgetBytes);
213
- if (section)
292
+ const restoredEntry = restored.get(sessionID);
293
+ if (restoredEntry) {
294
+ const section = memory.formatRestoredSession(restoredEntry.memories, remainingBytes ?? budgetBytes);
295
+ if (section) {
214
296
  event.system.push({ type: "text", text: section });
297
+ recall.record(sessionID, restoredEntry, "restore", null, section);
298
+ }
215
299
  }
216
300
  if (legacy["chat.params"]) {
217
301
  await legacy["chat.params"]({
@@ -222,21 +306,12 @@ export async function registerV2Adapter(ctx, legacy, memory) {
222
306
  });
223
307
  await ctx.session.hook("compaction", async (event) => {
224
308
  memory.snapshotRequestBudget();
309
+ const sessionID = event.sessionID;
310
+ setBounded(seen, sessionID, true, MAX_SEEN_SESSIONS);
311
+ firstPending.delete(sessionID);
225
312
  if (!memory.isCompactionEnabled())
226
313
  return;
227
- const sessionID = event.sessionID;
228
- try {
229
- // Load once here; the restored section is formatted when a request
230
- // emits it, against that request's captured budget.
231
- const memories = await memory.restoreSession(sessionID);
232
- if (memories)
233
- setBounded(restored, sessionID, memories);
234
- else
235
- restored.delete(sessionID);
236
- }
237
- catch (error) {
238
- log("v2 compaction: failed to load session memories", { sessionID, error: String(error) }, "error");
239
- }
314
+ await loadRestored(sessionID);
240
315
  });
241
316
  const controller = new AbortController();
242
317
  const watcher = (async () => {
@@ -266,6 +341,10 @@ export async function registerV2Adapter(ctx, legacy, memory) {
266
341
  restored.clear();
267
342
  messageIDs.clear();
268
343
  pendingPrompts.clear();
344
+ seen.clear();
345
+ firstPending.clear();
346
+ parents.clear();
347
+ recall.clear();
269
348
  await legacy.dispose?.();
270
349
  };
271
350
  }
@@ -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")
@@ -1,3 +1,9 @@
1
+ /** The sections of a session's first prompt. `openingQuery` is the query the opening search used. */
2
+ export interface FirstPromptSections {
3
+ promptSection: string | null;
4
+ openingSection: string | null;
5
+ openingQuery: string | null;
6
+ }
1
7
  /**
2
8
  * The memory operations the native OpenCode v2 hooks need, bound to the
3
9
  * plugin's location. Kept separate from the adapter so hook wiring can be
@@ -8,6 +14,10 @@ export interface V2MemoryBridge {
8
14
  isConfigured(): boolean;
9
15
  isInjectionEnabled(): boolean;
10
16
  isCompactionEnabled(): boolean;
17
+ /** Effective `visibleRecall` setting for this project, from a fresh config read. */
18
+ isVisibleRecallEnabled(): boolean;
19
+ /** Effective `subagentMemory` setting, from the config the last budget snapshot read. */
20
+ isSubagentMemoryEnabled(): boolean;
11
21
  isInternalPrompt(sessionID: string, text: string): boolean;
12
22
  recordPrompt(sessionID: string, messageID: string, text: string): Promise<void>;
13
23
  /**
@@ -18,6 +28,11 @@ export interface V2MemoryBridge {
18
28
  snapshotRequestBudget(): number;
19
29
  /** Wrapped per-prompt retrieval section packed within `budgetBytes`, or null when nothing is relevant. */
20
30
  retrieve(prompt: string, sessionID: string, budgetBytes: number): Promise<string | null>;
31
+ /**
32
+ * The first prompt of a main session: the prompt section plus the opening section when the
33
+ * option is on. The prompt section equals what `retrieve` returns for the same budget.
34
+ */
35
+ retrieveFirstPrompt(prompt: string, sessionID: string, budgetBytes: number): Promise<FirstPromptSections>;
21
36
  /** The session's own memories for restore after compaction, loaded once, or null. */
22
37
  restoreSession(sessionID: string): Promise<unknown[] | null>;
23
38
  /** Cached restored memories formatted within `budgetBytes`, or null when nothing fits. */
@@ -1,6 +1,7 @@
1
1
  import { CONFIG, isConfigured, refreshConfigIfChanged } from "../config.js";
2
2
  import { buildRetrievalSection, formatMemoriesForCompaction, retrievalWrapperBytes, wrapRetrievalSection, } from "../core/retrieval.js";
3
3
  import { DEFAULT_RETRIEVAL_MAX_TOKENS, tokensToByteCeiling } from "../core/context-budget.js";
4
+ import { buildFirstPromptSections, openingQueryFor } from "../core/opening-context.js";
4
5
  import { isInternalPrompt, recordUserPrompt } from "../adapters/opencode/user-prompt.js";
5
6
  import { memoryClient } from "../services/client.js";
6
7
  import { V2_RETRIEVAL_TIMEOUT_MS } from "../services/request-timeouts.js";
@@ -11,6 +12,11 @@ export function createV2MemoryBridge(directory) {
11
12
  isConfigured,
12
13
  isInjectionEnabled: () => isConfigured() && Boolean(CONFIG.chatMessage.enabled),
13
14
  isCompactionEnabled: () => isConfigured() && Boolean(CONFIG.compaction.enabled),
15
+ isVisibleRecallEnabled() {
16
+ refreshConfigIfChanged(directory);
17
+ return CONFIG.visibleRecall !== false;
18
+ },
19
+ isSubagentMemoryEnabled: () => CONFIG.subagentMemory !== false,
14
20
  isInternalPrompt,
15
21
  async recordPrompt(sessionID, messageID, text) {
16
22
  await recordUserPrompt(sessionID, messageID, directory, text);
@@ -28,6 +34,21 @@ export function createV2MemoryBridge(directory) {
28
34
  });
29
35
  return section ? wrapRetrievalSection(section) : null;
30
36
  },
37
+ async retrieveFirstPrompt(prompt, sessionID, budgetBytes) {
38
+ const openingQuery = openingQueryFor({
39
+ openingContext: CONFIG.openingContext,
40
+ openingContextQuery: CONFIG.openingContextQuery,
41
+ });
42
+ const sections = await buildFirstPromptSections({
43
+ prompt,
44
+ openingQuery,
45
+ directory,
46
+ sessionId: sessionID,
47
+ budget: { maxBytes: budgetBytes, wrapperBytes: retrievalWrapperBytes() },
48
+ openingReserveBytes: 0,
49
+ });
50
+ return { ...sections, openingQuery };
51
+ },
31
52
  async restoreSession(sessionID) {
32
53
  const tags = getTags(directory);
33
54
  const result = await memoryClient.searchMemoriesBySessionID(sessionID, tags.project.tag, CONFIG.compaction.memoryLimit);