om-memory-system 5.3.0 → 5.4.1

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-7p-aYnWZ.css +2 -0
  52. package/dist/web/assets/{index-CJ_K-hMG.js → index-CaFFlcl1.js} +17 -17
  53. package/dist/web/index.html +2 -2
  54. package/package.json +1 -1
  55. package/dist/web/assets/index-ccH97VE2.css +0 -2
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
4
4
  import { CONFIG, refreshConfigIfChanged } from "../config.js";
5
5
  import { classifyCaptureFailure, quickRetryDelayMs } from "../core/capture-retry-policy.js";
6
- import { buildRecentMemoriesSection, buildRetrievalSection, formatMemoriesForCompaction, retrievalWrapperBytes, wrapRetrievalSection, } from "../core/retrieval.js";
6
+ import { formatMemoriesForCompaction } from "../core/retrieval.js";
7
7
  import { handleFinishedTurn } from "../core/turn-outputs.js";
8
8
  import { DEFAULT_RETRIEVAL_MAX_TOKENS, tokensToByteCeiling } from "../core/context-budget.js";
9
9
  import { resolveClaudeCodeLiveModel } from "../services/ai/live-model-choice.js";
@@ -11,15 +11,15 @@ import { captureFailureOf, drainCaptureRetries, queueFailedCapture, registerCapt
11
11
  import { readClaudeCaptureCursor, writeClaudeCaptureCursor, } from "../services/claude-capture-cursor.js";
12
12
  import { memoryClient } from "../services/client.js";
13
13
  import { log } from "../services/logger.js";
14
- import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js";
15
14
  import { skillTurnRecorder } from "../services/skill-learning/settings-for.js";
16
15
  import { getTags } from "../services/tags.js";
17
16
  import { parseClaudeTranscript } from "./claude-conversation.js";
17
+ import { markSessionSeen, promptContext, sectionBudget, sectionLimits, subagentContext, wrapSection, } from "./claude-hook-context.js";
18
18
  import { learnClaudeProfile } from "./claude-profile-learning.js";
19
19
  import { claudeWindowsToCapture, recordClaudeSkillTurns, sessionDirectory, skillUnitOf, workUnitOf, } from "./claude-turn-units.js";
20
20
  import { defaultClaudeProjectsRoot } from "./claude-reader.js";
21
21
  // The web app's side of the Claude Code hooks (design decisions 4, 6, 7):
22
- // retrieval for SessionStart and UserPromptSubmit, a one-worker capture queue
22
+ // retrieval for SessionStart, UserPromptSubmit and SubagentStart, a one-worker capture queue
23
23
  // for Stop, profile learning, and the once-per-process backfill start. Logs
24
24
  // carry event names, session ids, counts, sizes, and codes; never text.
25
25
  const HOST = "claude-code";
@@ -31,6 +31,7 @@ export class ClaudeHookRequestError extends Error {
31
31
  this.name = "ClaudeHookRequestError";
32
32
  }
33
33
  }
