om-memory-system 3.6.0-next.42 → 3.6.0-next.44

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.
@@ -0,0 +1,24 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join } from "node:path";
3
+ /** Empty, an absolute path, or `~/...`. A relative path would depend on the web app's working directory. */
4
+ export function isValidClaudeConfigDir(value) {
5
+ if (typeof value !== "string")
6
+ return false;
7
+ const trimmed = value.trim();
8
+ return trimmed === "" || trimmed.startsWith("~/") || isAbsolute(trimmed);
9
+ }
10
+ export function resolveClaudeFolder(configured, env = process.env.CLAUDE_CONFIG_DIR, home = homedir()) {
11
+ const expand = (path) => (path.startsWith("~/") ? join(home, path.slice(2)) : path);
12
+ const setting = configured?.trim();
13
+ if (setting)
14
+ return { folder: expand(setting), source: "setting" };
15
+ // A variable set without shell expansion can still hold a literal `~/`.
16
+ const fromEnv = env?.trim();
17
+ if (fromEnv)
18
+ return { folder: expand(fromEnv), source: "env" };
19
+ return { folder: join(home, ".claude"), source: "default" };
20
+ }
21
+ /** `<Claude folder>/projects`, where Claude Code writes its transcripts. */
22
+ export function claudeProjectsRoot(configured, env, home) {
23
+ return join(resolveClaudeFolder(configured, env, home).folder, "projects");
24
+ }
@@ -3,6 +3,7 @@ import { promises as fs, readFileSync, statSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { applyEdits, modify, parse } from "jsonc-parser";
5
5
  import { CONFIG_TEMPLATE, getGlobalConfigSourcePath, getGlobalConfigWritePath, validateGlobalConfig, } from "../config.js";
6
+ import { isValidClaudeConfigDir } from "./claude-folder.js";
6
7
  const keys = new Set([
7
8
  "opencodeProvider",
8
9
  "opencodeModel",
@@ -21,6 +22,7 @@ const keys = new Set([
21
22
  "memoryModel",
22
23
  "memoryApiKey",
23
24
  "importPathMaps",
25
+ "claudeConfigDir",
24
26
  ]);
25
27
  const MEMORY_PROVIDERS = [
26
28
  "openai-chat",
@@ -50,6 +52,9 @@ function isValidEdit(key, value) {
50
52
  // Entries are checked by the startup validation below.
51
53
  if (key === "importPathMaps")
52
54
  return Array.isArray(value);
55
+ // An empty value clears the field.
56
+ if (key === "claudeConfigDir")
57
+ return isValidClaudeConfigDir(value);
53
58
  return typeof value === "string" && value.trim().length > 0;
54
59
  }
55
60
  export class ConfigConflictError extends Error {
@@ -29,6 +29,11 @@ export declare function getSettingsSnapshot(directory: string): {
29
29
  pi: import("./ai/live-model-choice.js").PiLiveModelChoice;
30
30
  "claude-code": import("./ai/live-model-choice.js").AutoCaptureProviderStatus;
31
31
  };
32
+ claudeFolder: {
33
+ root: string;
34
+ source: import("./claude-folder.js").ClaudeFolderSource;
35
+ exists: boolean;
36
+ };
32
37
  };
33
38
  /**
34
39
  * Where `memoryApiKey` comes from and whether it resolves in this web
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parse } from "jsonc-parser";
4
4
  import { CONFIG, getGlobalConfigSourcePath } from "../config.js";
5
+ import { resolveClaudeFolder } from "./claude-folder.js";
5
6
  import { readGlobalConfigRevision } from "./global-config-writer.js";
6
7
  import { getAutoCaptureProviderStatus, isExternalModelReady, resolveClaudeCodeLiveModel, resolvePiLiveModel, } from "./ai/live-model-choice.js";
7
8
  const editable = [
@@ -21,6 +22,7 @@ const editable = [
21
22
  "memoryApiUrl",
22
23
  "memoryModel",
23
24
  "importPathMaps",
25
+ "claudeConfigDir",
24
26
  ];
25
27
  /** Keys a project config cannot override, so the page always shows the global value. */
26
28
  const globalOnly = [
@@ -32,6 +34,7 @@ const globalOnly = [
32
34
  "memoryProvider",
33
35
  "memoryApiUrl",
34
36
  "importPathMaps",
37
+ "claudeConfigDir",
35
38
  ];
36
39
  function readSettingsFile(path) {
37
40
  if (!path)
@@ -83,6 +86,9 @@ export function getSettingsSnapshot(directory) {
83
86
  },
84
87
  ];
85
88
  }));
89
+ const claudeSetting = global.claudeConfigDir ?? CONFIG.claudeConfigDir;
90
+ const claude = resolveClaudeFolder(typeof claudeSetting === "string" ? claudeSetting : "");
91
+ const claudeRoot = join(claude.folder, "projects");
86
92
  return {
87
93
  revision: readGlobalConfigRevision(),
88
94
  settings,
@@ -95,6 +101,8 @@ export function getSettingsSnapshot(directory) {
95
101
  // Claude Code capture uses the external API only; `issues` names each missing setting.
96
102
  "claude-code": resolveClaudeCodeLiveModel(CONFIG),
97
103
  },
104
+ // The folder Claude Code capture and import read; the check never reads transcripts.
105
+ claudeFolder: { root: claudeRoot, source: claude.source, exists: existsSync(claudeRoot) },
98
106
  };
99
107
  }
100
108
  /**
@@ -1,3 +1,4 @@
1
+ /** Each code maps to a host message or log code; none of them fails the session. */
1
2
  export type EnsureResult = "running" | "started" | "disabled" | "port-busy" | "no-runtime" | "start-timeout";
2
3
  /** Port and switch of the web app, as the caller read them from `CONFIG`. */
3
4
  export interface EnsureSettings {
@@ -14,13 +15,20 @@ type SpawnFn = (command: string, args: string[], options: {
14
15
  cwd: string;
15
16
  windowsHide: true;
16
17
  }) => SpawnedChild;
17
- /** The three file operations the start lock needs. */
18
+ /** The file operations the start lock needs. */
18
19
  export interface LockFs {
19
20
  /** Create the file only when it does not exist. Returns false when it does. */
20
21
  createExclusive(path: string, text: string): boolean;
21
22
  read(path: string): string | null;
22
23
  remove(path: string): void;
24
+ /** Hard-link `from` to `to`. False when `to` exists or `from` is missing. */
25
+ link(from: string, to: string): boolean;
26
+ /** Time since the file's change time, or null when it is missing. A link sets the change time. */
27
+ ageMs(path: string): number | null;
28
+ /** Replace the file in one step, so a reader never finds it missing. */
29
+ replace(path: string, text: string): void;
23
30
  }
31
+ /** Injected so tests run the start rule without a network, a process, or a real clock. */
24
32
  export interface EnsureDeps {
25
33
  fetch: typeof fetch;
26
34
  spawn: SpawnFn;
@@ -36,6 +44,7 @@ export interface EnsureDeps {
36
44
  pid: number;
37
45
  log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
38
46
  }
47
+ /** Hosts differ only in these values; the start rule itself has no host-specific code. */
39
48
  export interface EnsureOptions {
40
49
  settings: EnsureSettings;
41
50
  /** How long the caller may wait for the web app to answer. */
@@ -48,9 +57,18 @@ export interface EnsureOptions {
48
57
  export declare const START_LOCK_STALE_MS = 20000;
49
58
  /** Path of the start lock; the standalone command writes it for a restart copy. */
50
59
  export declare function startLockPath(home?: string): string;
60
+ /** Lock files are private to the user (0600 in a 0700 folder), like the token file. */
51
61
  export declare const nodeLockFs: LockFs;
52
62
  /** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
53
63
  export declare function removeStartLockFor(pid: number, path?: string, lockFs?: LockFs): void;
64
+ /** What taking the start lock needs; a test drives two callers through it step by step. */
65
+ export type LockDeps = Pick<EnsureDeps, "lockPath" | "lockFs" | "pid" | "now" | "pidAlive">;
66
+ /**
67
+ * Take the start lock. A stale lock is replaced by one caller only: the caller
68
+ * that hard-links it to a tombstone named after its text. Remove-then-create
69
+ * alone let two callers each remove the lock the other had just created.
70
+ */
71
+ export declare function takeStartLock(deps: LockDeps): boolean;
54
72
  /**
55
73
  * Use a running OMMS web app, or start one detached `om-memory-system web`.
56
74
  * Never throws; a failed check must not block or fail the host session.
@@ -1,5 +1,6 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
4
  import { homedir } from "node:os";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -11,6 +12,7 @@ export const START_LOCK_STALE_MS = 20_000;
11
12
  export function startLockPath(home = homedir()) {
12
13
  return join(home, ".omms", "web-start.lock");
13
14
  }
15
+ /** Lock files are private to the user (0600 in a 0700 folder), like the token file. */
14
16
  export const nodeLockFs = {
15
17
  createExclusive(path, text) {
16
18
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -35,6 +37,32 @@ export const nodeLockFs = {
35
37
  remove(path) {
36
38
  rmSync(path, { force: true });
37
39
  },
40
+ link(from, to) {
41
+ try {
42
+ linkSync(from, to);
43
+ return true;
44
+ }
45
+ catch (error) {
46
+ const code = error.code;
47
+ if (code === "EEXIST" || code === "ENOENT")
48
+ return false;
49
+ throw error;
50
+ }
51
+ },
52
+ ageMs(path) {
53
+ try {
54
+ return Date.now() - statSync(path).ctimeMs;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ },
60
+ replace(path, text) {
61
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
62
+ const temp = `${path}.${process.pid}.tmp`;
63
+ writeFileSync(temp, text, { mode: 0o600 });
64
+ renameSync(temp, path);
65
+ },
38
66
  };
39
67
  /** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
40
68
  export function removeStartLockFor(pid, path = startLockPath(), lockFs = nodeLockFs) {
@@ -104,19 +132,40 @@ function lockIsStale(text, deps) {
104
132
  return true;
105
133
  }
106
134
  }
107
- /** Take the start lock. A stale lock is removed and taken once more. */
108
- function takeLock(deps) {
135
+ /**
136
+ * Take the start lock. A stale lock is replaced by one caller only: the caller
137
+ * that hard-links it to a tombstone named after its text. Remove-then-create
138
+ * alone let two callers each remove the lock the other had just created.
139
+ */
140
+ export function takeStartLock(deps) {
109
141
  const mine = JSON.stringify({ pid: deps.pid, at: deps.now() });
110
142
  if (deps.lockFs.createExclusive(deps.lockPath, mine))
111
143
  return true;
112
144
  const seen = deps.lockFs.read(deps.lockPath);
145
+ if (seen === null)
146
+ return deps.lockFs.createExclusive(deps.lockPath, mine);
113
147
  if (!lockIsStale(seen, deps))
114
148
  return false;
115
- // Another caller may have replaced the stale lock since it was read.
116
- if (deps.lockFs.read(deps.lockPath) !== seen)
149
+ const tomb = `${deps.lockPath}.${createHash("sha256").update(seen).digest("hex").slice(0, 16)}.stale`;
150
+ if (!deps.lockFs.link(deps.lockPath, tomb)) {
151
+ // A takeover that crashed after its link leaves the tombstone behind. Clear
152
+ // it, and let the next caller do the takeover.
153
+ const age = deps.lockFs.ageMs(tomb);
154
+ if (age !== null && age > START_LOCK_STALE_MS)
155
+ deps.lockFs.remove(tomb);
117
156
  return false;
118
- deps.lockFs.remove(deps.lockPath);
119
- return deps.lockFs.createExclusive(deps.lockPath, mine);
157
+ }
158
+ try {
159
+ // A slow caller can link a lock that was replaced after it read it. That
160
+ // lock is live, so leave it.
161
+ if (deps.lockFs.read(tomb) !== seen)
162
+ return false;
163
+ deps.lockFs.remove(deps.lockPath);
164
+ return deps.lockFs.createExclusive(deps.lockPath, mine);
165
+ }
166
+ finally {
167
+ deps.lockFs.remove(tomb);
168
+ }
120
169
  }
121
170
  /**
122
171
  * Use a running OMMS web app, or start one detached `om-memory-system web`.
@@ -152,7 +201,7 @@ export async function ensureWebApp(options) {
152
201
  await logCode("no-runtime");
153
202
  return "no-runtime";
154
203
  }
155
- held = takeLock(deps);
204
+ held = takeStartLock(deps);
156
205
  if (!held)
157
206
  return await waitForOther(settings.baseUrl, wait, remaining, deps);
158
207
  // Another caller may have finished its start before this one took the lock.
@@ -58,6 +58,7 @@ export declare class WebServer {
58
58
  setOnStepAside(callback: () => void | Promise<void>): void;
59
59
  /** Standalone web apps register this to stop or restart from the page. */
60
60
  setOnPowerAction(callback: (action: PowerAction) => void | Promise<void>): void;
61
+ /** Only a local caller may stop the web app; the token alone is not enough. */
61
62
  private handlePowerAction;
62
63
  private scheduleStepAside;
63
64
  private handleStepAside;
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { createServer } from "node:http";
3
4
  import { Readable } from "node:stream";
@@ -143,6 +144,8 @@ export function nextFallbackPort(currentPort, failedTakeovers, maxFallbackPort,
143
144
  const STEP_ASIDE_HOLD_OFF_MS = 60_000;
144
145
  /** Lets the 202 reply reach the caller before the server stops. */
145
146
  const STEP_ASIDE_REPLY_GRACE_MS = 100;
147
+ /** Differs between processes, so the page can tell a restarted web app from the old one. */
148
+ const PROCESS_INSTANCE = randomUUID();
146
149
  export class WebServer {
147
150
  server = null;
148
151
  config;
@@ -217,6 +220,7 @@ export class WebServer {
217
220
  setOnPowerAction(callback) {
218
221
  this.onPowerActionCallback = callback;
219
222
  }
223
+ /** Only a local caller may stop the web app; the token alone is not enough. */
220
224
  handlePowerAction(action, remoteAddress) {
221
225
  const record = (outcome) => log("Web server power request", { action, outcome, ownVersion: packageVersion() });
222
226
  if (!isLoopbackAddress(remoteAddress)) {
@@ -440,6 +444,8 @@ export class WebServer {
440
444
  clearTimeout(timer);
441
445
  this.stepAsideTimer = null;
442
446
  this.holdOffTimer = null;
447
+ // A restart whose copy failed calls start() again on this server.
448
+ this.startPromise = null;
443
449
  if (!this.isOwner || !this.server) {
444
450
  return;
445
451
  }
@@ -591,10 +597,12 @@ export class WebServer {
591
597
  ? controls.resume(host, cwd)
592
598
  : controls.runNow(host, cwd));
593
599
  }
600
+ // The page shows the power button only when this says the caller may control the web app.
594
601
  if (path === "/api/web/status" && method === "GET") {
595
602
  return this.jsonResponse({
596
603
  version: packageVersion(),
597
604
  canControl: isLoopbackAddress(remoteAddress) && this.onPowerActionCallback !== null,
605
+ instance: PROCESS_INSTANCE,
598
606
  });
599
607
  }
600
608
  if ((path === "/api/web/stop" || path === "/api/web/restart") && method === "POST") {
@@ -785,8 +793,11 @@ export class WebServer {
785
793
  if (body?.host !== "pi" && body?.host !== "opencode" && body?.host !== "claude-code") {
786
794
  return this.jsonResponse({ error: "Choose Pi, OpenCode, or Claude Code" }, 400);
787
795
  }
788
- const { browseImportSources } = await import("../importer/web-import-api.js");
789
- return this.importResponse(() => browseImportSources(body.host, body.path));
796
+ const [{ browseImportSources }, { CONFIG }] = await Promise.all([
797
+ import("../importer/web-import-api.js"),
798
+ import("../config.js"),
799
+ ]);
800
+ return this.importResponse(() => browseImportSources(body.host, body.path, CONFIG.claudeConfigDir));
790
801
  }
791
802
  if (path === "/api/settings/imports/sources/validate" && method === "POST") {
792
803
  const body = (await req.json());