om-memory-system 3.6.0-next.37 → 3.6.0-next.39

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 (60) hide show
  1. package/README.md +24 -16
  2. package/dist/adapters/claude-code/hook-client.d.ts +73 -0
  3. package/dist/adapters/claude-code/hook-client.js +285 -0
  4. package/dist/adapters/claude-code/hook-command.d.ts +6 -0
  5. package/dist/adapters/claude-code/hook-command.js +25 -0
  6. package/dist/cli/index.js +19 -3
  7. package/dist/cli/memory-command.d.ts +24 -0
  8. package/dist/cli/memory-command.js +180 -0
  9. package/dist/cli/web-command.d.ts +8 -1
  10. package/dist/cli/web-command.js +119 -3
  11. package/dist/core/retrieval.d.ts +21 -0
  12. package/dist/core/retrieval.js +35 -0
  13. package/dist/importer/backfill-controls.d.ts +10 -0
  14. package/dist/importer/backfill-controls.js +40 -11
  15. package/dist/importer/backfill-model.d.ts +1 -1
  16. package/dist/importer/backfill-model.js +3 -0
  17. package/dist/importer/claude-conversation.d.ts +74 -0
  18. package/dist/importer/claude-conversation.js +216 -0
  19. package/dist/importer/claude-hook-api.d.ts +50 -0
  20. package/dist/importer/claude-hook-api.js +441 -0
  21. package/dist/importer/claude-import.d.ts +15 -0
  22. package/dist/importer/claude-import.js +62 -0
  23. package/dist/importer/claude-reader.d.ts +99 -0
  24. package/dist/importer/claude-reader.js +292 -0
  25. package/dist/importer/external-backfill-models.js +2 -2
  26. package/dist/importer/import-args.d.ts +1 -1
  27. package/dist/importer/import-args.js +11 -4
  28. package/dist/importer/import-readiness.d.ts +13 -1
  29. package/dist/importer/import-readiness.js +19 -0
  30. package/dist/importer/import-runs.js +1 -1
  31. package/dist/importer/import-sessions.d.ts +4 -1
  32. package/dist/importer/import-sessions.js +14 -2
  33. package/dist/importer/import-sources.d.ts +8 -2
  34. package/dist/importer/import-sources.js +21 -4
  35. package/dist/importer/map-suggestions.d.ts +1 -0
  36. package/dist/importer/map-suggestions.js +6 -1
  37. package/dist/importer/run-import.js +14 -10
  38. package/dist/importer/web-import-jobs.js +10 -3
  39. package/dist/index.js +9 -23
  40. package/dist/services/ai/live-model-choice.d.ts +5 -0
  41. package/dist/services/ai/live-model-choice.js +7 -0
  42. package/dist/services/backfill-state.d.ts +1 -1
  43. package/dist/services/capture-retry-queue.js +3 -2
  44. package/dist/services/claude-capture-cursor.d.ts +10 -0
  45. package/dist/services/claude-capture-cursor.js +44 -0
  46. package/dist/services/settings-snapshot.d.ts +1 -0
  47. package/dist/services/settings-snapshot.js +3 -1
  48. package/dist/services/version-compare.d.ts +8 -0
  49. package/dist/services/version-compare.js +58 -0
  50. package/dist/services/web-api-auth.d.ts +2 -0
  51. package/dist/services/web-api-auth.js +10 -0
  52. package/dist/services/web-server.d.ts +11 -0
  53. package/dist/services/web-server.js +114 -10
  54. package/dist/types/host-label.d.ts +3 -0
  55. package/dist/types/host-label.js +9 -0
  56. package/dist/types/index.d.ts +1 -1
  57. package/dist/web/assets/index-BFiVixOK.js +124 -0
  58. package/dist/web/index.html +1 -1
  59. package/package.json +1 -1
  60. package/dist/web/assets/index-DIy--9g_.js +0 -124
