om-memory-system 5.3.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 (55) 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/tui-recall.js +13 -3
  5. package/dist/adapters/pi/extension.d.ts +1 -0
  6. package/dist/adapters/pi/extension.js +28 -11
  7. package/dist/adapters/pi/recall-rows.d.ts +31 -0
  8. package/dist/adapters/pi/recall-rows.js +148 -0
  9. package/dist/adapters/pi/session-context.d.ts +22 -0
  10. package/dist/adapters/pi/session-context.js +125 -0
  11. package/dist/config-template.d.ts +1 -1
  12. package/dist/config-template.js +18 -3
  13. package/dist/config.d.ts +6 -0
  14. package/dist/config.js +5 -4
  15. package/dist/core/context-budget.d.ts +2 -0
  16. package/dist/core/context-budget.js +2 -1
  17. package/dist/core/opening-context.d.ts +45 -0
  18. package/dist/core/opening-context.js +60 -0
  19. package/dist/core/recall-summary.d.ts +11 -7
  20. package/dist/core/recall-summary.js +14 -8
  21. package/dist/core/retrieval.d.ts +16 -0
  22. package/dist/core/retrieval.js +33 -11
  23. package/dist/importer/claude-hook-api.d.ts +5 -4
  24. package/dist/importer/claude-hook-api.js +58 -41
  25. package/dist/importer/claude-hook-context.d.ts +49 -0
  26. package/dist/importer/claude-hook-context.js +264 -0
  27. package/dist/services/ai/local-api-url.d.ts +2 -0
  28. package/dist/services/ai/local-api-url.js +12 -0
  29. package/dist/services/ai/provider-config.d.ts +2 -0
  30. package/dist/services/ai/provider-config.js +3 -1
  31. package/dist/services/ai/providers/google-gemini.js +2 -1
  32. package/dist/services/ai/providers/openai-responses.js +2 -1
  33. package/dist/services/chat/chat-runtime.js +5 -1
  34. package/dist/services/chat/chat-status.js +4 -1
  35. package/dist/services/global-config-writer.js +39 -14
  36. package/dist/services/injection-options-config.d.ts +53 -0
  37. package/dist/services/injection-options-config.js +56 -0
  38. package/dist/services/settings-snapshot.js +44 -12
  39. package/dist/services/visible-recall-config.d.ts +7 -2
  40. package/dist/services/visible-recall-config.js +2 -1
  41. package/dist/utils/memory-limits.d.ts +2 -1
  42. package/dist/utils/memory-limits.js +6 -3
  43. package/dist/v2/adapter.d.ts +2 -0
  44. package/dist/v2/adapter.js +107 -33
  45. package/dist/v2/memory-bridge.d.ts +13 -0
  46. package/dist/v2/memory-bridge.js +17 -0
  47. package/dist/v2/recall-reporter.d.ts +6 -3
  48. package/dist/v2/recall-reporter.js +4 -27
  49. package/dist/v2/session-parent.d.ts +14 -0
  50. package/dist/v2/session-parent.js +26 -0
  51. package/dist/web/assets/{index-CJ_K-hMG.js → index-BhQR88tU.js} +16 -16
  52. package/dist/web/assets/index-BmFUZbGr.css +2 -0
  53. package/dist/web/index.html +2 -2
  54. package/package.json +1 -1
  55. package/dist/web/assets/index-ccH97VE2.css +0 -2
@@ -8,6 +8,7 @@ import { isValidClaudeConfigDir } from "./claude-folder.js";
8
8
  import { missingSkillFolder } from "./skill-learning/skill-config.js";
9
9
  import { KEYWORD_MODES } from "../utils/keyword-boost.js";
10
10
  import { getMemoryLimitRule } from "../utils/memory-limits.js";
11
+ import { OPENING_CONTEXT_QUERY_MAX_LENGTH } from "./injection-options-config.js";
11
12
  import { RERANKER_TIMEOUT_MAX_MS, RERANKER_TIMEOUT_MIN_MS } from "./request-timeouts.js";