34
+ const RETRIEVE_EVENTS = ["session-start", "user-prompt-submit", "subagent-start"];
34
35
  function objectBody(body) {
35
36
  if (!body || typeof body !== "object" || Array.isArray(body)) {
36
37
  throw new ClaudeHookRequestError("A JSON object is required");
@@ -57,14 +58,17 @@ function optionalString(body, key) {
57
58
  }
58
59
  function parseRetrieveRequest(body) {
59
60
  const input = objectBody(body);
60
- if (input.event !== "session-start" && input.event !== "user-prompt-submit") {
61
- throw new ClaudeHookRequestError("event must be session-start or user-prompt-submit");
61
+ const event = RETRIEVE_EVENTS.find((name) => name === input.event);
62
+ if (!event) {
63
+ throw new ClaudeHookRequestError(`event must be one of ${RETRIEVE_EVENTS.join(", ")}`);
62
64
  }
63
65
  return {
64
- event: input.event,
66
+ event,
65
67
  ...sessionAndCwd(input),
66
68
  source: optionalString(input, "source"),
67
69
  prompt: optionalString(input, "prompt"),
70
+ transcriptPath: optionalString(input, "transcript_path"),
71
+ agentType: optionalString(input, "agent_type"),
68
72
  };
69
73
  }
70
74
  /**
@@ -140,44 +144,58 @@ function maybeStartBackfill(deps) {
140
144
  .startBackfill()
141
145
  .catch((error) => log("Claude Code backfill start failed", { code: errorCode(error) }, "error"));
142
146
  }
143
- async function retrievalSection(request, budgetBytes) {
144
- // Every section this api returns is wrapped in the retrieval tag, so each
145
- // packed section reserves the wrapper bytes.
146
- const budget = { maxBytes: budgetBytes, wrapperBytes: retrievalWrapperBytes() };
147
+ /** The checked transcript path of the request, or undefined when it has none or fails the check. */
148
+ function requestTranscript(request) {
149
+ if (!request.transcriptPath)
150
+ return undefined;
151
+ try {
152
+ return checkedTranscriptPath(request.transcriptPath, request.sessionId);
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ }
158
+ /** The text to add for one request, already wrapped in the retrieval tag. Empty means nothing. */
159
+ async function retrievalText(request, budgetBytes) {
160
+ const limits = sectionLimits(budgetBytes);
147
161
  if (request.event === "user-prompt-submit") {
148
- const prompt = stripPrivateContent(request.prompt ?? "").trim();
149
- if (!CONFIG.chatMessage.enabled || !prompt || isFullyPrivate(request.prompt ?? "")) {
150
- return null;
151
- }
152
- return buildRetrievalSection(prompt, request.cwd, request.sessionId, budget);
162
+ return promptContext({
163
+ prompt: request.prompt ?? "",
164
+ cwd: request.cwd,
165
+ sessionId: request.sessionId,
166
+ transcriptPath: requestTranscript(request),
167
+ }, limits);
153
168
  }
154
- const tags = getTags(request.cwd);
155
- if (request.source === "compact" || request.source === "resume") {
156
- if (!CONFIG.compaction.enabled)
157
- return null;
158
- const result = await memoryClient.searchMemoriesBySessionID(request.sessionId, tags.project.tag, CONFIG.compaction.memoryLimit);
159
- const own = result.success
160
- ? result.results.filter((memory) => memory.metadata?.host === HOST)
161
- : [];
162
- return own.length > 0 ? formatMemoriesForCompaction(own, budget) : null;
169
+ if (request.event === "subagent-start") {
170
+ return subagentContext({
171
+ cwd: request.cwd,
172
+ sessionId: request.sessionId,
173
+ transcriptPath: requestTranscript(request),
174
+ agentType: request.agentType,
175
+ }, budgetBytes);
163
176
  }
164
- if (!CONFIG.chatMessage.enabled)
165
- return null;
166
- return buildRecentMemoriesSection({
167
- projectTag: tags.project.tag,
168
- userEmail: tags.user.userEmail,
169
- sessionId: request.sessionId,
170
- maxMemories: CONFIG.chatMessage.maxMemories,
171
- excludeCurrentSession: CONFIG.chatMessage.excludeCurrentSession,
172
- maxAgeDays: CONFIG.chatMessage.maxAgeDays,
173
- budget,
174
- });
177
+ // A fresh or cleared session adds nothing; the opening section waits for the first prompt.
178
+ if (request.source !== "compact" && request.source !== "resume")
179
+ return "";
180
+ // A compacted or resumed session already has history, so it never gets an opening section.
181
+ markSessionSeen(request.sessionId);
182
+ if (!CONFIG.compaction.enabled)
183
+ return "";
184
+ const tags = getTags(request.cwd);
185
+ const result = await memoryClient.searchMemoriesBySessionID(request.sessionId, tags.project.tag, CONFIG.compaction.memoryLimit);
186
+ const own = result.success
187
+ ? result.results.filter((memory) => memory.metadata?.host === HOST)
188
+ : [];
189
+ return own.length > 0
190
+ ? wrapSection(formatMemoriesForCompaction(own, sectionBudget(limits)), limits)
191
+ : "";
175
192
  }
176
193
  /**
177
- * `POST /api/claude/retrieve`: recent project memories for a fresh session,
178
- * the session's own memories after compaction or resume, or the retrieval
179
- * section for a prompt. Every text is wrapped in the retrieval tag; an empty
180
- * string means nothing to add.
194
+ * `POST /api/claude/retrieve`: the retrieval section for a prompt (and the
195
+ * opening section on a session's first prompt, when on), the memories for a
196
+ * starting subagent's task, or the session's own memories after compaction or
197
+ * resume. A fresh or cleared session adds nothing. Every text is wrapped in the
198
+ * retrieval tag; an empty string means nothing to add.
181
199
  */
182
200
  export async function handleClaudeRetrieve(body, deps = {}) {
183
201
  const request = parseRetrieveRequest(body);
@@ -189,8 +207,7 @@ export async function handleClaudeRetrieve(body, deps = {}) {
189
207
  if (request.event === "session-start")
190
208
  maybeStartBackfill(deps);
191
209
  try {
192
- const section = await retrievalSection(request, budgetBytes);
193
- return { additionalContext: section ? wrapRetrievalSection(section) : "" };
210
+ return { additionalContext: await retrievalText(request, budgetBytes) };
194
211
  }
195
212
  catch (error) {
196
213
  log("Claude Code retrieval failed", {
@@ -0,0 +1,49 @@
1
+ import type { ContextBudgetOptions } from "../core/context-budget.js";
2
+ /** Tells the recall rows to draw nothing for this section. 21 bytes, newline included. */
3
+ export declare const ROWS_OFF_MARKER = "<!--omms:rows-off-->\n";
4
+ /** The byte budget of one request and whether recall rows are off for it. */
5
+ export interface SectionLimits {
6
+ budgetBytes: number;
7
+ rowsOff: boolean;
8
+ }
9
+ /** Read `visibleRecall` once per request, after the config refresh. */
10
+ export declare function sectionLimits(budgetBytes: number): SectionLimits;
11
+ /** The budget for one packed section: the retrieval tag, and the rows-off marker when set, are reserved. */
12
+ export declare function sectionBudget(limits: SectionLimits): ContextBudgetOptions;
13
+ /** Put a marker line right after the opening tag of a wrapped section. */
14
+ export declare function insertMarker(wrapped: string, marker: string): string;
15
+ /** Wrap a packed section, with the rows-off marker when rows are off. */
16
+ export declare function wrapSection(section: string, limits: SectionLimits): string;
17
+ /**
18
+ * The opening marker. The query goes on one line, with `"`, `<` and `>`
19
+ * removed, `--` folded to `-` and 300 characters at most, so it cannot end the
20
+ * comment early.
21
+ */
22
+ export declare function openingMarker(query: string): string;
23
+ /** Remember a session, so a later prompt in this process is never first. Oldest ids drop out. */
24
+ export declare function markSessionSeen(sessionId: string): void;
25
+ export interface PromptContextInput {
26
+ prompt: string;
27
+ cwd: string;
28
+ sessionId: string;
29
+ /** The checked transcript path, or undefined when the request had none or it failed the check. */
30
+ transcriptPath: string | undefined;
31
+ }
32
+ /**
33
+ * The added context for a prompt: its retrieval section, and on the first
34
+ * prompt of a session, with `openingContext` on, the opening section after it.
35
+ * Empty string when there is nothing to add.
36
+ */
37
+ export declare function promptContext(input: PromptContextInput, limits: SectionLimits): Promise<string>;
38
+ export interface SubagentContextInput {
39
+ cwd: string;
40
+ sessionId: string;
41
+ transcriptPath: string | undefined;
42
+ agentType: string | undefined;
43
+ }
44
+ /**
45
+ * The added context for a starting subagent: the retrieval section for its
46
+ * task prompt, read from the parent transcript. Every subagent section carries
47
+ * the rows-off marker. Any problem gives an empty string.
48
+ */
49
+ export declare function subagentContext(input: SubagentContextInput, budgetBytes: number): Promise<string>;
@@ -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
+ }
@@ -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 {