@@ -33,8 +33,9 @@ export function validateWebImportRequest(value) {
33
33
  if (!value || typeof value !== "object" || Array.isArray(value))
34
34
  throw new Error("Invalid import request");
35
35
  const input = value;
36
- if (input.host !== "pi" && input.host !== "opencode")
37
- throw new Error("Choose Pi or OpenCode");
36
+ if (input.host !== "pi" && input.host !== "opencode" && input.host !== "claude-code") {
37
+ throw new Error("Choose Pi, OpenCode, or Claude Code");
38
+ }
38
39
  if (!input.options || typeof input.options !== "object" || Array.isArray(input.options))
39
40
  throw new Error("Invalid import options");
40
41
  const options = input.options;
@@ -72,12 +73,18 @@ export function validateWebImportRequest(value) {
72
73
  (typeof input.modelChoice !== "string" ||
73
74
  (input.modelChoice !== "external" && !/^[^/]+\/.+$/.test(input.modelChoice))))
74
75
  throw new Error("Choose a signed-in provider/model or the external API");
76
+ // Claude Code has no session model: its imports always use the external API.
77
+ if (input.host === "claude-code" &&
78
+ input.modelChoice !== undefined &&
79
+ input.modelChoice !== "external")
80
+ throw new Error("Claude Code imports use the external API");
81
+ const modelChoice = input.host === "claude-code" ? "external" : input.modelChoice;
75
82
  return {
76
83
  host: input.host,
77
84
  source: input.source,
78
85
  selection: validateImportSelection(input.selection),
79
86
  options: options,
80
- modelChoice: input.modelChoice,
87
+ modelChoice,
81
88
  };
82
89
  }
83
90
  /** One in-memory slot per web server, shared by previews and imports. */
package/dist/index.js CHANGED
@@ -1,13 +1,12 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
2
  import { memoryClient } from "./services/client.js";
3
- import { formatContextForPrompt } from "./services/context.js";
4
3
  import { getTags } from "./services/tags.js";
5
4
  import { performAutoCapture } from "./services/auto-capture.js";
6
5
  import { createOpenCodeAutoCaptureHost } from "./adapters/opencode/auto-capture-host.js";
7
6
  import { isInternalPrompt, isStructuredSummaryPromptMessage, recordUserPrompt, } from "./adapters/opencode/user-prompt.js";
8
7
  import { executeMemoryOperation } from "./core/memory-operations.js";
9
8
  import { OPENCODE_IMPORT_COMMAND, OPENCODE_IMPORT_DESCRIPTION, latestUserMessageModel, runOpencodeImportCommand, } from "./adapters/opencode/import-command.js";
10
- import { formatMemoriesForCompaction } from "./core/retrieval.js";
9
+ import { buildRecentMemoriesSection, formatMemoriesForCompaction } from "./core/retrieval.js";
11
10
  import { performUserProfileLearning } from "./adapters/opencode/profile-learning.js";
12
11
  import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js";
13
12
  import { startWebServer, WebServer } from "./services/web-server.js";
@@ -489,27 +488,14 @@ export const OmmsPlugin = async (ctx) => {
489
488
  !m.parts.every((p) => p.type !== "text" || p.synthetic === true)).length === 1);
490
489
  if (!shouldInject)
491
490
  return;
