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.
- package/README.md +24 -16
- package/dist/adapters/claude-code/hook-client.d.ts +73 -0
- package/dist/adapters/claude-code/hook-client.js +285 -0
- package/dist/adapters/claude-code/hook-command.d.ts +6 -0
- package/dist/adapters/claude-code/hook-command.js +25 -0
- package/dist/cli/index.js +19 -3
- package/dist/cli/memory-command.d.ts +24 -0
- package/dist/cli/memory-command.js +180 -0
- package/dist/cli/web-command.d.ts +8 -1
- package/dist/cli/web-command.js +119 -3
- package/dist/core/retrieval.d.ts +21 -0
- package/dist/core/retrieval.js +35 -0
- package/dist/importer/backfill-controls.d.ts +10 -0
- package/dist/importer/backfill-controls.js +40 -11
- package/dist/importer/backfill-model.d.ts +1 -1
- package/dist/importer/backfill-model.js +3 -0
- package/dist/importer/claude-conversation.d.ts +74 -0
- package/dist/importer/claude-conversation.js +216 -0
- package/dist/importer/claude-hook-api.d.ts +50 -0
- package/dist/importer/claude-hook-api.js +441 -0
- package/dist/importer/claude-import.d.ts +15 -0
- package/dist/importer/claude-import.js +62 -0
- package/dist/importer/claude-reader.d.ts +99 -0
- package/dist/importer/claude-reader.js +292 -0
- package/dist/importer/external-backfill-models.js +2 -2
- package/dist/importer/import-args.d.ts +1 -1
- package/dist/importer/import-args.js +11 -4
- package/dist/importer/import-readiness.d.ts +13 -1
- package/dist/importer/import-readiness.js +19 -0
- package/dist/importer/import-runs.js +1 -1
- package/dist/importer/import-sessions.d.ts +4 -1
- package/dist/importer/import-sessions.js +14 -2
- package/dist/importer/import-sources.d.ts +8 -2
- package/dist/importer/import-sources.js +21 -4
- package/dist/importer/map-suggestions.d.ts +1 -0
- package/dist/importer/map-suggestions.js +6 -1
- package/dist/importer/run-import.js +14 -10
- package/dist/importer/web-import-jobs.js +10 -3
- package/dist/index.js +9 -23
- package/dist/services/ai/live-model-choice.d.ts +5 -0
- package/dist/services/ai/live-model-choice.js +7 -0
- package/dist/services/backfill-state.d.ts +1 -1
- package/dist/services/capture-retry-queue.js +3 -2
- package/dist/services/claude-capture-cursor.d.ts +10 -0
- package/dist/services/claude-capture-cursor.js +44 -0
- package/dist/services/settings-snapshot.d.ts +1 -0
- package/dist/services/settings-snapshot.js +3 -1
- package/dist/services/version-compare.d.ts +8 -0
- package/dist/services/version-compare.js +58 -0
- package/dist/services/web-api-auth.d.ts +2 -0
- package/dist/services/web-api-auth.js +10 -0
- package/dist/services/web-server.d.ts +11 -0
- package/dist/services/web-server.js +114 -10
- package/dist/types/host-label.d.ts +3 -0
- package/dist/types/host-label.js +9 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/web/assets/index-BFiVixOK.js +124 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- 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
|
|
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
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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;
|
|
@@ -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
|
|
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
|
|
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);
|
package/dist/types/index.d.ts
CHANGED