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
@@ -0,0 +1,264 @@
1
+ import { open, readFile, stat } from "node:fs/promises";
2
+ import { CONFIG } from "../config.js";
3
+ import { buildFirstPromptSections, openingQueryFor } from "../core/opening-context.js";
4
+ import { RETRIEVAL_SECTION_TAG, buildRetrievalSection, retrievalWrapperBytes, wrapRetrievalSection, } from "../core/retrieval.js";
5
+ import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js";
6
+ import { utf8ByteLength } from "../utils/context-limit.js";
7
+ import { parseClaudeTranscript } from "./claude-conversation.js";
8
+ // What the Claude Code hooks add beyond a plain retrieval section (change
9
+ // align-recall-host-parity): the rows-off marker, the opening section for the
10
+ // first prompt of a session, and the task memories for a subagent. The hook
11
+ // reaches the recall rows only through the injected text, so the markers are
12
+ // HTML comments on the line after a section's opening tag. They hold no memory
13
+ // text. Logs here carry sizes and codes, never text.
14
+ /** Tells the recall rows to draw nothing for this section. 21 bytes, newline included. */
15
+ export const ROWS_OFF_MARKER = "<!--omms:rows-off-->\n";
16
+ /** Between the prompt's section and the opening section. */
17
+ const SECTION_SEPARATOR = "\n";
18
+ const OPENING_QUERY_MAX = 300;
19
+ /** Sessions this process remembers, so the transcript check runs once per session. */
20
+ const SEEN_SESSIONS_MAX = 500;
21
+ /** A transcript this large already holds earlier prompts, or is too big to scan. */
22
+ const FIRST_PROMPT_SCAN_MAX_BYTES = 1024 * 1024;
23
+ const SUBAGENT_TAIL_BYTES = 1024 * 1024;
24
+ /** Read `visibleRecall` once per request, after the config refresh. */
25
+ export function sectionLimits(budgetBytes) {
26
+ return { budgetBytes, rowsOff: CONFIG.visibleRecall === false };
27
+ }
28
+ /** The budget for one packed section: the retrieval tag, and the rows-off marker when set, are reserved. */
29
+ export function sectionBudget(limits) {
30
+ const marker = limits.rowsOff ? utf8ByteLength(ROWS_OFF_MARKER) : 0;
31
+ return { maxBytes: limits.budgetBytes, wrapperBytes: retrievalWrapperBytes() + marker };
32
+ }
33
+ /** Put a marker line right after the opening tag of a wrapped section. */
34
+ export function insertMarker(wrapped, marker) {
35
+ const head = `<${RETRIEVAL_SECTION_TAG}>\n`;
36
+ return wrapped.startsWith(head) ? head + marker + wrapped.slice(head.length) : wrapped;
37
+ }
38
+ /** Wrap a packed section, with the rows-off marker when rows are off. */
39
+ export function wrapSection(section, limits) {
40
+ const wrapped = wrapRetrievalSection(section);
41
+ return limits.rowsOff ? insertMarker(wrapped, ROWS_OFF_MARKER) : wrapped;
42
+ }
43
+ /**
44
+ * The opening marker. The query goes on one line, with `"`, `<` and `>`
45
+ * removed, `--` folded to `-` and 300 characters at most, so it cannot end the
46
+ * comment early.
47
+ */
48
+ export function openingMarker(query) {
49
+ const line = query
50
+ .replace(/["<>]/g, "")
51
+ .replace(/-{2,}/g, "-")
52
+ .replace(/\s+/g, " ")
53
+ .trim()
54
+ .slice(0, OPENING_QUERY_MAX)
55
+ // Do not leave half of a surrogate pair at the cut.
56
+ .replace(/[\uD800-\uDBFF]$/, "");
57
+ return `<!--omms:opening query="${line}"-->\n`;
58
+ }
59
+ // --- First prompt of a session ---
60
+ const seenSessions = new Set();
61
+ /** Remember a session, so a later prompt in this process is never first. Oldest ids drop out. */
62
+ export function markSessionSeen(sessionId) {
63
+ seenSessions.delete(sessionId);
64
+ seenSessions.add(sessionId);
65
+ if (seenSessions.size > SEEN_SESSIONS_MAX) {
66
+ seenSessions.delete(seenSessions.values().next().value);
67
+ }
68
+ }
69
+ // User entries that Claude Code writes itself (command output, shell mode,
70
+ // background task notices) are not prompts.
71
+ const NOT_PROMPT_PREFIXES = [
72
+ "<local-command-",
73
+ "<command-name>",
74
+ "<command-message>",
75
+ "<bash-",
76
+ "<task-notification>",
77
+ ];
78
+ function contentBlocks(entry) {
79
+ const content = entry.message?.content;
80
+ return Array.isArray(content)
81
+ ? content.filter((block) => block && typeof block === "object")
82
+ : [];
83
+ }
84
+ /** The text of a prompt the user typed, or null for any other transcript entry. */
85
+ function userPromptText(entry) {
86
+ if (entry.type !== "user" || entry.isMeta === true || entry.isSidechain === true)
87
+ return null;
88
+ const content = entry.message?.content;
89
+ let text;
90
+ if (typeof content === "string")
91
+ text = content;
92
+ else {
93
+ const blocks = contentBlocks(entry);
94
+ if (blocks.some((block) => block.type === "tool_result"))
95
+ return null;
96
+ text = blocks.map((block) => (block.type === "text" ? (block.text ?? "") : "")).join("\n");
97
+ }
98
+ text = text.trim();
99
+ return text && !NOT_PROMPT_PREFIXES.some((prefix) => text.startsWith(prefix)) ? text : null;
100
+ }
101
+ /**
102
+ * True when the transcript holds no user prompt before `prompt`. The host may
103
+ * or may not have written the current prompt yet, so a last prompt with the same
104
+ * text is not counted. A missing file is a session with no history. Any other
105
+ * problem counts as not first, so the opening section is skipped.
106
+ */
107
+ async function hasNoEarlierPrompt(transcriptPath, prompt) {
108
+ let text;
109
+ try {
110
+ if ((await stat(transcriptPath)).size > FIRST_PROMPT_SCAN_MAX_BYTES)
111
+ return false;
112
+ text = await readFile(transcriptPath, "utf8");
113
+ }
114
+ catch (error) {
115
+ return error?.code === "ENOENT";
116
+ }
117
+ const earlier = parseClaudeTranscript(text)
118
+ .entries.map(userPromptText)
119
+ .filter((entry) => entry !== null);
120
+ if (earlier.at(-1) === prompt.trim())
121
+ earlier.pop();
122
+ return earlier.length === 0;
123
+ }
124
+ /**
125
+ * The first prompt of a session, as far as this process can tell: the session
126
+ * is new to this process and its transcript holds no earlier prompt. Marks the
127
+ * session seen. A missing or invalid transcript path counts as not first.
128
+ */
129
+ async function isFirstPrompt(sessionId, transcriptPath, prompt) {
130
+ if (seenSessions.has(sessionId))
131
+ return false;
132
+ const first = transcriptPath ? await hasNoEarlierPrompt(transcriptPath, prompt) : false;
133
+ markSessionSeen(sessionId);
134
+ return first;
135
+ }
136
+ /**
137
+ * The added context for a prompt: its retrieval section, and on the first
138
+ * prompt of a session, with `openingContext` on, the opening section after it.
139
+ * Empty string when there is nothing to add.
140
+ */
141
+ export async function promptContext(input, limits) {
142
+ const prompt = stripPrivateContent(input.prompt).trim();
143
+ if (!CONFIG.chatMessage.enabled || !prompt || isFullyPrivate(input.prompt))
144
+ return "";
145
+ const budget = sectionBudget(limits);
146
+ const openingQuery = openingQueryFor(CONFIG);
147
+ const first = openingQuery !== null &&
148
+ (await isFirstPrompt(input.sessionId, input.transcriptPath, input.prompt));
149
+ if (!first) {
150
+ const section = await buildRetrievalSection(prompt, input.cwd, input.sessionId, budget);
151
+ return section ? wrapSection(section, limits) : "";
152
+ }
153
+ const marker = limits.rowsOff ? ROWS_OFF_MARKER : openingMarker(openingQuery);
154
+ const { promptSection, openingSection } = await buildFirstPromptSections({
155
+ prompt,
156
+ openingQuery,
157
+ directory: input.cwd,
158
+ sessionId: input.sessionId,
159
+ budget,
160
+ // The rows-off marker is already in `wrapperBytes`; the opening marker and the
161
+ // line between the two sections are extra.
162
+ openingReserveBytes: (limits.rowsOff ? 0 : utf8ByteLength(marker)) + SECTION_SEPARATOR.length,
163
+ });
164
+ return [
165
+ promptSection &&
166
+ (limits.rowsOff ? insertMarker(promptSection, ROWS_OFF_MARKER) : promptSection),
167
+ openingSection && insertMarker(openingSection, marker),
168
+ ]
169
+ .filter(Boolean)
170
+ .join(SECTION_SEPARATOR);
171
+ }
172
+ // --- Subagents ---
173
+ /**
174
+ * A background subagent's call gets this receipt as its tool result at launch, before the agent
175
+ * runs. It is not an answer: the call is still the one whose agent is starting.
176
+ */
177
+ const LAUNCH_RECEIPT_PREFIX = "Async agent launched";
178
+ function isLaunchReceipt(content) {
179
+ const text = Array.isArray(content)
180
+ ? content.map((part) => part?.text ?? "").join("")
181
+ : content;
182
+ return typeof text === "string" && text.startsWith(LAUNCH_RECEIPT_PREFIX);
183
+ }
184
+ /** Calls that already fed a subagent, per parent session. Bounded, in memory only. */
185
+ const claimedCalls = new Map();
186
+ const CLAIMED_SESSIONS_MAX = 500;
187
+ const CLAIMED_CALLS_MAX = 200;
188
+ function claimCall(sessionId, callId) {
189
+ const used = claimedCalls.get(sessionId) ?? new Set();
190
+ claimedCalls.delete(sessionId);
191
+ used.add(callId);
192
+ if (used.size > CLAIMED_CALLS_MAX)
193
+ used.delete(used.values().next().value);
194
+ claimedCalls.set(sessionId, used);
195
+ if (claimedCalls.size > CLAIMED_SESSIONS_MAX) {
196
+ claimedCalls.delete(claimedCalls.keys().next().value);
197
+ }
198
+ }
199
+ /**
200
+ * The task prompt of the oldest `Agent` or `Task` call of `agentType` that has no result yet and
201
+ * has not fed a subagent already.
202
+ * A background launch receipt does not count as a result.
203
+ */
204
+ async function subagentTaskPrompt(transcriptPath, agentType, sessionId) {
205
+ const handle = await open(transcriptPath, "r");
206
+ let text;
207
+ try {
208
+ const { size } = await handle.stat();
209
+ const length = Math.min(size, SUBAGENT_TAIL_BYTES);
210
+ const buffer = Buffer.alloc(length);
211
+ await handle.read(buffer, 0, length, size - length);
212
+ text = buffer.toString("utf8");
213
+ }
214
+ finally {
215
+ await handle.close();
216
+ }
217
+ const answered = new Set();
218
+ const calls = [];
219
+ for (const entry of parseClaudeTranscript(text).entries) {
220
+ if (entry.isSidechain === true)
221
+ continue;
222
+ for (const block of contentBlocks(entry)) {
223
+ if (block.type === "tool_result" && block.tool_use_id && !isLaunchReceipt(block.content)) {
224
+ answered.add(block.tool_use_id);
225
+ }
226
+ if (block.type !== "tool_use" || !block.id)
227
+ continue;
228
+ if (block.name !== "Agent" && block.name !== "Task")
229
+ continue;
230
+ const input = (block.input ?? {});
231
+ if (typeof input.prompt !== "string")
232
+ continue;
233
+ const type = typeof input.subagent_type === "string" && input.subagent_type;
234
+ calls.push({ id: block.id, type: type || "general-purpose", prompt: input.prompt });
235
+ }
236
+ }
237
+ // Oldest first: parallel subagents start in the order of their calls. A call that already
238
+ // fed a subagent is not used again, so a background call (open until its agent ends) or a
239
+ // second start with no new call gets nothing.
240
+ const used = claimedCalls.get(sessionId);
241
+ const call = calls.find((candidate) => candidate.type === agentType && !answered.has(candidate.id) && !used?.has(candidate.id));
242
+ if (!call)
243
+ return null;
244
+ claimCall(sessionId, call.id);
245
+ return call.prompt;
246
+ }
247
+ /**
248
+ * The added context for a starting subagent: the retrieval section for its
249
+ * task prompt, read from the parent transcript. Every subagent section carries
250
+ * the rows-off marker. Any problem gives an empty string.
251
+ */
252
+ export async function subagentContext(input, budgetBytes) {
253
+ if (!CONFIG.subagentMemory || !CONFIG.chatMessage.enabled)
254
+ return "";
255
+ if (!input.transcriptPath || !input.agentType)
256
+ return "";
257
+ const task = await subagentTaskPrompt(input.transcriptPath, input.agentType, input.sessionId).catch(() => null);
258
+ const prompt = stripPrivateContent(task ?? "").trim();
259
+ if (!task || !prompt || isFullyPrivate(task))
260
+ return "";
261
+ const limits = { budgetBytes, rowsOff: true };
262
+ const section = await buildRetrievalSection(prompt, input.cwd, input.sessionId, sectionBudget(limits));
263
+ return section ? wrapSection(section, limits) : "";
264
+ }
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
+ /** True when the URL names this machine, so a model server there may run without a key. */
2
+ export declare function isLocalApiUrl(url: string | undefined): boolean;
@@ -0,0 +1,12 @@
1
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
2
+ /** True when the URL names this machine, so a model server there may run without a key. */
3
+ export function isLocalApiUrl(url) {
4
+ if (!url)
5
+ return false;
6
+ try {
7
+ return LOCAL_HOSTS.has(new URL(url).hostname.toLowerCase());
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
@@ -12,6 +12,8 @@ interface MemoryProviderRuntimeConfig {
12
12
  interface ProviderConfigOverrides {
13
13
  maxIterations?: number;
14
14
  iterationTimeout?: number;
15
+ /** Chat only: a model server on this machine needs no key. Other callers still need one. */
16
+ keylessForLocalUrl?: boolean;
15
17
  }
16
18
  export declare function buildMemoryProviderConfig(config: MemoryProviderRuntimeConfig, overrides?: ProviderConfigOverrides): ProviderConfig;
17
19
  export {};
@@ -1,4 +1,5 @@
1
1
  import { isPlaceholderApiKey } from "./api-key-placeholder.js";
2
+ import { isLocalApiUrl } from "./local-api-url.js";
2
3
  export function buildMemoryProviderConfig(config, overrides = {}) {
3
4
  const memoryModel = config.memoryModel;
4
5
  const memoryApiUrl = config.memoryApiUrl;
@@ -11,7 +12,8 @@ export function buildMemoryProviderConfig(config, overrides = {}) {
11
12
  issues.push("missing memoryModel");
12
13
  if (!memoryApiUrl && !isOrcaRouter)
13
14
  issues.push("missing memoryApiUrl");
14
- if (!memoryApiKey)
15
+ const keyOptional = overrides.keylessForLocalUrl === true && isLocalApiUrl(memoryApiUrl);
16
+ if (!memoryApiKey && !keyOptional)
15
17
  issues.push("missing memoryApiKey");
16
18
  if (isPlaceholderApiKey(memoryApiKey))
17
19
  issues.push("replace the placeholder memoryApiKey value");
@@ -123,7 +123,8 @@ export class GoogleGeminiProvider extends BaseAIProvider {
123
123
  const timeout = setTimeout(() => controller.abort(), iterationTimeout);
124
124
  try {
125
125
  const baseUrl = this.config.apiUrl || "https://generativelanguage.googleapis.com/v1beta";
126
- const url = `${baseUrl}/models/${this.config.model}:generateContent?key=${this.config.apiKey}`;
126
+ const keyQuery = this.config.apiKey ? `?key=${this.config.apiKey}` : "";
127
+ const url = `${baseUrl}/models/${this.config.model}:generateContent${keyQuery}`;
127
128
  const requestBody = {
128
129
  contents,
129
130
  systemInstruction: geminiSystemInstruction,
@@ -54,7 +54,8 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
54
54
  method: "POST",
55
55
  headers: {
56
56
  "Content-Type": "application/json",
57
- Authorization: `Bearer ${this.config.apiKey}`,
57
+ // A keyless server on this machine gets no Authorization header.
58
+ ...(this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}),
58
59
  },
59
60
  body: JSON.stringify(requestBody),
60
61
  signal: controller.signal,
@@ -23,7 +23,11 @@ export async function callTool(config, system, user, tool, timeoutMs = CHAT_MODE
23
23
  const { AIProviderFactory } = await import("../ai/ai-provider-factory.js");
24
24
  const { buildMemoryProviderConfig } = await import("../ai/provider-config.js");
25
25
  const { aiSessionManager } = await import("../ai/session/ai-session-manager.js");
26
- const provider = AIProviderFactory.createProvider(config.memoryProvider, buildMemoryProviderConfig(config, { maxIterations: 1, iterationTimeout: timeoutMs }));
26
+ const provider = AIProviderFactory.createProvider(config.memoryProvider, buildMemoryProviderConfig(config, {
27
+ maxIterations: 1,
28
+ iterationTimeout: timeoutMs,
29
+ keylessForLocalUrl: true,
30
+ }));
27
31
  const sessionId = `chat_${crypto.randomUUID()}`;
28
32
  try {
29
33
  return await provider.executeToolCall(system, user, tool, sessionId);
@@ -1,4 +1,5 @@
1
1
  import { isPlaceholderApiKey } from "../ai/api-key-placeholder.js";
2
+ import { isLocalApiUrl } from "../ai/local-api-url.js";
2
3
  /** Whether chat can call the external memory model. Mirrors `buildMemoryProviderConfig`. */
3
4
  export function chatStatus(config) {
4
5
  const provider = config.memoryProvider ?? "openai-chat";
@@ -8,7 +9,9 @@ export function chatStatus(config) {
8
9
  missing.push("memoryModel");
9
10
  if (!config.memoryApiUrl && !presetEndpoint)
10
11
  missing.push("memoryApiUrl");
11
- if (!config.memoryApiKey || isPlaceholderApiKey(config.memoryApiKey)) {
12
+ // A model server on this machine needs no key. A placeholder that was left in the box is still flagged.
13
+ const keyOptional = !config.memoryApiKey && isLocalApiUrl(config.memoryApiUrl);
14
+ if (!keyOptional && (!config.memoryApiKey || isPlaceholderApiKey(config.memoryApiKey))) {
12
15
  missing.push("memoryApiKey");
13
16
  }
14
17
  return {
@@ -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
+ }