492
- const listResult = await memoryClient.listMemories(tags.project.tag, CONFIG.chatMessage.maxMemories);
493
- let memories = listResult.success ? listResult.memories : [];
494
- if (CONFIG.chatMessage.excludeCurrentSession) {
495
- memories = memories.filter((m) => m.metadata?.sessionID !== input.sessionID);
496
- }
497
- if (CONFIG.chatMessage.maxAgeDays) {
498
- const cutoffDate = Date.now() - CONFIG.chatMessage.maxAgeDays * 86400000;
499
- memories = memories.filter((m) => new Date(m.createdAt).getTime() > cutoffDate);
500
- }
501
- if (memories.length === 0)
502
- return;
503
- const projectMemories = {
504
- results: memories.map((m) => ({
505
- similarity: 1.0,
506
- memory: m.summary,
507
- })),
508
- total: memories.length,
509
- timing: 0,
510
- };
511
- const userId = tags.user.userEmail || null;
512
- const memoryContext = await formatContextForPrompt(userId, projectMemories);
491
+ const memoryContext = await buildRecentMemoriesSection({
492
+ projectTag: tags.project.tag,
493
+ userEmail: tags.user.userEmail,
494
+ sessionId: input.sessionID,
495
+ maxMemories: CONFIG.chatMessage.maxMemories,
496
+ excludeCurrentSession: CONFIG.chatMessage.excludeCurrentSession,
497
+ maxAgeDays: CONFIG.chatMessage.maxAgeDays,
498
+ });
513
499
  if (memoryContext) {
514
500
  const contextPart = {
515
501
  id: `prt-memory-context-${Date.now()}`,
@@ -49,6 +49,11 @@ export declare function resolvePiLiveModel(config: AutoCaptureProviderRuntimeCon
49
49
  piProvider?: string;
50
50
  piModel?: string;
51
51
  }): PiLiveModelChoice;
52
+ /**
53
+ * Claude Code's live model: the external API only. Its hooks cannot call the
54
+ * session's model, so there is no host model and no session fallback.
55
+ */
56
+ export declare function resolveClaudeCodeLiveModel(config: AutoCaptureProviderRuntimeConfig): AutoCaptureProviderStatus;
52
57
  /** True when the external API is fully configured and can serve as a fallback. */
53
58
  export declare function isExternalModelReady(config: AutoCaptureProviderRuntimeConfig): boolean;
54
59
  /** Why the external API cannot be used as a host's chosen model, or an empty list. */
@@ -93,6 +93,13 @@ export function resolvePiLiveModel(config) {
93
93
  return { kind: "session" };
94
94
  return { kind: "unready", issues: manual.issues };
95
95
  }
96
+ /**
97
+ * Claude Code's live model: the external API only. Its hooks cannot call the
98
+ * session's model, so there is no host model and no session fallback.
99
+ */
100
+ export function resolveClaudeCodeLiveModel(config) {
101
+ return externalChoiceStatus(config);
102
+ }
96
103
  /** True when the external API is fully configured and can serve as a fallback. */
97
104
  export function isExternalModelReady(config) {
98
105
  return getManualProviderStatus(config).ready;
@@ -1,4 +1,4 @@
1
- type BackfillHost = "pi" | "opencode";
1
+ type BackfillHost = "pi" | "opencode" | "claude-code";
2
2
  export type BackfillStatus = {
3
3
  host: BackfillHost;
4
4
  cutoff: number;
@@ -179,11 +179,12 @@ export async function deleteCaptureRetry(id, config) {
179
179
  export async function countCaptureRetries(config) {
180
180
  const db = await database(config);
181
181
  const rows = await db.all("SELECT host, COUNT(*) AS count FROM capture_retry_queue GROUP BY host");
182
- const counts = { opencode: 0, pi: 0 };
182
+ const counts = { opencode: 0, pi: 0, "claude-code": 0 };
183
183
  for (const row of rows) {
184
184
  const host = String(row.host);
185
- if (host === "opencode" || host === "pi")
185
+ if (host === "opencode" || host === "pi" || host === "claude-code") {
186
186
  counts[host] = Number(row.count);
187
+ }
187
188
  }
188
189
  return counts;
189
190
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The last Claude Code user entry that live capture handled, per session. It
3
+ * lives in `user-prompts.db` next to the capture retry queue, so a web app
4
+ * restart does not capture the last turn again. Callers pass `CONFIG`.
5
+ */
6
+ export interface ClaudeCaptureCursorConfig {
7
+ storagePath: string;
8
+ }
9
+ export declare function readClaudeCaptureCursor(sessionId: string, config: ClaudeCaptureCursorConfig): Promise<string | null>;
10
+ export declare function writeClaudeCaptureCursor(sessionId: string, userEntryId: string, config: ClaudeCaptureCursorConfig, now?: number): Promise<void>;
@@ -0,0 +1,44 @@
1
+ import { join } from "node:path";
2
+ import { tursoConnectionManager } from "./turso/connection-manager.js";
3
+ /** A session with no capture for this long loses its cursor. */
4
+ const CURSOR_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
5
+ const prepared = new Map();
6
+ async function database(config) {
7
+ const path = join(config.storagePath, "user-prompts.db");
8
+ const db = await tursoConnectionManager.getConnection(path);
9
+ let ready = prepared.get(path);
10
+ if (!ready) {
11
+ ready = db
12
+ .batch([
13
+ {
14
+ sql: `CREATE TABLE IF NOT EXISTS claude_capture_cursors (
15
+ session_id TEXT PRIMARY KEY,
16
+ last_user_entry_id TEXT NOT NULL,
17
+ updated_at INTEGER NOT NULL
18
+ )`,
19
+ },
20
+ {
21
+ sql: "DELETE FROM claude_capture_cursors WHERE updated_at < ?",
22
+ args: [Date.now() - CURSOR_RETENTION_MS],
23
+ },
24
+ ])
25
+ .then(() => undefined);
26
+ // A failed preparation is tried again on the next call.
27
+ ready.catch(() => prepared.delete(path));
28
+ prepared.set(path, ready);
29
+ }
30
+ await ready;
31
+ return db;
32
+ }
33
+ export async function readClaudeCaptureCursor(sessionId, config) {
34
+ const db = await database(config);
35
+ const row = await db.get("SELECT last_user_entry_id FROM claude_capture_cursors WHERE session_id = ?", [sessionId]);
36
+ return row ? String(row.last_user_entry_id) : null;
37
+ }
38
+ export async function writeClaudeCaptureCursor(sessionId, userEntryId, config, now = Date.now()) {
39
+ const db = await database(config);
40
+ await db.run(`INSERT INTO claude_capture_cursors (session_id, last_user_entry_id, updated_at)
41
+ VALUES (?, ?, ?)
42
+ ON CONFLICT(session_id) DO UPDATE SET
43
+ last_user_entry_id = excluded.last_user_entry_id, updated_at = excluded.updated_at`, [sessionId, userEntryId, now]);
44
+ }
@@ -27,6 +27,7 @@ export declare function getSettingsSnapshot(directory: string): {
27
27
  effective: {
28
28
  opencode: import("./ai/live-model-choice.js").AutoCaptureProviderStatus;
29
29
  pi: import("./ai/live-model-choice.js").PiLiveModelChoice;
30
+ "claude-code": import("./ai/live-model-choice.js").AutoCaptureProviderStatus;
30
31
  };
31
32
  };
32
33
  /**
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { parse } from "jsonc-parser";
4
4
  import { CONFIG, getGlobalConfigSourcePath } from "../config.js";
5
5
  import { readGlobalConfigRevision } from "./global-config-writer.js";
6
- import { getAutoCaptureProviderStatus, isExternalModelReady, resolvePiLiveModel, } from "./ai/live-model-choice.js";
6
+ import { getAutoCaptureProviderStatus, isExternalModelReady, resolveClaudeCodeLiveModel, resolvePiLiveModel, } from "./ai/live-model-choice.js";
7
7
  const editable = [
8
8
  "opencodeProvider",
9
9
  "opencodeModel",
@@ -92,6 +92,8 @@ export function getSettingsSnapshot(directory) {
92
92
  effective: {
93
93
  opencode: getAutoCaptureProviderStatus(CONFIG),
94
94
  pi: resolvePiLiveModel(CONFIG),
95
+ // Claude Code capture uses the external API only; `issues` names each missing setting.
96
+ "claude-code": resolveClaudeCodeLiveModel(CONFIG),
95
97
  },
96
98
  };
97
99
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Compare two versions in SemVer order. Returns a negative number when `a` is older,
3
+ * a positive number when `a` is newer, `0` when equal, and `null` when either
4
+ * version cannot be parsed, such as `unknown`.
5
+ */
6
+ export declare function compareVersions(a: string, b: string): number | null;
7
+ /** True only when `a` is provably older than `b`. An unplaceable version is never older. */
8
+ export declare function isOlderVersion(a: string, b: string): boolean;
@@ -0,0 +1,58 @@
1
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
2
+ function parse(version) {
3
+ const match = SEMVER.exec(version.trim());
4
+ if (!match)
5
+ return null;
6
+ return {
7
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
8
+ pre: match[4] ? match[4].split(".") : [],
9
+ };
10
+ }
11
+ function comparePart(a, b) {
12
+ const aNumeric = /^\d+$/.test(a);
13
+ const bNumeric = /^\d+$/.test(b);
14
+ if (aNumeric && bNumeric)
15
+ return Math.sign(Number(a) - Number(b));
16
+ // A numeric part is older than a text part.
17
+ if (aNumeric)
18
+ return -1;
19
+ if (bNumeric)
20
+ return 1;
21
+ return a < b ? -1 : a > b ? 1 : 0;
22
+ }
23
+ /**
24
+ * Compare two versions in SemVer order. Returns a negative number when `a` is older,
25
+ * a positive number when `a` is newer, `0` when equal, and `null` when either
26
+ * version cannot be parsed, such as `unknown`.
27
+ */
28
+ export function compareVersions(a, b) {
29
+ const left = parse(a);
30
+ const right = parse(b);
31
+ if (!left || !right)
32
+ return null;
33
+ for (let i = 0; i < 3; i++) {
34
+ const diff = left.core[i] - right.core[i];
35
+ if (diff)
36
+ return Math.sign(diff);
37
+ }
38
+ // A release is newer than any of its prereleases.
39
+ if (!left.pre.length || !right.pre.length)
40
+ return right.pre.length - left.pre.length;
41
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
42
+ const l = left.pre[i];
43
+ const r = right.pre[i];
44
+ if (l === undefined)
45
+ return -1;
46
+ if (r === undefined)
47
+ return 1;
48
+ const diff = comparePart(l, r);
49
+ if (diff)
50
+ return diff;
51
+ }
52
+ return 0;
53
+ }
54
+ /** True only when `a` is provably older than `b`. An unplaceable version is never older. */
55
+ export function isOlderVersion(a, b) {
56
+ const order = compareVersions(a, b);
57
+ return order !== null && order < 0;
58
+ }
@@ -1,4 +1,6 @@
1
1
  export declare function isLoopbackHost(host: string): boolean;
2
+ /** True for a peer socket address on this machine: 127.x.x.x, ::1, or an IPv4-mapped 127.x.x.x. */
3
+ export declare function isLoopbackAddress(address: string | undefined): boolean;
2
4
  /** A URL needs brackets around an IPv6 literal such as ::1. */
3
5
  export declare function webServerUrl(host: string, port: number): string;
4
6
  export declare function assertWebServerNetworkAuth(host: string, apiToken?: string, basicAuthEnabled?: boolean): void;
@@ -3,6 +3,16 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0:0:0
3
3
  export function isLoopbackHost(host) {
4
4
  return LOOPBACK_HOSTS.has(host.trim().toLowerCase());
5
5
  }
6
+ /** True for a peer socket address on this machine: 127.x.x.x, ::1, or an IPv4-mapped 127.x.x.x. */
7
+ export function isLoopbackAddress(address) {
8
+ if (!address)
9
+ return false;
10
+ const value = address
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/^::ffff:/, "");
14
+ return value === "::1" || /^127(?:\.\d{1,3}){3}$/.test(value);
15
+ }
6
16
  /** A URL needs brackets around an IPv6 literal such as ::1. */
7
17
  export function webServerUrl(host, port) {
8
18
  const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
@@ -23,6 +23,8 @@ interface WebServerConfig {
23
23
  enabled: boolean;
24
24
  auth?: WebAuth;
25
25
  apiToken?: string;
26
+ /** How long a stepped-aside server waits before it may take the port back. */
27
+ stepAsideHoldOffMs?: number;
26
28
  }
27
29
  export declare class WebServer {
28
30
  private server;
@@ -32,6 +34,9 @@ export declare class WebServer {
32
34
  private healthCheckInterval;
33
35
  private onTakeoverCallback;
34
36
  private onPortsExhaustedCallback;
37
+ private onStepAsideCallback;
38
+ private stepAsideTimer;
39
+ private holdOffTimer;
35
40
  private portsExhaustedNotified;
36
41
  private takeoverFailures;
37
42
  private readonly maxFallbackPort;
@@ -39,11 +44,17 @@ export declare class WebServer {
39
44
  private backfillControlsInstance?;
40
45
  /** Run an import-page action; errors carry their own status and never file contents. */
41
46
  private importResponse;
47
+ /** Claude Code hook requests; they reach this point only with a valid API token. */
48
+ private claudeHookResponse;
42
49
  private backfillControls;
43
50
  private importJobs;
44
51
  constructor(config: WebServerConfig);
45
52
  setOnTakeoverCallback(callback: () => Promise<void>): void;
46
53
  setOnPortsExhaustedCallback(callback: () => void): void;
54
+ /** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
55
+ setOnStepAside(callback: () => void | Promise<void>): void;
56
+ private scheduleStepAside;
57
+ private handleStepAside;
47
58
  start(): Promise<void>;
48
59
  private _start;
49
60
  private startHealthCheckLoop;
@@ -5,7 +5,9 @@ import { join, dirname, extname, normalize, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { log } from "./logger.js";
7
7
  import { corsPreflightResponse, disallowedCorsResponse, isAllowedBrowserOrigin } from "./cors.js";
8
- import { assertWebServerNetworkAuth, authorizeApiRequest, isLoopbackHost, webServerUrl, } from "./web-api-auth.js";
8
+ import { assertWebServerNetworkAuth, authorizeApiRequest, isLoopbackAddress, isLoopbackHost, webServerUrl, } from "./web-api-auth.js";
9
+ import { packageVersion } from "./package-version.js";
10
+ import { isOlderVersion } from "./version-compare.js";
9
11
  import { getOrCreateAuthToken, isAuthorizedApiRequest } from "./auth-token.js";
10
12
  import { WebAuth } from "./web-auth.js";
11
13
  import { NODE_HTTP_IDLE_TIMEOUT_MS } from "./request-timeouts.js";
@@ -34,7 +36,7 @@ function serveFetch(opts) {
34
36
  const bunHandle = globalThis.Bun.serve({
35
37
  port: opts.port,
36
38
  hostname: opts.hostname,
37
- fetch: opts.fetch,
39
+ fetch: (req, server) => opts.fetch(req, server.requestIP(req)?.address),
38
40
  });
39
41
  return Promise.resolve({ stop: () => bunHandle.stop() });
40
42
  }
@@ -67,7 +69,7 @@ function serveFetch(opts) {
67
69
  signal: abortController.signal,
68
70
  ...(hasBody ? { duplex: "half" } : {}),
69
71
  });
70
- const webRes = await opts.fetch(webReq);
72
+ const webRes = await opts.fetch(webReq, req.socket.remoteAddress);
71
73
  if (destroyed)
72
74
  return;
73
75
  res.statusCode = webRes.status;
@@ -138,6 +140,9 @@ export function nextFallbackPort(currentPort, failedTakeovers, maxFallbackPort,
138
140
  }
139
141
  return currentPort + 1;
140
142
  }
143
+ const STEP_ASIDE_HOLD_OFF_MS = 60_000;
144
+ /** Lets the 202 reply reach the caller before the server stops. */
145
+ const STEP_ASIDE_REPLY_GRACE_MS = 100;
141
146
  export class WebServer {
142
147
  server = null;
143
148
  config;
@@ -146,6 +151,9 @@ export class WebServer {
146
151
  healthCheckInterval = null;
147
152
  onTakeoverCallback = null;
148
153
  onPortsExhaustedCallback = null;
154
+ onStepAsideCallback = null;
155
+ stepAsideTimer = null;
156
+ holdOffTimer = null;
149
157
  portsExhaustedNotified = false;
150
158
  takeoverFailures = 0;
151
159
  maxFallbackPort;
@@ -161,6 +169,26 @@ export class WebServer {
161
169
  return this.jsonResponse({ error: error instanceof Error ? error.message : "Import request failed" }, typeof code === "number" ? code : 400);
162
170
  }
163
171
  }
172
+ /** Claude Code hook requests; they reach this point only with a valid API token. */
173
+ async claudeHookResponse(req, path) {
174
+ const api = await import("../importer/claude-hook-api.js");
175
+ const body = await req.json().catch(() => null);
176
+ try {
177
+ if (path === "/api/claude/capture") {
178
+ return this.jsonResponse(api.handleClaudeCapture(body), 202);
179
+ }
180
+ const cwd = this.config.directory ?? process.cwd();
181
+ return this.jsonResponse(await api.handleClaudeRetrieve(body, {
182
+ startBackfill: async () => (await this.backfillControls()).startAuto("claude-code", cwd),
183
+ }));
184
+ }
185
+ catch (error) {
186
+ if (error instanceof api.ClaudeHookRequestError) {
187
+ return this.jsonResponse({ error: error.message }, error.status);
188
+ }
189
+ throw error;
190
+ }
191
+ }
164
192
  async backfillControls() {
165
193
  const { BackfillControls } = await import("../importer/web-import-api.js");
166
194
  return (this.backfillControlsInstance ??= new BackfillControls());
@@ -179,6 +207,57 @@ export class WebServer {
179
207
  setOnPortsExhaustedCallback(callback) {
180
208
  this.onPortsExhaustedCallback = callback;
181
209
  }
210
+ /** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
211
+ setOnStepAside(callback) {
212
+ this.onStepAsideCallback = callback;
213
+ }
214
+ scheduleStepAside() {
215
+ if (this.stepAsideTimer)
216
+ return;
217
+ this.stepAsideTimer = setTimeout(async () => {
218
+ this.stepAsideTimer = null;
219
+ if (this.onStepAsideCallback) {
220
+ try {
221
+ await this.onStepAsideCallback();
222
+ }
223
+ catch (error) {
224
+ log("Step-aside callback error", { error: String(error) });
225
+ }
226
+ return;
227
+ }
228
+ // Inside a host session: stop serving, keep the session alive, and let the
229
+ // newer web app bind the port before the takeover loop can run again.
230
+ await this.stop();
231
+ this.startPromise = null;
232
+ this.holdOffTimer = setTimeout(() => {
233
+ this.holdOffTimer = null;
234
+ this.startHealthCheckLoop();
235
+ }, this.config.stepAsideHoldOffMs ?? STEP_ASIDE_HOLD_OFF_MS);
236
+ this.holdOffTimer.unref();
237
+ }, STEP_ASIDE_REPLY_GRACE_MS);
238
+ this.stepAsideTimer.unref();
239
+ }
240
+ handleStepAside(remoteAddress, body) {
241
+ const callerVersion = body && typeof body.version === "string"
242
+ ? body.version.slice(0, 40)
243
+ : "";
244
+ const record = (outcome) => log("Web server step-aside request", {
245
+ outcome,
246
+ ownVersion: packageVersion(),
247
+ callerVersion,
248
+ });
249
+ if (!isLoopbackAddress(remoteAddress)) {
250
+ record("refused_auth");
251
+ return this.jsonResponse({ success: false, error: "Loopback caller required" }, 403);
252
+ }
253
+ if (!callerVersion || !isOlderVersion(packageVersion(), callerVersion)) {
254
+ record("refused_not_newer");
255
+ return this.jsonResponse({ success: false, error: "Caller is not newer" }, 409);
256
+ }
257
+ record("stepped_aside");
258
+ this.scheduleStepAside();
259
+ return this.jsonResponse({ success: true }, 202);
260
+ }
182
261
  async start() {
183
262
  if (this.startPromise) {
184
263
  return this.startPromise;
@@ -202,6 +281,10 @@ export class WebServer {
202
281
  fetch: this.handleRequest.bind(this),
203
282
  });
204
283
  this.isOwner = true;
284
+ // This process serves the Claude Code hooks, so it also retries their failed captures.
285
+ void import("../importer/claude-hook-api.js")
286
+ .then(({ startClaudeCodeWorker }) => startClaudeCodeWorker())
287
+ .catch(() => { });
205
288
  }
206
289
  catch (error) {
207
290
  const errorMsg = String(error);
@@ -310,6 +393,11 @@ export class WebServer {
310
393
  }
311
394
  async stop() {
312
395
  this.stopHealthCheckLoop();
396
+ for (const timer of [this.stepAsideTimer, this.holdOffTimer])
397
+ if (timer)
398
+ clearTimeout(timer);
399
+ this.stepAsideTimer = null;
400
+ this.holdOffTimer = null;
313
401
  if (!this.isOwner || !this.server) {
314
402
  return;
315
403
  }
@@ -356,7 +444,7 @@ export class WebServer {
356
444
  }
357
445
  }
358
446
  // --- HTTP request handling ---
359
- async handleRequest(req) {
447
+ async handleRequest(req, remoteAddress) {
360
448
  const url = new URL(req.url);
361
449
  const path = url.pathname;
362
450
  const method = req.method;
@@ -382,6 +470,13 @@ export class WebServer {
382
470
  ? authorizeApiRequest(req, this.config.apiToken)
383
471
  : null;
384
472
  if (!this.config.apiToken || configuredTokenFailure) {
473
+ if (path === "/api/web/step-aside") {
474
+ log("Web server step-aside request", {
475
+ outcome: "refused_auth",
476
+ ownVersion: packageVersion(),
477
+ callerVersion: "",
478
+ });
479
+ }
385
480
  return (configuredTokenFailure ??
386
481
  this.jsonResponse({ success: false, error: "Unauthorized" }, 401));
387
482
  }
@@ -430,12 +525,13 @@ export class WebServer {
430
525
  return this.jsonResponse({
431
526
  pi: await readBackfillStatus("pi"),
432
527
  opencode: await readBackfillStatus("opencode"),
528
+ "claude-code": await readBackfillStatus("claude-code"),
433
529
  });
434
530
  }
435
531
  if (path === "/api/settings/backfill/runs" && method === "GET") {
436
532
  return this.jsonResponse(await (await this.backfillControls()).status());
437
533
  }
438
- const backfillAction = /^\/api\/settings\/backfill\/(pi|opencode)\/(run|pause|resume)$/.exec(path);
534
+ const backfillAction = /^\/api\/settings\/backfill\/(pi|opencode|claude-code)\/(run|pause|resume)$/.exec(path);
439
535
  if (backfillAction && method === "POST") {
440
536
  const host = backfillAction[1];
441
537
  const controls = await this.backfillControls();
@@ -446,6 +542,10 @@ export class WebServer {
446
542
  ? controls.resume(host, cwd)
447
543
  : controls.runNow(host, cwd));
448
544
  }
545
+ if (path === "/api/web/step-aside" && method === "POST") {
546
+ const body = await req.json().catch(() => null);
547
+ return this.handleStepAside(remoteAddress, body);
548
+ }
449
549
  if (path === "/api/settings/version" && method === "GET") {
450
550
  const [{ packageVersion }, { globalCommandVersion }] = await Promise.all([
451
551
  import("./package-version.js"),
@@ -573,7 +673,7 @@ export class WebServer {
573
673
  retryQueue: await countCaptureRetries(CONFIG),
574
674
  });
575
675
  }
576
- const retryNow = /^\/api\/settings\/capture-retry\/(pi|opencode)\/run$/.exec(path);
676
+ const retryNow = /^\/api\/settings\/capture-retry\/(pi|opencode|claude-code)\/run$/.exec(path);
577
677
  if (retryNow && method === "POST") {
578
678
  const [{ CONFIG }, { requestCaptureRetryNow }] = await Promise.all([
579
679
  import("../config.js"),
@@ -624,16 +724,16 @@ export class WebServer {
624
724
  return this.jsonResponse({ error: "Browsing is available only on a loopback bind; enter a path instead" }, 403);
625
725
  }
626
726
  const body = (await req.json());
627
- if (body?.host !== "pi" && body?.host !== "opencode") {
628
- return this.jsonResponse({ error: "Choose Pi or OpenCode" }, 400);
727
+ if (body?.host !== "pi" && body?.host !== "opencode" && body?.host !== "claude-code") {
728
+ return this.jsonResponse({ error: "Choose Pi, OpenCode, or Claude Code" }, 400);
629
729
  }
630
730
  const { browseImportSources } = await import("../importer/web-import-api.js");
631
731
  return this.importResponse(() => browseImportSources(body.host, body.path));
632
732
  }
633
733
  if (path === "/api/settings/imports/sources/validate" && method === "POST") {
634
734
  const body = (await req.json());
635
- if (body?.host !== "pi" && body?.host !== "opencode") {
636
- return this.jsonResponse({ error: "Choose Pi or OpenCode" }, 400);
735
+ if (body?.host !== "pi" && body?.host !== "opencode" && body?.host !== "claude-code") {
736
+ return this.jsonResponse({ error: "Choose Pi, OpenCode, or Claude Code" }, 400);
637
737
  }
638
738
  const { validateImportSource } = await import("../importer/web-import-api.js");
639
739
  return this.importResponse(() => validateImportSource(body.host, body.path));
@@ -722,6 +822,10 @@ export class WebServer {
722
822
  const result = await handleSearch(query, tag, page, pageSize);
723
823
  return this.jsonResponse(result);
724
824
  }
825
+ if ((path === "/api/claude/retrieve" || path === "/api/claude/capture") &&
826
+ method === "POST") {
827
+ return this.claudeHookResponse(req, path);
828
+ }
725
829
  if (path === "/api/stats" && method === "GET") {
726
830
  const result = await handleStats();
727
831
  return this.jsonResponse(result);
@@ -0,0 +1,3 @@
1
+ import type { MemoryHost } from "./index.js";
2
+ /** The host's display name in messages and reports. */
3
+ export declare function hostLabel(host: MemoryHost): string;
@@ -0,0 +1,9 @@
1
+ const HOST_LABELS = {
2
+ opencode: "OpenCode",
3
+ pi: "Pi",
4
+ "claude-code": "Claude Code",
5
+ };
6
+ /** The host's display name in messages and reports. */
7
+ export function hostLabel(host) {
8
+ return HOST_LABELS[host];
9
+ }
@@ -1,5 +1,5 @@
1
1
  export type MemoryType = string;
2
- export type MemoryHost = "opencode" | "pi";
2
+ export type MemoryHost = "opencode" | "pi" | "claude-code";
3
3
  export type MemorySourceType = "live-capture" | "history-import";
4
4
  export interface MemoryMetadata {
5
5
  type?: MemoryType;