12
13
  const keys = new Set([
13
14
  "opencodeProvider",
@@ -47,6 +48,11 @@ const keys = new Set([
47
48
  "skillLearning",
48
49
  "skillLearningRedact",
49
50
  "skillFolders",
51
+ "openingContext",
52
+ "openingContextQuery",
53
+ "subagentMemory",
54
+ "compaction.enabled",
55
+ "compaction.memoryLimit",
50
56
  ]);
51
57
  function isFolder(path) {
52
58
  try {
@@ -83,10 +89,18 @@ export function isSecretReference(value) {
83
89
  /** Reranker keys that a null edit removes, the same wire form as a limit reset. */
84
90
  const OPTIONAL_RERANKER_KEYS = ["rerankerApiUrl", "rerankerModel", "rerankerApiKey"];
85
91
  /** Other keys whose `null` edit removes them, so they follow the default again. */
86
- const RESETTABLE_KEYS = ["keywordMode", "skillFolders"];
92
+ const RESETTABLE_KEYS = [
93
+ "keywordMode",
94
+ "skillFolders",
95
+ "openingContextQuery",
96
+ "openingContext",
97
+ "subagentMemory",
98
+ "compaction.enabled",
99
+ ];
100
+ const QUERY_RULE = `openingContextQuery must be text of ${OPENING_CONTEXT_QUERY_MAX_LENGTH} characters or fewer`;
87
101
  function isValidEdit(key, value) {
88
102
  // `undefined` removes the key from the file. `null` is the wire form of
89
- // "reset to default", accepted for the six memory limits and the optional reranker keys.
103
+ // "reset to default", accepted for the memory limits and the optional reranker keys.
90
104
  const memoryLimit = getMemoryLimitRule(key);
91
105
  if (memoryLimit && value === null)
92
106
  return true;
@@ -98,6 +112,11 @@ function isValidEdit(key, value) {
98
112
  // keywordMode: null removes the key, so the mode follows the default again.
99
113
  if (key === "keywordMode")
100
114
  return value === null || KEYWORD_MODES.includes(value);
115
+ // The opening context query: null removes it; an empty query means no opening search.
116
+ if (key === "openingContextQuery") {
117
+ return (value === null ||
118
+ (typeof value === "string" && value.trim().length <= OPENING_CONTEXT_QUERY_MAX_LENGTH));
119
+ }
101
120
  // The five opt-in reranker keys (openspec retrieval-threshold-and-reranker).
102
121
  if (key === "rerankerEnabled")
103
122
  return typeof value === "boolean";
@@ -133,8 +152,12 @@ function isValidEdit(key, value) {
133
152
  key === "webServerAutoStart" ||
134
153
  key === "userProfileAutoCleanupEnabled" ||
135
154
  key === "skillLearning" ||
136
- key === "skillLearningRedact") {
137
- return typeof value === "boolean";
155
+ key === "skillLearningRedact" ||
156
+ key === "openingContext" ||
157
+ key === "subagentMemory" ||
158
+ key === "compaction.enabled") {
159
+ // The three memory injection switches take null too: it removes the key (reset to default).
160
+ return typeof value === "boolean" || (value === null && RESETTABLE_KEYS.includes(key));
138
161
  }
139
162
  // Path rules are checked by the startup validation below; existence just after this check.
140
163
  if (key === "skillFolders") {
@@ -160,8 +183,8 @@ function isValidEdit(key, value) {
160
183
  // Entries are checked by the startup validation below.
161
184
  if (key === "importPathMaps" || key === "importIgnoredDirectories")
162
185
  return Array.isArray(value);
163
- // The five memory limits share one validation rule set (six with
164
- // similarityThreshold; null means reset, handled above).
186
+ // The memory limits share one validation rule set (similarityThreshold and
187
+ // compaction.memoryLimit included; null means reset, handled above).
165
188
  if (memoryLimit)
166
189
  return memoryLimit.isValid(value);
167
190
  // An empty value clears the field.
@@ -218,11 +241,13 @@ options = {}) {
218
241
  };
219
242
  throw new Error(["memoryApiKey", "embeddingApiKey", "webServerAuthPassword", "rerankerApiKey"].includes(key)
220
243
  ? `${key} must be an env:// or file:// reference`
221
- : memoryRule
222
- ? `Invalid ${key} setting: accepted ${memoryRule.accepted}`
223
- : rerankerAccepted[key]
224
- ? `Invalid ${key} setting: accepted ${rerankerAccepted[key]}`
225
- : `Invalid ${key} setting`);
244
+ : key === "openingContextQuery"
245
+ ? QUERY_RULE
246
+ : memoryRule
247
+ ? `Invalid ${key} setting: accepted ${memoryRule.accepted}`
248
+ : rerankerAccepted[key]
249
+ ? `Invalid ${key} setting: accepted ${rerankerAccepted[key]}`
250
+ : `Invalid ${key} setting`);
226
251
  }
227
252
  const missing = key === "skillFolders" && value !== null
228
253
  ? missingSkillFolder(value, isFolder)
@@ -246,9 +271,9 @@ options = {}) {
246
271
  }
247
272
  let content = original.content;
248
273
  for (const [key, value] of Object.entries(edits)) {
249
- // A nested limit identifier maps to its JSONC leaf path, so
250
- // chatMessage.maxMemories writes the leaf, never a literal dotted key.
251
- const jsoncPath = (getMemoryLimitRule(key)?.path ?? [key]);
274
+ // A nested identifier maps to its JSONC leaf path, so chatMessage.maxMemories
275
+ // and compaction.enabled write the leaf, never a literal dotted key.
276
+ const jsoncPath = (getMemoryLimitRule(key)?.path ?? key.split("."));
252
277
  // null is the wire form of "reset to default": remove the key so the
253
278
  // value follows the default again (undefined removes in jsonc modify).
254
279
  const jsoncValue = value === null &&
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The memory injection settings (change align-recall-host-parity): the opening
3
+ * context and subagent memory, on OpenCode V2, Pi and Claude Code. Kept out of
4
+ * `src/config.ts` to save its lines. Pure on purpose, like
5
+ * `visible-recall-config.ts`: callers pass the file config and a log function,
6
+ * and the loaded `CONFIG` is never imported here, because many tests stub
7
+ * `src/config.js`.
8
+ *
9
+ * Read the effective values from `CONFIG.openingContext`,
10
+ * `CONFIG.openingContextQuery` and `CONFIG.subagentMemory`.
11
+ */
12
+ /** The opening context search query when the config file sets none. */
13
+ export declare const DEFAULT_OPENING_CONTEXT_QUERY = "project architecture, setup, conventions and key decisions";
14
+ /** Most characters of `openingContextQuery`, after trimming. */
15
+ export declare const OPENING_CONTEXT_QUERY_MAX_LENGTH = 500;
16
+ /**
17
+ * The keys as they appear in a config file. `OmmsConfig` in `src/config.ts` has
18
+ * no fields for them, to save lines, so the functions take `FileConfigWithKeys`,
19
+ * which any config object satisfies. A hand-edited file can hold any value,
20
+ * which is why each key is checked for its type.
21
+ */
22
+ export interface InjectionOptionsFileKeys {
23
+ /** Search memory with `openingContextQuery` on the first message of a new session. Default false. A project can only turn it off. */
24
+ openingContext?: boolean;
25
+ /** The opening context search query. Global only. Trimmed, 500 characters at most. Empty means no search. */
26
+ openingContextQuery?: string;
27
+ /** Give subagent sessions the memories that match their task. Default true. A project can only turn it off. */
28
+ subagentMemory?: boolean;
29
+ }
30
+ type FileConfigWithKeys = InjectionOptionsFileKeys & object;
31
+ /**
32
+ * Read the settings from a merged file config. A value of the wrong type reads
33
+ * as the default. A query over the limit is cut, and `log` gets its length,
34
+ * never its text.
35
+ */
36
+ export declare function injectionOptionsFromFile(fileConfig: FileConfigWithKeys, log?: (message: string, data: {
37
+ length: number;
38
+ }) => void): {
39
+ openingContext: boolean;
40
+ openingContextQuery: string;
41
+ subagentMemory: boolean;
42
+ };
43
+ /**
44
+ * Apply the project rules to `overrides`, the copy of the project config that
45
+ * is merged over the global one. A project may turn either switch off, so a
46
+ * value of `false` stays. Any other value is dropped, and a value of `true` is
47
+ * reported through `log`. The query is global only, so a project query is
48
+ * always dropped and reported.
49
+ */
50
+ export declare function applyProjectInjectionOptions(project: FileConfigWithKeys, overrides: FileConfigWithKeys, directory: string, log: (message: string, data: {
51
+ directory: string;
52
+ }) => void): void;
53
+ export {};
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The memory injection settings (change align-recall-host-parity): the opening
3
+ * context and subagent memory, on OpenCode V2, Pi and Claude Code. Kept out of
4
+ * `src/config.ts` to save its lines. Pure on purpose, like
5
+ * `visible-recall-config.ts`: callers pass the file config and a log function,
6
+ * and the loaded `CONFIG` is never imported here, because many tests stub
7
+ * `src/config.js`.
8
+ *
9
+ * Read the effective values from `CONFIG.openingContext`,
10
+ * `CONFIG.openingContextQuery` and `CONFIG.subagentMemory`.
11
+ */
12
+ /** The opening context search query when the config file sets none. */
13
+ export const DEFAULT_OPENING_CONTEXT_QUERY = "project architecture, setup, conventions and key decisions";
14
+ /** Most characters of `openingContextQuery`, after trimming. */
15
+ export const OPENING_CONTEXT_QUERY_MAX_LENGTH = 500;
16
+ /**
17
+ * Read the settings from a merged file config. A value of the wrong type reads
18
+ * as the default. A query over the limit is cut, and `log` gets its length,
19
+ * never its text.
20
+ */
21
+ export function injectionOptionsFromFile(fileConfig, log) {
22
+ const raw = fileConfig.openingContextQuery;
23
+ let query = typeof raw === "string" ? raw.trim() : DEFAULT_OPENING_CONTEXT_QUERY;
24
+ if (query.length > OPENING_CONTEXT_QUERY_MAX_LENGTH) {
25
+ log?.("openingContextQuery is longer than 500 characters; only the first 500 are used", {
26
+ length: query.length,
27
+ });
28
+ query = query.slice(0, OPENING_CONTEXT_QUERY_MAX_LENGTH);
29
+ }
30
+ return {
31
+ openingContext: fileConfig.openingContext === true,
32
+ openingContextQuery: query,
33
+ subagentMemory: fileConfig.subagentMemory !== false,
34
+ };
35
+ }
36
+ /**
37
+ * Apply the project rules to `overrides`, the copy of the project config that
38
+ * is merged over the global one. A project may turn either switch off, so a
39
+ * value of `false` stays. Any other value is dropped, and a value of `true` is
40
+ * reported through `log`. The query is global only, so a project query is
41
+ * always dropped and reported.
42
+ */
43
+ export function applyProjectInjectionOptions(project, overrides, directory, log) {
44
+ for (const key of ["openingContext", "subagentMemory"]) {
45
+ if (project[key] === false)
46
+ continue;
47
+ delete overrides[key];
48
+ if (project[key] === true) {
49
+ log(`Project config cannot turn on ${key}; the value was ignored`, { directory });
50
+ }
51
+ }
52
+ delete overrides.openingContextQuery;
53
+ if (Object.hasOwn(project, "openingContextQuery")) {
54
+ log("Project config cannot set openingContextQuery; the value was ignored", { directory });
55
+ }
56
+ }
@@ -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.
@@ -5,7 +5,8 @@
5
5
  * because many tests stub `src/config.js`.
6
6
  *
7
7
  * Read the effective value from `CONFIG.visibleRecall`. It is on by default,
8
- * and only an explicit `false` turns it off.
8
+ * and only an explicit `false` turns it off. It applies to OpenCode, Pi and
9
+ * Claude Code.
9
10
  */
10
11
  /**
11
12
  * The key as it appears in a config file. `OmmsConfig` in `src/config.ts` has
@@ -14,7 +15,11 @@
14
15
  * which is why they compare with `=== false`.
15
16
  */
16
17
  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
+ /**
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
+ */
18
23
  visibleRecall?: boolean;
19
24
  }
20
25
  type FileConfigWithKey = VisibleRecallFileKey & object;
@@ -5,7 +5,8 @@
5
5
  * because many tests stub `src/config.js`.
6
6
  *
7
7
  * Read the effective value from `CONFIG.visibleRecall`. It is on by default,
8
- * and only an explicit `false` turns it off.
8
+ * and only an explicit `false` turns it off. It applies to OpenCode, Pi and
9
+ * Claude Code.
9
10
  */
10
11
  /** Read the setting from a merged file config. Unset means on. */
11
12
  export function visibleRecallFromFile(fileConfig) {
@@ -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>>;
@@ -5,6 +5,7 @@ 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
7
  import { createRecallReporter } from "./recall-reporter.js";
8
+ import { createSessionParents } from "./session-parent.js";
8
9
  const memoryInput = {
9
10
  type: "object",
10
11
  properties: {
@@ -41,14 +42,21 @@ const memoryInput = {
41
42
  };
42
43
  /** Per-session state is bounded so long-running servers cannot grow it without limit. */
43
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;
44
47
  /** Unadmitted prompts kept per session before the oldest is dropped. */
45
48
  const MAX_PENDING_PROMPTS = 16;
46
49
  /** Compaction is restored natively through the `compaction` hook, never via the V1 event path. */
47
50
  const COMPACTION_EVENT_TYPES = new Set(["session.compacted", "session.compaction.ended"]);
48
- 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) {
49
57
  map.delete(key);
50
58
  map.set(key, value);
51
- while (map.size > MAX_TRACKED_SESSIONS) {
59
+ while (map.size > limit) {
52
60
  const oldest = map.keys().next().value;
53
61
  if (oldest === undefined)
54
62
  break;
@@ -71,14 +79,23 @@ function deletedSessionID(event) {
71
79
  export async function registerV2Adapter(ctx, legacy, memory) {
72
80
  const retrievals = new Map();
73
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();
74
87
  const messageIDs = new Map();
75
88
  // The prompt hook runs before OpenCode admits a prompt, so prompts wait here
76
89
  // until their message appears in a model request's history (the context
77
90
  // hook), and are only then recorded for auto-capture and profile learning.
78
91
  const pendingPrompts = new Map();
79
- const recall = await createRecallReporter(ctx, memory, setBounded);
92
+ const parents = createSessionParents(ctx, setBounded);
93
+ const recall = await createRecallReporter(ctx, memory, setBounded, parents);
80
94
  const forgetSession = (sessionID) => {
81
95
  recall.forget(sessionID);
96
+ parents.forget(sessionID);
97
+ seen.delete(sessionID);
98
+ firstPending.delete(sessionID);
82
99
  retrievals.delete(sessionID);
83
100
  restored.delete(sessionID);
84
101
  messageIDs.delete(sessionID);
@@ -111,11 +128,47 @@ export async function registerV2Adapter(ctx, legacy, memory) {
111
128
  // reuses the same section, even if the search finishes after the timeout.
112
129
  const settledRetrieval = async (retrieval) => {
113
130
  if (!retrieval.settled) {
114
- const value = await withTimeout(retrieval.promise, memory.retrievalTimeoutMs, null);
131
+ const value = await withTimeout(retrieval.promise, memory.retrievalTimeoutMs, NO_SECTIONS);
115
132
  retrieval.settled ??= { value };
116
133
  }
117
134
  return retrieval.settled.value;
118
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
+ };
119
172
  // Each V1 tool becomes a direct V2 tool, like OpenCode's own read/grep/skill, not a
120
173
  // Code Mode catalog entry, so the model can always see and call it (as on V1 and Pi).
121
174
  const legacyTool = (name, input) => ({
@@ -181,15 +234,36 @@ export async function registerV2Adapter(ctx, legacy, memory) {
181
234
  ].slice(-MAX_PENDING_PROMPTS);
182
235
  setBounded(pendingPrompts, sessionID, pending);
183
236
  }
184
- 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())
185
251
  return;
186
252
  // Start the search now with the entry snapshot, so it overlaps prompt admission.
187
253
  // An edit during retrieval must not change this request's allowance.
188
- 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) => {
189
258
  log("v2 prompt: memory retrieval failed", { sessionID, error: String(error) }, "error");
190
- return null;
259
+ return NO_SECTIONS;
260
+ });
261
+ setBounded(retrievals, sessionID, {
262
+ promise,
263
+ budgetBytes,
264
+ query: text,
265
+ openingToken: {},
191
266
  });
192
- setBounded(retrievals, sessionID, { promise, budgetBytes, query: text });
193
267
  });
194
268
  await ctx.session.hook("context", async (event) => {
195
269
  const sessionID = event.sessionID;
@@ -197,25 +271,31 @@ export async function registerV2Adapter(ctx, legacy, memory) {
197
271
  const budgetBytes = retrieval?.budgetBytes ?? memory.snapshotRequestBudget();
198
272
  // Before chat.params, which stores the model on the recorded prompt row.
199
273
  await recordAdmittedPrompts(sessionID, event.messages);
200
- // 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
201
276
  // restored section is formatted here, within what the retrieval left.
202
277
  let remainingBytes = null;
203
278
  if (retrieval) {
204
- const section = await settledRetrieval(retrieval);
205
- if (section) {
206
- event.system.push({ type: "text", text: section });
207
- recall.record(sessionID, retrieval, retrieval.query, section);
208
- 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);
209
285
  }
210
- else {
211
- 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);
212
290
  }
213
291
  }
214
- const restoredMemories = restored.get(sessionID);
215
- if (restoredMemories) {
216
- const section = memory.formatRestoredSession(restoredMemories, remainingBytes ?? budgetBytes);
217
- if (section)
292
+ const restoredEntry = restored.get(sessionID);
293
+ if (restoredEntry) {
294
+ const section = memory.formatRestoredSession(restoredEntry.memories, remainingBytes ?? budgetBytes);
295
+ if (section) {
218
296
  event.system.push({ type: "text", text: section });
297
+ recall.record(sessionID, restoredEntry, "restore", null, section);
298
+ }
219
299
  }
220
300
  if (legacy["chat.params"]) {
221
301
  await legacy["chat.params"]({
@@ -226,21 +306,12 @@ export async function registerV2Adapter(ctx, legacy, memory) {
226
306
  });
227
307
  await ctx.session.hook("compaction", async (event) => {
228
308
  memory.snapshotRequestBudget();
309
+ const sessionID = event.sessionID;
310
+ setBounded(seen, sessionID, true, MAX_SEEN_SESSIONS);
311
+ firstPending.delete(sessionID);
229
312
  if (!memory.isCompactionEnabled())
230
313
  return;
231
- const sessionID = event.sessionID;
232
- try {
233
- // Load once here; the restored section is formatted when a request
234
- // emits it, against that request's captured budget.
235
- const memories = await memory.restoreSession(sessionID);
236
- if (memories)
237
- setBounded(restored, sessionID, memories);
238
- else
239
- restored.delete(sessionID);
240
- }
241
- catch (error) {
242
- log("v2 compaction: failed to load session memories", { sessionID, error: String(error) }, "error");
243
- }
314
+ await loadRestored(sessionID);
244
315
  });
245
316
  const controller = new AbortController();
246
317
  const watcher = (async () => {
@@ -270,6 +341,9 @@ export async function registerV2Adapter(ctx, legacy, memory) {
270
341
  restored.clear();
271
342
  messageIDs.clear();
272
343
  pendingPrompts.clear();
344
+ seen.clear();
345
+ firstPending.clear();
346
+ parents.clear();
273
347
  recall.clear();
274
348
  await legacy.dispose?.();
275
349
  };
@@ -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
@@ -10,6 +16,8 @@ export interface V2MemoryBridge {
10
16
  isCompactionEnabled(): boolean;
11
17
  /** Effective `visibleRecall` setting for this project, from a fresh config read. */
12
18
  isVisibleRecallEnabled(): boolean;
19
+ /** Effective `subagentMemory` setting, from the config the last budget snapshot read. */
20
+ isSubagentMemoryEnabled(): boolean;
13
21
  isInternalPrompt(sessionID: string, text: string): boolean;
14
22
  recordPrompt(sessionID: string, messageID: string, text: string): Promise<void>;
15
23
  /**
@@ -20,6 +28,11 @@ export interface V2MemoryBridge {
20
28
  snapshotRequestBudget(): number;
21
29
  /** Wrapped per-prompt retrieval section packed within `budgetBytes`, or null when nothing is relevant. */
22
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>;
23
36
  /** The session's own memories for restore after compaction, loaded once, or null. */
24
37
  restoreSession(sessionID: string): Promise<unknown[] | null>;
25
38
  /** Cached restored memories formatted within `budgetBytes`, or null when nothing fits. */