om-memory-system 4.13.0 → 4.13.3
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/README.md +6 -0
- package/dist/adapters/claude-code/hook-command.d.ts +3 -1
- package/dist/adapters/claude-code/hook-command.js +14 -0
- package/dist/adapters/claude-code/plugin-self-update.d.ts +27 -0
- package/dist/adapters/claude-code/plugin-self-update.js +103 -0
- package/dist/services/turso/cross-process-write-lock.js +55 -18
- package/dist/services/web-update.d.ts +7 -2
- package/dist/services/web-update.js +20 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,6 +55,12 @@ downloads a small embedding model (the part that makes memories searchable),
|
|
|
55
55
|
so you need internet access once. The terminal import command also needs
|
|
56
56
|
Node.js 22.14 or later.
|
|
57
57
|
|
|
58
|
+
> [!NOTE]
|
|
59
|
+
> OMMS is tested mostly on macOS and Linux. Windows runs only in the release
|
|
60
|
+
> check, so it gets less testing. If something breaks on Windows,
|
|
61
|
+
> [open an issue](https://github.com/cmdaltctr/omms/issues). If you fix it,
|
|
62
|
+
> a pull request is welcome.
|
|
63
|
+
|
|
58
64
|
## Set up
|
|
59
65
|
|
|
60
66
|
### 1. Install
|
|
@@ -4,4 +4,6 @@ import { type ClaudeStatusOptions } from "./status.js";
|
|
|
4
4
|
* `om-memory-system claude-hook <event>`. Always returns 0: a hook must never
|
|
5
5
|
* block or break a Claude Code session, whatever goes wrong.
|
|
6
6
|
*/
|
|
7
|
-
export declare function runClaudeHookCommand(argv: string[], io?: ClaudeHookOptions & ClaudeStatusOptions
|
|
7
|
+
export declare function runClaudeHookCommand(argv: string[], io?: ClaudeHookOptions & ClaudeStatusOptions & {
|
|
8
|
+
skipSelfUpdate?: boolean;
|
|
9
|
+
}): Promise<number>;
|
|
@@ -9,6 +9,18 @@ async function printStatus(io) {
|
|
|
9
9
|
const status = await buildClaudeStatus({ ...inputs, fetch: io.fetch ?? globalThis.fetch });
|
|
10
10
|
(io.writeStdout ?? ((text) => process.stdout.write(text)))(`${JSON.stringify(status)}\n`);
|
|
11
11
|
}
|
|
12
|
+
/** Update the Claude plugin in the background when npm has a newer release. Never throws. */
|
|
13
|
+
async function selfUpdate(io) {
|
|
14
|
+
try {
|
|
15
|
+
const { startPluginSelfUpdate, pluginSelfUpdateDeps } = await import("./plugin-self-update.js");
|
|
16
|
+
const log = io.log ??
|
|
17
|
+
(async (message, data) => (await import("../../services/logger.js")).log(message, data));
|
|
18
|
+
await startPluginSelfUpdate(pluginSelfUpdateDeps(log));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* The memory hook already ran; a failed update check changes nothing. */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
12
24
|
/**
|
|
13
25
|
* `om-memory-system claude-hook <event>`. Always returns 0: a hook must never
|
|
14
26
|
* block or break a Claude Code session, whatever goes wrong.
|
|
@@ -22,6 +34,8 @@ export async function runClaudeHookCommand(argv, io = {}) {
|
|
|
22
34
|
}
|
|
23
35
|
if (isHookEvent(event)) {
|
|
24
36
|
await runClaudeHook(event, io);
|
|
37
|
+
if (event === "session-start" && !io.skipSelfUpdate)
|
|
38
|
+
await selfUpdate(io);
|
|
25
39
|
return 0;
|
|
26
40
|
}
|
|
27
41
|
// Only a known name reaches the log; argv is not trusted text.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A second session does not start another update for the same release within this time. */
|
|
2
|
+
export declare const SELF_UPDATE_RETRY_MS: number;
|
|
3
|
+
export type PluginSelfUpdateCode = "started" | "current" | "recent" | "disabled" | "no-plugin" | "unreadable-plugin" | "unreachable" | "spawn-failed";
|
|
4
|
+
export interface PluginSelfUpdateDeps {
|
|
5
|
+
/** False when `OMMS_DISABLE_UPDATE_CHECK` is `1`. */
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
/** `CLAUDE_PLUGIN_ROOT`; undefined outside a Claude plugin. */
|
|
8
|
+
pluginRoot: string | undefined;
|
|
9
|
+
readPluginVersion: (root: string) => string;
|
|
10
|
+
fetch: typeof fetch;
|
|
11
|
+
now: () => number;
|
|
12
|
+
readMarker: () => {
|
|
13
|
+
version: string;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
} | null;
|
|
16
|
+
writeMarker: (value: {
|
|
17
|
+
version: string;
|
|
18
|
+
startedAt: number;
|
|
19
|
+
}) => void;
|
|
20
|
+
/** Start a process that outlives this hook. */
|
|
21
|
+
spawnDetached: (command: string, args: string[]) => void;
|
|
22
|
+
log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** Start a background plugin update when npm has a newer release. Never throws. */
|
|
25
|
+
export declare function startPluginSelfUpdate(deps: PluginSelfUpdateDeps): Promise<PluginSelfUpdateCode>;
|
|
26
|
+
/** Production inputs: the real plugin manifest, npm, and a marker file in ~/.omms. */
|
|
27
|
+
export declare function pluginSelfUpdateDeps(log: PluginSelfUpdateDeps["log"], env?: NodeJS.ProcessEnv): PluginSelfUpdateDeps;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { availableUpdate, latestNpmVersion } from "../../services/update-check.js";
|
|
6
|
+
// Claude Code installs the OMMS plugin from the `stable` branch, but its own
|
|
7
|
+
// update pass runs at its own time and can leave a new session on the old
|
|
8
|
+
// plugin. At session start OMMS compares the plugin with npm `latest` and, when
|
|
9
|
+
// it is older, runs Claude Code's own update commands in the background. The
|
|
10
|
+
// new copy loads in the next session or after /reload-plugins.
|
|
11
|
+
/** A second session does not start another update for the same release within this time. */
|
|
12
|
+
export const SELF_UPDATE_RETRY_MS = 30 * 60 * 1000;
|
|
13
|
+
/** Node script that refreshes the marketplace, then updates the plugin. Run detached. */
|
|
14
|
+
const UPDATE_SCRIPT = `
|
|
15
|
+
const { spawnSync } = require("node:child_process");
|
|
16
|
+
const shell = process.platform === "win32";
|
|
17
|
+
for (const args of [["plugin", "marketplace", "update", "omms"], ["plugin", "update", "omms@omms"]]) {
|
|
18
|
+
const run = spawnSync("claude", args, { stdio: "ignore", shell, timeout: 120000 });
|
|
19
|
+
if (run.status !== 0) process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
`;
|
|
22
|
+
/** Start a background plugin update when npm has a newer release. Never throws. */
|
|
23
|
+
export async function startPluginSelfUpdate(deps) {
|
|
24
|
+
const code = await decide(deps);
|
|
25
|
+
if (code.code === "started" || code.code === "spawn-failed") {
|
|
26
|
+
await deps.log("Claude plugin self-update", {
|
|
27
|
+
code: code.code,
|
|
28
|
+
plugin: code.plugin,
|
|
29
|
+
latest: code.latest,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return code.code;
|
|
33
|
+
}
|
|
34
|
+
async function decide(deps) {
|
|
35
|
+
if (!deps.enabled)
|
|
36
|
+
return { code: "disabled" };
|
|
37
|
+
if (!deps.pluginRoot)
|
|
38
|
+
return { code: "no-plugin" };
|
|
39
|
+
let plugin;
|
|
40
|
+
try {
|
|
41
|
+
plugin = deps.readPluginVersion(deps.pluginRoot);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { code: "unreadable-plugin" };
|
|
45
|
+
}
|
|
46
|
+
const latest = await latestNpmVersion(deps.fetch);
|
|
47
|
+
if (latest === null)
|
|
48
|
+
return { code: "unreachable" };
|
|
49
|
+
const newer = availableUpdate(plugin, latest);
|
|
50
|
+
if (!newer)
|
|
51
|
+
return { code: "current" };
|
|
52
|
+
const marker = deps.readMarker();
|
|
53
|
+
const now = deps.now();
|
|
54
|
+
if (marker?.version === newer && now - marker.startedAt <= SELF_UPDATE_RETRY_MS) {
|
|
55
|
+
return { code: "recent" };
|
|
56
|
+
}
|
|
57
|
+
// Write first, so a session that starts during the update does not start another.
|
|
58
|
+
deps.writeMarker({ version: newer, startedAt: now });
|
|
59
|
+
try {
|
|
60
|
+
deps.spawnDetached(process.execPath, ["-e", UPDATE_SCRIPT]);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return { code: "spawn-failed", plugin, latest: newer };
|
|
64
|
+
}
|
|
65
|
+
return { code: "started", plugin, latest: newer };
|
|
66
|
+
}
|
|
67
|
+
/** Production inputs: the real plugin manifest, npm, and a marker file in ~/.omms. */
|
|
68
|
+
export function pluginSelfUpdateDeps(log, env = process.env) {
|
|
69
|
+
const markerPath = join(homedir(), ".omms", "claude-plugin-update.json");
|
|
70
|
+
return {
|
|
71
|
+
enabled: env.OMMS_DISABLE_UPDATE_CHECK !== "1",
|
|
72
|
+
pluginRoot: env.CLAUDE_PLUGIN_ROOT || undefined,
|
|
73
|
+
readPluginVersion: (root) => {
|
|
74
|
+
const manifest = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"));
|
|
75
|
+
if (typeof manifest.version !== "string")
|
|
76
|
+
throw new Error("no version");
|
|
77
|
+
return manifest.version;
|
|
78
|
+
},
|
|
79
|
+
fetch: globalThis.fetch,
|
|
80
|
+
now: Date.now,
|
|
81
|
+
readMarker: () => {
|
|
82
|
+
try {
|
|
83
|
+
const value = JSON.parse(readFileSync(markerPath, "utf8"));
|
|
84
|
+
return typeof value.version === "string" && typeof value.startedAt === "number"
|
|
85
|
+
? { version: value.version, startedAt: value.startedAt }
|
|
86
|
+
: null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
writeMarker: (value) => {
|
|
93
|
+
mkdirSync(dirname(markerPath), { recursive: true });
|
|
94
|
+
writeFileSync(markerPath, JSON.stringify(value));
|
|
95
|
+
},
|
|
96
|
+
spawnDetached: (command, args) => {
|
|
97
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
98
|
+
child.on("error", () => { });
|
|
99
|
+
child.unref();
|
|
100
|
+
},
|
|
101
|
+
log,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { CONFIG } from "../../config.js";
|
|
4
5
|
/**
|
|
@@ -51,6 +52,26 @@ function readLiveLock(path) {
|
|
|
51
52
|
function lockFilePath(scope, hash) {
|
|
52
53
|
return join(CONFIG.storagePath, WRITE_LOCK_DIR, `${scope}_${hash}.lock`);
|
|
53
54
|
}
|
|
55
|
+
async function publishLock(candidate, path) {
|
|
56
|
+
// Match the bounded Windows hard-link retries used by the web start lock (TDR-034).
|
|
57
|
+
const waits = [1, 2, 5, 10, 20, 50, 100, 200];
|
|
58
|
+
for (let attempt = 0;; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
linkSync(candidate, path);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const code = error.code;
|
|
65
|
+
if (process.platform !== "win32" ||
|
|
66
|
+
!code ||
|
|
67
|
+
!["EPERM", "EACCES", "EBUSY"].includes(code) ||
|
|
68
|
+
waits[attempt] === undefined) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, waits[attempt]));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
54
75
|
export async function withCrossProcessWriteLock(scope, hash, fn) {
|
|
55
76
|
const dir = join(CONFIG.storagePath, WRITE_LOCK_DIR);
|
|
56
77
|
if (!existsSync(dir)) {
|
|
@@ -59,31 +80,47 @@ export async function withCrossProcessWriteLock(scope, hash, fn) {
|
|
|
59
80
|
const path = lockFilePath(scope, hash);
|
|
60
81
|
const state = { pid: process.pid, timestamp: new Date().toISOString() };
|
|
61
82
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
const candidate = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
84
|
+
let acquired = false;
|
|
85
|
+
try {
|
|
86
|
+
// Exclusive open exposes an empty file before its PID is written. Publish
|
|
87
|
+
// the complete payload with a hard link, which cannot replace a live lock.
|
|
88
|
+
writeFileSync(candidate, JSON.stringify(state), { flag: "wx" });
|
|
89
|
+
for (;;) {
|
|
90
|
+
try {
|
|
91
|
+
await publishLock(candidate, path);
|
|
92
|
+
acquired = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code !== "EEXIST")
|
|
97
|
+
throw error;
|
|
98
|
+
const holder = readLiveLock(path);
|
|
99
|
+
if (!holder)
|
|
100
|
+
continue;
|
|
101
|
+
if (Date.now() > deadline) {
|
|
102
|
+
throw new Error(`Timed out acquiring the cross-process write lock for ${scope}/${hash}: ` +
|
|
103
|
+
`held by pid ${holder.pid} since ${holder.timestamp}`, { cause: error });
|
|
104
|
+
}
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS + Math.random() * 10));
|
|
74
106
|
}
|
|
75
|
-
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS + Math.random() * 10));
|
|
76
107
|
}
|
|
77
|
-
}
|
|
78
|
-
try {
|
|
79
108
|
return await fn();
|
|
80
109
|
}
|
|
81
110
|
finally {
|
|
111
|
+
if (acquired) {
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(path);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Best effort: a stale file is cleaned up by the next acquirer.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
82
119
|
try {
|
|
83
|
-
unlinkSync(
|
|
120
|
+
unlinkSync(candidate);
|
|
84
121
|
}
|
|
85
122
|
catch {
|
|
86
|
-
//
|
|
123
|
+
// A failed publication must not leave its temporary payload behind.
|
|
87
124
|
}
|
|
88
125
|
}
|
|
89
126
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const UPDATE_CHECK_INTERVAL_MS: number;
|
|
2
|
+
/** An open page triggers a new check once the last one is this old. */
|
|
3
|
+
export declare const STATUS_RECHECK_AFTER_MS: number;
|
|
2
4
|
/** An install that takes longer is stopped, and the web app keeps serving. */
|
|
3
5
|
export declare const INSTALL_TIMEOUT_MS: number;
|
|
4
6
|
export type UpdateState = "idle" | "installing" | "restarting" | "failed";
|
|
@@ -60,11 +62,14 @@ export declare class WebUpdate {
|
|
|
60
62
|
private state;
|
|
61
63
|
private code;
|
|
62
64
|
private started;
|
|
65
|
+
private lastCheck;
|
|
66
|
+
private checking;
|
|
63
67
|
constructor(deps: WebUpdateDeps);
|
|
64
|
-
/** Check now, then every
|
|
68
|
+
/** Check now, then every 10 minutes. A second call does nothing. */
|
|
65
69
|
start(): void;
|
|
66
|
-
/** Read npm `latest`. A failed read keeps the last result. */
|
|
70
|
+
/** Read npm `latest`. A failed read keeps the last result. Calls during a read share it. */
|
|
67
71
|
check(): Promise<void>;
|
|
72
|
+
private read;
|
|
68
73
|
status(): UpdateStatus;
|
|
69
74
|
private canInstall;
|
|
70
75
|
/** Start the install and the restart. A request while one runs joins it. */
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { posix, win32 } from "node:path";
|
|
2
2
|
import { availableUpdate, latestNpmVersion } from "./update-check.js";
|
|
3
|
-
// The web app's npm release check. It reads npm `latest` at start
|
|
4
|
-
//
|
|
3
|
+
// The web app's npm release check. It reads npm `latest` at start, every 10
|
|
4
|
+
// minutes, and when an open page reads the status and the last check is over a
|
|
5
|
+
// minute old. It keeps the result in memory for `GET /api/web/status`. It imports
|
|
5
6
|
// no host adapter and no config, so tests pass every input.
|
|
6
|
-
export const UPDATE_CHECK_INTERVAL_MS =
|
|
7
|
+
export const UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
|
8
|
+
/** An open page triggers a new check once the last one is this old. */
|
|
9
|
+
export const STATUS_RECHECK_AFTER_MS = 60 * 1000;
|
|
7
10
|
/** An install that takes longer is stopped, and the web app keeps serving. */
|
|
8
11
|
export const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
12
|
/** Only a plain release reaches the npm arguments; the value comes from the registry. */
|
|
@@ -24,10 +27,12 @@ export class WebUpdate {
|
|
|
24
27
|
state = "idle";
|
|
25
28
|
code = null;
|
|
26
29
|
started = false;
|
|
30
|
+
lastCheck = -Infinity;
|
|
31
|
+
checking = null;
|
|
27
32
|
constructor(deps) {
|
|
28
33
|
this.deps = deps;
|
|
29
34
|
}
|
|
30
|
-
/** Check now, then every
|
|
35
|
+
/** Check now, then every 10 minutes. A second call does nothing. */
|
|
31
36
|
start() {
|
|
32
37
|
if (this.started || !this.deps.enabled)
|
|
33
38
|
return;
|
|
@@ -35,10 +40,15 @@ export class WebUpdate {
|
|
|
35
40
|
void this.check();
|
|
36
41
|
this.deps.setInterval(() => void this.check(), UPDATE_CHECK_INTERVAL_MS).unref?.();
|
|
37
42
|
}
|
|
38
|
-
/** Read npm `latest`. A failed read keeps the last result. */
|
|
39
|
-
|
|
43
|
+
/** Read npm `latest`. A failed read keeps the last result. Calls during a read share it. */
|
|
44
|
+
check() {
|
|
40
45
|
if (!this.deps.enabled)
|
|
41
|
-
return;
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
this.checking ??= this.read().finally(() => (this.checking = null));
|
|
48
|
+
return this.checking;
|
|
49
|
+
}
|
|
50
|
+
async read() {
|
|
51
|
+
this.lastCheck = this.deps.now();
|
|
42
52
|
const latest = await latestNpmVersion(this.deps.fetch);
|
|
43
53
|
if (latest === null) {
|
|
44
54
|
this.deps.log("Web app update check failed", { code: "unreachable" });
|
|
@@ -47,6 +57,9 @@ export class WebUpdate {
|
|
|
47
57
|
this.available = availableUpdate(this.deps.version, latest);
|
|
48
58
|
}
|
|
49
59
|
status() {
|
|
60
|
+
// An open page polls the status, so a release shows within about a minute.
|
|
61
|
+
if (this.deps.now() - this.lastCheck > STATUS_RECHECK_AFTER_MS)
|
|
62
|
+
void this.check();
|
|
50
63
|
return {
|
|
51
64
|
available: this.available,
|
|
52
65
|
state: this.state,
|