om-memory-system 3.6.0-next.40 → 3.6.0-next.43
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/core/memory-operations.js +2 -0
- package/dist/importer/claude-hook-api.js +42 -3
- package/dist/importer/claude-reader.d.ts +1 -0
- package/dist/importer/claude-reader.js +3 -1
- package/dist/services/api-handlers.js +1 -1
- package/dist/services/client.d.ts +1 -0
- package/dist/services/client.js +1 -0
- package/dist/services/turso/types.d.ts +1 -0
- package/dist/services/turso/vector-search.js +3 -2
- 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 +8 -0
- package/dist/web/assets/index-BtJzyEtW.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
package/dist/cli/web-command.js
CHANGED
|
@@ -173,7 +173,7 @@ export async function runWebCommand(args, options = {}, online, handover = {}) {
|
|
|
173
173
|
});
|
|
174
174
|
console.log(`OMMS web app: ${server.getUrl()}`);
|
|
175
175
|
// The Node HTTP adapter unrefs its socket for plugin use. Keep the CLI alive.
|
|
176
|
-
|
|
176
|
+
let keeper = setInterval(() => { }, 60_000);
|
|
177
177
|
const stop = () => {
|
|
178
178
|
clearInterval(keeper);
|
|
179
179
|
process.off("SIGINT", stop);
|
|
@@ -191,6 +191,14 @@ export async function runWebCommand(args, options = {}, online, handover = {}) {
|
|
|
191
191
|
process.off("SIGTERM", stop);
|
|
192
192
|
await server.stop();
|
|
193
193
|
},
|
|
194
|
+
// A restart whose copy failed serves again from this process.
|
|
195
|
+
resumeServer: async () => {
|
|
196
|
+
keeper = setInterval(() => { }, 60_000);
|
|
197
|
+
process.once("SIGINT", stop);
|
|
198
|
+
process.once("SIGTERM", stop);
|
|
199
|
+
await server.start();
|
|
200
|
+
},
|
|
201
|
+
baseUrl: server.getUrl(),
|
|
194
202
|
loginItem: action === "--login-item",
|
|
195
203
|
autostart: options,
|
|
196
204
|
}));
|
package/dist/cli/web-power.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type { WebAutostartOptions } from "../services/web-autostart.js";
|
|
2
|
+
/** The two choices the page offers; the web server passes them through unchanged. */
|
|
2
3
|
export type PowerAction = "stop" | "restart";
|
|
3
4
|
interface SpawnedChild {
|
|
4
5
|
pid?: number;
|
|
5
6
|
unref(): void;
|
|
6
|
-
on(event: "error", listener: (
|
|
7
|
+
on(event: "error" | "exit", listener: () => void): unknown;
|
|
8
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
7
9
|
}
|
|
10
|
+
/** How long the old process waits for the copy to answer before it serves again. */
|
|
11
|
+
export declare const HANDOFF_WAIT_MS = 15000;
|
|
12
|
+
/** Injected so tests check the restart order without spawning or exiting. */
|
|
8
13
|
export interface PowerDeps {
|
|
9
14
|
spawn: (command: string, args: string[], options: {
|
|
10
15
|
detached: true;
|
|
@@ -16,6 +21,12 @@ export interface PowerDeps {
|
|
|
16
21
|
restartLoginItem: () => boolean | Promise<boolean>;
|
|
17
22
|
/** Write the start lock, naming `pid`, so a host that starts meanwhile waits. */
|
|
18
23
|
writeStartLock: (pid: number) => void | Promise<void>;
|
|
24
|
+
/** Remove the start lock when it names `pid`. */
|
|
25
|
+
removeStartLock: (pid: number) => void | Promise<void>;
|
|
26
|
+
/** True when an OMMS web app answers health at `baseUrl`. */
|
|
27
|
+
probe: (baseUrl: string) => Promise<boolean>;
|
|
28
|
+
sleep: (ms: number) => Promise<void>;
|
|
29
|
+
now: () => number;
|
|
19
30
|
exit: (code: number) => void;
|
|
20
31
|
/** The runtime and arguments of this process, for a detached copy. */
|
|
21
32
|
execPath: string;
|
|
@@ -26,13 +37,17 @@ export interface PowerDeps {
|
|
|
26
37
|
}
|
|
27
38
|
/**
|
|
28
39
|
* The callback the web server runs after it replies to Stop or Restart.
|
|
29
|
-
* `stopServer` stops serving and any keep-alive timer
|
|
30
|
-
* copy on the same port: the service manager for the
|
|
31
|
-
* copy otherwise. A detached child of the login item
|
|
32
|
-
* launchd job or systemd control group.
|
|
40
|
+
* `stopServer` stops serving and any keep-alive timer; `resumeServer` undoes it.
|
|
41
|
+
* Restart starts a fresh copy on the same port: the service manager for the
|
|
42
|
+
* login item, and a detached copy otherwise. A detached child of the login item
|
|
43
|
+
* would die with its launchd job or systemd control group. A restart never
|
|
44
|
+
* leaves the port empty because the copy failed: the old process serves again.
|
|
33
45
|
*/
|
|
34
46
|
export declare function createPowerAction(options: {
|
|
35
47
|
stopServer: () => Promise<void>;
|
|
48
|
+
resumeServer: () => Promise<void>;
|
|
49
|
+
/** The URL this web app serves, where the copy must answer after the handoff. */
|
|
50
|
+
baseUrl: string;
|
|
36
51
|
loginItem: boolean;
|
|
37
52
|
autostart?: WebAutostartOptions;
|
|
38
53
|
deps?: Partial<PowerDeps>;
|
package/dist/cli/web-power.js
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
+
/** How long a fresh copy must stay up before the old process stops serving. */
|
|
4
|
+
const SPAWN_CHECK_MS = 1_000;
|
|
5
|
+
/** How long the old process waits for the copy to answer before it serves again. */
|
|
6
|
+
export const HANDOFF_WAIT_MS = 15_000;
|
|
7
|
+
const HANDOFF_POLL_MS = 250;
|
|
3
8
|
function productionDeps(options) {
|
|
4
9
|
return {
|
|
5
10
|
spawn: nodeSpawn,
|
|
6
11
|
restartLoginItem: async () => (await import("../services/web-autostart.js")).restartWebAutostart(options),
|
|
7
12
|
writeStartLock: async (pid) => {
|
|
8
13
|
const { nodeLockFs, startLockPath } = await import("../services/web-ensure.js");
|
|
9
|
-
|
|
10
|
-
nodeLockFs.remove(path);
|
|
11
|
-
nodeLockFs.createExclusive(path, JSON.stringify({ pid, at: Date.now() }));
|
|
14
|
+
nodeLockFs.replace(startLockPath(), JSON.stringify({ pid, at: Date.now() }));
|
|
12
15
|
},
|
|
16
|
+
removeStartLock: async (pid) => (await import("../services/web-ensure.js")).removeStartLockFor(pid),
|
|
17
|
+
probe: async (baseUrl) => {
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(`${baseUrl}/api/health`, {
|
|
20
|
+
signal: AbortSignal.timeout(1_000),
|
|
21
|
+
});
|
|
22
|
+
const body = (await response.json());
|
|
23
|
+
return response.ok && body.success === true && body.status === "ok";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
30
|
+
now: () => Date.now(),
|
|
13
31
|
exit: (code) => process.exit(code),
|
|
14
32
|
execPath: process.execPath,
|
|
15
33
|
args: process.argv.slice(1),
|
|
@@ -20,42 +38,104 @@ function productionDeps(options) {
|
|
|
20
38
|
}
|
|
21
39
|
/**
|
|
22
40
|
* The callback the web server runs after it replies to Stop or Restart.
|
|
23
|
-
* `stopServer` stops serving and any keep-alive timer
|
|
24
|
-
* copy on the same port: the service manager for the
|
|
25
|
-
* copy otherwise. A detached child of the login item
|
|
26
|
-
* launchd job or systemd control group.
|
|
41
|
+
* `stopServer` stops serving and any keep-alive timer; `resumeServer` undoes it.
|
|
42
|
+
* Restart starts a fresh copy on the same port: the service manager for the
|
|
43
|
+
* login item, and a detached copy otherwise. A detached child of the login item
|
|
44
|
+
* would die with its launchd job or systemd control group. A restart never
|
|
45
|
+
* leaves the port empty because the copy failed: the old process serves again.
|
|
27
46
|
*/
|
|
28
47
|
export function createPowerAction(options) {
|
|
29
48
|
const deps = { ...productionDeps(options.autostart ?? {}), ...options.deps };
|
|
49
|
+
const logFailure = (code) => Promise.resolve(deps.log("Web app restart failed", { code })).catch(() => undefined);
|
|
50
|
+
let stopped = false;
|
|
51
|
+
/** Start the copy, check it, stop serving, and wait until the copy answers. */
|
|
52
|
+
async function handOff() {
|
|
53
|
+
let child;
|
|
54
|
+
try {
|
|
55
|
+
child = deps.spawn(deps.execPath, deps.args, {
|
|
56
|
+
detached: true,
|
|
57
|
+
stdio: "ignore",
|
|
58
|
+
cwd: deps.cwd,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return giveUp("spawn-error");
|
|
64
|
+
}
|
|
65
|
+
let failed = false;
|
|
66
|
+
let exited = false;
|
|
67
|
+
child.on("error", () => (failed = true));
|
|
68
|
+
child.on("exit", () => (exited = true));
|
|
69
|
+
child.unref();
|
|
70
|
+
const copyPid = child.pid;
|
|
71
|
+
if (!copyPid)
|
|
72
|
+
return giveUp("spawn-error");
|
|
73
|
+
// A missing script or a broken runtime ends the copy within this time.
|
|
74
|
+
await deps.sleep(SPAWN_CHECK_MS);
|
|
75
|
+
if (failed)
|
|
76
|
+
return giveUp("spawn-error");
|
|
77
|
+
if (exited)
|
|
78
|
+
return giveUp("copy-exit");
|
|
79
|
+
await deps.writeStartLock(copyPid);
|
|
80
|
+
if (!stopped) {
|
|
81
|
+
await options.stopServer();
|
|
82
|
+
stopped = true;
|
|
83
|
+
}
|
|
84
|
+
// The copy waits as a non-owner and takes the port within about 7 seconds.
|
|
85
|
+
const began = deps.now();
|
|
86
|
+
while (deps.now() - began < HANDOFF_WAIT_MS) {
|
|
87
|
+
if (exited)
|
|
88
|
+
return giveUp("copy-exit", copyPid);
|
|
89
|
+
if (await deps.probe(options.baseUrl))
|
|
90
|
+
return deps.exit(0);
|
|
91
|
+
await deps.sleep(HANDOFF_POLL_MS);
|
|
92
|
+
}
|
|
93
|
+
if (exited)
|
|
94
|
+
return giveUp("copy-exit", copyPid);
|
|
95
|
+
// A copy that never answers would wait as a non-owner for ever.
|
|
96
|
+
child.kill("SIGTERM");
|
|
97
|
+
return giveUp("handoff", copyPid);
|
|
98
|
+
}
|
|
99
|
+
/** Keep or resume serving on the old process, and release the lock it wrote. */
|
|
100
|
+
async function giveUp(code, copyPid) {
|
|
101
|
+
if (copyPid !== undefined)
|
|
102
|
+
await deps.removeStartLock(copyPid);
|
|
103
|
+
else if (!options.loginItem)
|
|
104
|
+
await deps.removeStartLock(deps.pid);
|
|
105
|
+
await logFailure(code);
|
|
106
|
+
if (!stopped)
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
await options.resumeServer();
|
|
110
|
+
stopped = false;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
await logFailure(error instanceof Error ? error.name : "resume-error");
|
|
114
|
+
deps.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
30
117
|
return async (action) => {
|
|
31
118
|
if (action === "stop") {
|
|
32
119
|
await options.stopServer();
|
|
33
120
|
deps.exit(0);
|
|
34
121
|
return;
|
|
35
122
|
}
|
|
123
|
+
stopped = false;
|
|
36
124
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
125
|
+
if (options.loginItem) {
|
|
126
|
+
await options.stopServer();
|
|
127
|
+
stopped = true;
|
|
128
|
+
if (await deps.restartLoginItem())
|
|
129
|
+
return deps.exit(0);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Hold other hosts off until the copy owns the port.
|
|
39
133
|
await deps.writeStartLock(deps.pid);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return deps.exit(0);
|
|
43
|
-
const child = deps.spawn(deps.execPath, deps.args, {
|
|
44
|
-
detached: true,
|
|
45
|
-
stdio: "ignore",
|
|
46
|
-
cwd: deps.cwd,
|
|
47
|
-
windowsHide: true,
|
|
48
|
-
});
|
|
49
|
-
child.on("error", () => undefined);
|
|
50
|
-
child.unref();
|
|
51
|
-
if (child.pid)
|
|
52
|
-
await deps.writeStartLock(child.pid);
|
|
134
|
+
}
|
|
135
|
+
await handOff();
|
|
53
136
|
}
|
|
54
137
|
catch (error) {
|
|
55
|
-
await
|
|
56
|
-
code: error instanceof Error ? error.name : "unknown",
|
|
57
|
-
})).catch(() => undefined);
|
|
138
|
+
await giveUp(error instanceof Error ? error.name : "unknown");
|
|
58
139
|
}
|
|
59
|
-
deps.exit(0);
|
|
60
140
|
};
|
|
61
141
|
}
|
|
@@ -12,6 +12,7 @@ function formatSearchResults(query, results, limit) {
|
|
|
12
12
|
results: memoryResults.slice(0, limit || 10).map((result) => ({
|
|
13
13
|
id: result.id,
|
|
14
14
|
content: result.memory || result.chunk,
|
|
15
|
+
type: result.type,
|
|
15
16
|
similarity: Math.round(result.similarity * 100),
|
|
16
17
|
})),
|
|
17
18
|
};
|
|
@@ -198,6 +199,7 @@ export async function executeMemoryOperation(args, context) {
|
|
|
198
199
|
memories: result.memories?.map((memory) => ({
|
|
199
200
|
id: memory.id,
|
|
200
201
|
content: memory.summary,
|
|
202
|
+
type: memory.type,
|
|
201
203
|
createdAt: memory.createdAt,
|
|
202
204
|
})),
|
|
203
205
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { isAbsolute } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
4
|
import { CONFIG, refreshConfigIfChanged } from "../config.js";
|
|
4
5
|
import { captureConversation } from "../core/capture.js";
|
|
5
6
|
import { classifyCaptureFailure, quickRetryDelayMs } from "../core/capture-retry-policy.js";
|
|
@@ -12,6 +13,7 @@ import { log } from "../services/logger.js";
|
|
|
12
13
|
import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js";
|
|
13
14
|
import { getTags } from "../services/tags.js";
|
|
14
15
|
import { extractClaudeWindowsAfter, parseClaudeTranscript, } from "./claude-conversation.js";
|
|
16
|
+
import { defaultClaudeProjectsRoot } from "./claude-reader.js";
|
|
15
17
|
// The web app's side of the Claude Code hooks (design decisions 4, 6, 7):
|
|
16
18
|
// retrieval for SessionStart and UserPromptSubmit, a one-worker capture queue
|
|
17
19
|
// for Stop, profile learning, and the once-per-process backfill start. Logs
|
|
@@ -61,6 +63,41 @@ function parseRetrieveRequest(body) {
|
|
|
61
63
|
prompt: optionalString(input, "prompt"),
|
|
62
64
|
};
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolves symlinks and returns the letter case on disk. Node's plain
|
|
68
|
+
* `realpathSync` keeps the case it is given, which fails the root check on a
|
|
69
|
+
* case-insensitive file system. A path that does not exist yet resolves
|
|
70
|
+
* through its nearest existing parent.
|
|
71
|
+
*/
|
|
72
|
+
function realPathOrResolved(path) {
|
|
73
|
+
const absolute = resolve(path);
|
|
74
|
+
try {
|
|
75
|
+
return realpathSync.native(absolute);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
const parent = dirname(absolute);
|
|
79
|
+
if (parent === absolute)
|
|
80
|
+
return absolute;
|
|
81
|
+
return join(realPathOrResolved(parent), basename(absolute));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Claude Code writes `<projects root>/<folder>/<session_id>.jsonl`. Accept only
|
|
86
|
+
* that shape, so the route cannot be used to read other `.jsonl` files. Returns
|
|
87
|
+
* the resolved path, which the capture then reads.
|
|
88
|
+
*/
|
|
89
|
+
function checkedTranscriptPath(transcriptPath, sessionId) {
|
|
90
|
+
const fileName = `${sessionId}.jsonl`;
|
|
91
|
+
const real = realPathOrResolved(transcriptPath);
|
|
92
|
+
if (basename(transcriptPath) !== fileName || basename(real) !== fileName) {
|
|
93
|
+
throw new ClaudeHookRequestError("transcript_path must be named <session_id>.jsonl");
|
|
94
|
+
}
|
|
95
|
+
const root = realPathOrResolved(defaultClaudeProjectsRoot());
|
|
96
|
+
if (!real.startsWith(root + sep)) {
|
|
97
|
+
throw new ClaudeHookRequestError("transcript_path must be under the Claude projects folder");
|
|
98
|
+
}
|
|
99
|
+
return real;
|
|
100
|
+
}
|
|
64
101
|
function parseCaptureRequest(body) {
|
|
65
102
|
const input = objectBody(body);
|
|
66
103
|
const transcriptPath = input.transcript_path;
|
|
@@ -69,9 +106,11 @@ function parseCaptureRequest(body) {
|
|
|
69
106
|
!transcriptPath.endsWith(".jsonl")) {
|
|
70
107
|
throw new ClaudeHookRequestError("transcript_path must be an absolute .jsonl path");
|
|
71
108
|
}
|
|
109
|
+
const { sessionId, cwd } = sessionAndCwd(input);
|
|
72
110
|
return {
|
|
73
|
-
|
|
74
|
-
|
|
111
|
+
sessionId,
|
|
112
|
+
cwd,
|
|
113
|
+
transcriptPath: checkedTranscriptPath(transcriptPath, sessionId),
|
|
75
114
|
lastAssistantMessage: optionalString(input, "last_assistant_message"),
|
|
76
115
|
stopHookActive: input.stop_hook_active === true,
|
|
77
116
|
};
|
|
@@ -16,6 +16,7 @@ import { type ImportPathMap, type ImportProjectReport, type LazyImportSource } f
|
|
|
16
16
|
* time.
|
|
17
17
|
*/
|
|
18
18
|
export declare const CLAUDE_HEADER_READ_LIMIT: number;
|
|
19
|
+
/** `$CLAUDE_CONFIG_DIR/projects` when the variable is set, else `~/.claude/projects`. */
|
|
19
20
|
export declare function defaultClaudeProjectsRoot(): string;
|
|
20
21
|
export interface DiscoveredClaudeSession {
|
|
21
22
|
file: string;
|
|
@@ -23,8 +23,10 @@ import { countImportableUnits, projectFilterTag, selectImportWindows, } from "./
|
|
|
23
23
|
*/
|
|
24
24
|
export const CLAUDE_HEADER_READ_LIMIT = 1024 * 1024;
|
|
25
25
|
const READ_CHUNK = 64 * 1024;
|
|
26
|
+
/** `$CLAUDE_CONFIG_DIR/projects` when the variable is set, else `~/.claude/projects`. */
|
|
26
27
|
export function defaultClaudeProjectsRoot() {
|
|
27
|
-
|
|
28
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
29
|
+
return join(configDir || join(homedir(), ".claude"), "projects");
|
|
28
30
|
}
|
|
29
31
|
/** A missing folder is an empty history. Any other read error is reported, not hidden. */
|
|
30
32
|
function readEntries(dir, unreadable) {
|
|
@@ -496,7 +496,7 @@ export async function handleSearch(query, tag, page = 1, pageSize = 20) {
|
|
|
496
496
|
type: "memory",
|
|
497
497
|
id: r.id,
|
|
498
498
|
content: r.memory,
|
|
499
|
-
memoryType: r.metadata?.type,
|
|
499
|
+
memoryType: r.type ?? r.metadata?.type,
|
|
500
500
|
tags: r.tags,
|
|
501
501
|
createdAt: safeToISOString(r.createdAt ?? r.metadata?.createdAt),
|
|
502
502
|
updatedAt: r.metadata?.updatedAt ? safeToISOString(r.metadata.updatedAt) : undefined,
|
package/dist/services/client.js
CHANGED
|
@@ -204,6 +204,7 @@ export class LocalMemoryClient {
|
|
|
204
204
|
const memories = allMemories.slice(0, limit).map((r) => ({
|
|
205
205
|
id: r.id,
|
|
206
206
|
summary: r.content,
|
|
207
|
+
type: r.type ? String(r.type) : undefined,
|
|
207
208
|
createdAt: safeToISOString(r.created_at),
|
|
208
209
|
metadata: safeJSONParse(r.metadata),
|
|
209
210
|
displayName: r.display_name,
|
|
@@ -109,7 +109,7 @@ export class TursoVectorSearch {
|
|
|
109
109
|
// hits still get a full hybrid score (missing ANN side is not forced to 0).
|
|
110
110
|
const rows = await db.all(containerTag === ""
|
|
111
111
|
? `
|
|
112
|
-
SELECT id, content, tags, created_at, metadata, container_tag,
|
|
112
|
+
SELECT id, content, tags, type, created_at, metadata, container_tag,
|
|
113
113
|
display_name, user_name, user_email, project_path, project_name,
|
|
114
114
|
git_repo_url, is_pinned,
|
|
115
115
|
vector_distance_cos(vector, vector32(?)) AS content_dist,
|
|
@@ -120,7 +120,7 @@ export class TursoVectorSearch {
|
|
|
120
120
|
WHERE id IN (${placeholders})
|
|
121
121
|
`
|
|
122
122
|
: `
|
|
123
|
-
SELECT id, content, tags, created_at, metadata, container_tag,
|
|
123
|
+
SELECT id, content, tags, type, created_at, metadata, container_tag,
|
|
124
124
|
display_name, user_name, user_email, project_path, project_name,
|
|
125
125
|
git_repo_url, is_pinned,
|
|
126
126
|
vector_distance_cos(vector, vector32(?)) AS content_dist,
|
|
@@ -158,6 +158,7 @@ export class TursoVectorSearch {
|
|
|
158
158
|
similarity,
|
|
159
159
|
createdAt: Number(row.created_at),
|
|
160
160
|
tags: memoryTagsStr ? memoryTagsStr.split(",") : [],
|
|
161
|
+
type: row.type ? String(row.type) : undefined,
|
|
161
162
|
metadata: parseMetadata(row.metadata),
|
|
162
163
|
containerTag: String(row.container_tag),
|
|
163
164
|
displayName: row.display_name ? String(row.display_name) : undefined,
|
|
@@ -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") {
|