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.
- package/dist/cli/web-command.js +9 -1
- package/dist/cli/web-power.d.ts +20 -5
- package/dist/cli/web-power.js +106 -26
- package/dist/config.d.ts +5 -1
- package/dist/config.js +11 -0
- package/dist/importer/claude-hook-api.js +3 -1
- package/dist/importer/claude-reader.d.ts +2 -2
- package/dist/importer/claude-reader.js +6 -6
- package/dist/importer/import-readiness.js +1 -1
- package/dist/importer/import-sessions.js +1 -1
- package/dist/importer/import-sources.d.ts +4 -8
- package/dist/importer/import-sources.js +10 -11
- package/dist/importer/run-import.js +11 -1
- package/dist/services/claude-folder.d.ts +16 -0
- package/dist/services/claude-folder.js +24 -0
- package/dist/services/global-config-writer.js +5 -0
- package/dist/services/settings-snapshot.d.ts +5 -0
- package/dist/services/settings-snapshot.js +8 -0
- package/dist/services/web-ensure.d.ts +19 -1
- package/dist/services/web-ensure.js +57 -8
- package/dist/services/web-server.d.ts +1 -0
- package/dist/services/web-server.js +13 -2
- package/dist/web/assets/index-Bh5aFNPX.js +124 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-Ctzj_Zg0.js +0 -124
|
@@ -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
|
|
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 {
|
|
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
|
-
/**
|
|
108
|
-
|
|
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
|
-
|
|
116
|
-
if (deps.lockFs.
|
|
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
|
-
|
|
119
|
-
|
|
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 =
|
|
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
|
|
789
|
-
|
|
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());
|