memorix 1.3.3 → 1.3.4
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/CHANGELOG.md +14 -0
- package/dist/cli/index.js +1162 -1098
- package/dist/index.d.ts +513 -0
- package/dist/index.js +14751 -14044
- package/dist/maintenance-runner.js +105 -52
- package/dist/media-video-runner.js +105 -52
- package/dist/memcode-runtime/CHANGELOG.md +14 -0
- package/dist/sdk.d.ts +5 -513
- package/dist/sdk.js +186 -75
- package/dist/vector-backfill-runner.js +105 -52
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -3
- package/package.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/media.ts +10 -2
- package/src/cli/commands/serve-http.ts +7 -38
- package/src/cli/commands/serve-shared.ts +18 -46
- package/src/cli/commands/serve.ts +3 -41
- package/src/cli/commands/setup.ts +24 -21
- package/src/cli/mcp-root-path.ts +36 -0
- package/src/index.ts +37 -55
- package/src/media/asset-store.ts +74 -6
- package/src/media/attachment.ts +12 -9
- package/src/media/media-store.ts +38 -4
- package/src/media/video-jobs.ts +39 -14
- package/src/memory/observations.ts +21 -2
- package/src/orchestrate/adapters/spawn-helper.ts +2 -0
- package/src/server/tool-profile.ts +7 -4
- package/src/server.ts +6 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
1
3
|
import type { ProjectInfo } from '../../types.js';
|
|
2
4
|
|
|
3
5
|
export interface ResolveServeProjectOptions {
|
|
@@ -6,7 +8,6 @@ export interface ResolveServeProjectOptions {
|
|
|
6
8
|
initCwd?: string;
|
|
7
9
|
processCwd: string;
|
|
8
10
|
homeDir: string;
|
|
9
|
-
lastKnownProjectRoot?: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface ResolveServeProjectDeps {
|
|
@@ -18,7 +19,7 @@ export interface ResolveServeProjectDeps {
|
|
|
18
19
|
export interface ServeProjectResolution {
|
|
19
20
|
projectRoot: string;
|
|
20
21
|
detectedProject: ProjectInfo | null;
|
|
21
|
-
source: 'direct' | 'subdir' | '
|
|
22
|
+
source: 'direct' | 'subdir' | 'unresolved';
|
|
22
23
|
messages: string[];
|
|
23
24
|
error?: string;
|
|
24
25
|
}
|
|
@@ -45,6 +46,21 @@ export function resolveServeProject(
|
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
const hasExplicitRoot = Boolean(options.cwdArg || options.envProjectRoot || options.initCwd);
|
|
50
|
+
const startsAtHome = path.resolve(projectRoot) === path.resolve(options.homeDir);
|
|
51
|
+
if (deps.isSystemDirectory(projectRoot) || (!hasExplicitRoot && startsAtHome)) {
|
|
52
|
+
messages.push(`[memorix] Unreliable launch directory detected: ${projectRoot}`);
|
|
53
|
+
messages.push('[memorix] Memorix will wait for MCP Roots or an explicit memorix_session_start projectRoot.');
|
|
54
|
+
messages.push('[memorix] It will not restore a previous project automatically, to prevent cross-project memory access.');
|
|
55
|
+
return {
|
|
56
|
+
projectRoot,
|
|
57
|
+
detectedProject: null,
|
|
58
|
+
source: 'unresolved',
|
|
59
|
+
messages,
|
|
60
|
+
error: 'No reliable git project was provided by the launcher.',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
const subGit = deps.findGitInSubdirs(projectRoot);
|
|
49
65
|
if (subGit) {
|
|
50
66
|
projectRoot = subGit;
|
|
@@ -60,50 +76,6 @@ export function resolveServeProject(
|
|
|
60
76
|
}
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
if (deps.isSystemDirectory(projectRoot)) {
|
|
64
|
-
messages.push(`[memorix] System directory detected: ${projectRoot}`);
|
|
65
|
-
messages.push('[memorix] Your IDE launched memorix from a non-workspace directory.');
|
|
66
|
-
messages.push('[memorix] Fix: add --cwd to your MCP config, or use an IDE/client that exposes workspace roots.');
|
|
67
|
-
|
|
68
|
-
if (options.lastKnownProjectRoot) {
|
|
69
|
-
detected = deps.detectProject(options.lastKnownProjectRoot);
|
|
70
|
-
if (detected) {
|
|
71
|
-
messages.push(`[memorix] Restored last known project: ${options.lastKnownProjectRoot}`);
|
|
72
|
-
return {
|
|
73
|
-
projectRoot: options.lastKnownProjectRoot,
|
|
74
|
-
detectedProject: detected,
|
|
75
|
-
source: 'last-known',
|
|
76
|
-
messages,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
detected = deps.detectProject(options.homeDir);
|
|
82
|
-
if (detected) {
|
|
83
|
-
messages.push(`[memorix] Restored project from home directory: ${options.homeDir}`);
|
|
84
|
-
return {
|
|
85
|
-
projectRoot: options.homeDir,
|
|
86
|
-
detectedProject: detected,
|
|
87
|
-
source: 'home',
|
|
88
|
-
messages,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const homeSubGit = deps.findGitInSubdirs(options.homeDir);
|
|
93
|
-
if (homeSubGit) {
|
|
94
|
-
detected = deps.detectProject(homeSubGit);
|
|
95
|
-
if (detected) {
|
|
96
|
-
messages.push(`[memorix] Found .git in home subdirectory: ${homeSubGit}`);
|
|
97
|
-
return {
|
|
98
|
-
projectRoot: homeSubGit,
|
|
99
|
-
detectedProject: detected,
|
|
100
|
-
source: 'home-subdir',
|
|
101
|
-
messages,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
79
|
messages.push('[memorix] Unable to establish a reliable git-backed project context.');
|
|
108
80
|
messages.push('[memorix] Memorix now refuses to silently fall back to untracked/* in stdio mode.');
|
|
109
81
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { defineCommand } from 'citty';
|
|
6
6
|
import { resolveToolProfile } from '../../server/tool-profile.js';
|
|
7
|
+
import { mcpFileUriToPath } from '../mcp-root-path.js';
|
|
7
8
|
|
|
8
9
|
export default defineCommand({
|
|
9
10
|
meta: {
|
|
@@ -45,18 +46,6 @@ export default defineCommand({
|
|
|
45
46
|
// Priority: explicit --cwd arg > MEMORIX_PROJECT_ROOT env > INIT_CWD (npm lifecycle) > process.cwd()
|
|
46
47
|
let safeCwd: string;
|
|
47
48
|
try { safeCwd = process.cwd(); } catch { safeCwd = homedir(); }
|
|
48
|
-
const { existsSync, readFileSync } = await import('node:fs');
|
|
49
|
-
const path = await import('node:path');
|
|
50
|
-
const lastRootFile = path.join(homedir(), '.memorix', 'last-project-root');
|
|
51
|
-
let lastKnownProjectRoot: string | undefined;
|
|
52
|
-
if (existsSync(lastRootFile)) {
|
|
53
|
-
try {
|
|
54
|
-
const lastRoot = readFileSync(lastRootFile, 'utf-8').trim();
|
|
55
|
-
if (lastRoot && existsSync(lastRoot)) {
|
|
56
|
-
lastKnownProjectRoot = lastRoot;
|
|
57
|
-
}
|
|
58
|
-
} catch { /* ignore read errors */ }
|
|
59
|
-
}
|
|
60
49
|
|
|
61
50
|
const resolution = resolveServeProject(
|
|
62
51
|
{
|
|
@@ -65,7 +54,6 @@ export default defineCommand({
|
|
|
65
54
|
initCwd: process.env.INIT_CWD,
|
|
66
55
|
processCwd: safeCwd,
|
|
67
56
|
homeDir: homedir(),
|
|
68
|
-
lastKnownProjectRoot,
|
|
69
57
|
},
|
|
70
58
|
{ detectProject, findGitInSubdirs, isSystemDirectory },
|
|
71
59
|
);
|
|
@@ -84,16 +72,6 @@ export default defineCommand({
|
|
|
84
72
|
const detected = resolution.detectedProject;
|
|
85
73
|
const projectRoot = resolution.projectRoot;
|
|
86
74
|
|
|
87
|
-
// Persist successful project root for future system-directory fallback
|
|
88
|
-
if (detected) {
|
|
89
|
-
try {
|
|
90
|
-
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
91
|
-
const memorixDir = path.join(homedir(), '.memorix');
|
|
92
|
-
mkdirSync(memorixDir, { recursive: true });
|
|
93
|
-
writeFileSync(path.join(memorixDir, 'last-project-root'), detected.rootPath, 'utf-8');
|
|
94
|
-
} catch { /* non-critical */ }
|
|
95
|
-
}
|
|
96
|
-
|
|
97
75
|
// Always register ALL tools BEFORE connecting transport.
|
|
98
76
|
// This ensures tools/list returns the full tool set immediately on connect.
|
|
99
77
|
// When no project detected, use deferred binding (allowUntrackedFallback=false, deferProjectInitUntilBound=true)
|
|
@@ -113,16 +91,6 @@ export default defineCommand({
|
|
|
113
91
|
// After connect, request workspace roots from the client (IDE).
|
|
114
92
|
// This is the proper way to discover the user's workspace —
|
|
115
93
|
// no --cwd needed if the IDE supports roots capability.
|
|
116
|
-
const persistRoot = async (rootPath: string) => {
|
|
117
|
-
try {
|
|
118
|
-
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
119
|
-
const pathMod = await import('node:path');
|
|
120
|
-
const memorixDir = pathMod.join(homedir(), '.memorix');
|
|
121
|
-
mkdirSync(memorixDir, { recursive: true });
|
|
122
|
-
writeFileSync(pathMod.join(memorixDir, 'last-project-root'), rootPath, 'utf-8');
|
|
123
|
-
} catch { /* non-critical */ }
|
|
124
|
-
};
|
|
125
|
-
|
|
126
94
|
const tryRootsSwitch = async () => {
|
|
127
95
|
try {
|
|
128
96
|
const { roots } = await server.server.listRoots();
|
|
@@ -130,18 +98,14 @@ export default defineCommand({
|
|
|
130
98
|
|
|
131
99
|
for (const root of roots) {
|
|
132
100
|
if (!root.uri.startsWith('file://')) continue;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// Windows: file:///E:/... → E:/...
|
|
136
|
-
if (/^\/[A-Za-z]:/.test(rootPath)) rootPath = rootPath.slice(1);
|
|
137
|
-
rootPath = rootPath.replace(/\//g, '\\'); // normalize to Windows backslashes
|
|
101
|
+
const rootPath = mcpFileUriToPath(root.uri);
|
|
102
|
+
if (!rootPath) continue;
|
|
138
103
|
|
|
139
104
|
const rootDetected = detectProject(rootPath);
|
|
140
105
|
if (rootDetected) {
|
|
141
106
|
const switched = await switchProject(rootPath);
|
|
142
107
|
if (switched) {
|
|
143
108
|
console.error(`[memorix] [UPDATED] Project updated via MCP roots: ${rootDetected.id}`);
|
|
144
|
-
await persistRoot(rootDetected.rootPath);
|
|
145
109
|
}
|
|
146
110
|
return; // use first valid root
|
|
147
111
|
}
|
|
@@ -151,8 +115,6 @@ export default defineCommand({
|
|
|
151
115
|
const switched = await switchProject(subGit);
|
|
152
116
|
if (switched) {
|
|
153
117
|
console.error(`[memorix] [UPDATED] Project updated via MCP roots (subdir): ${subGit}`);
|
|
154
|
-
const subDetected = detectProject(subGit);
|
|
155
|
-
if (subDetected) await persistRoot(subDetected.rootPath);
|
|
156
118
|
}
|
|
157
119
|
return;
|
|
158
120
|
}
|
|
@@ -560,30 +560,33 @@ export async function removeLegacyCodexMemorixMcpConfig(
|
|
|
560
560
|
return { configPath, removed: true };
|
|
561
561
|
}
|
|
562
562
|
|
|
563
|
-
function collectHookCommands(value: unknown, commands: string[]): void {
|
|
564
|
-
if (Array.isArray(value)) {
|
|
565
|
-
for (const item of value) collectHookCommands(item, commands);
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
if (!value || typeof value !== 'object') return;
|
|
569
|
-
|
|
570
|
-
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
571
|
-
if ((key === 'command' || key === 'commandWindows') && typeof child === 'string') {
|
|
572
|
-
commands.push(child);
|
|
573
|
-
} else {
|
|
574
|
-
collectHookCommands(child, commands);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
563
|
function isOwnedLegacyCodexHooks(content: string): boolean {
|
|
580
564
|
try {
|
|
581
565
|
const parsed = JSON.parse(content) as { hooks?: unknown };
|
|
582
|
-
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
566
|
+
if (!parsed.hooks || typeof parsed.hooks !== 'object' || Array.isArray(parsed.hooks)) return false;
|
|
567
|
+
// There was no ownership marker in the old project-local file. Treat any
|
|
568
|
+
// timeout, matcher, extra field, or extra top-level metadata as user-owned
|
|
569
|
+
// rather than guessing from a command prefix and deleting their config.
|
|
570
|
+
if (Object.keys(parsed).some((key) => key !== 'hooks')) return false;
|
|
571
|
+
const hooks = parsed.hooks as Record<string, unknown>;
|
|
572
|
+
const allowedEntryKeys = new Set(['type', 'command', 'commandWindows']);
|
|
573
|
+
let entryCount = 0;
|
|
574
|
+
for (const entries of Object.values(hooks)) {
|
|
575
|
+
if (!Array.isArray(entries) || entries.length === 0) return false;
|
|
576
|
+
for (const entry of entries) {
|
|
577
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
|
|
578
|
+
const record = entry as Record<string, unknown>;
|
|
579
|
+
if (Object.keys(record).some((key) => !allowedEntryKeys.has(key))) return false;
|
|
580
|
+
if (record.type !== 'command') return false;
|
|
581
|
+
const commands = [record.command, record.commandWindows]
|
|
582
|
+
.filter((command): command is string => typeof command === 'string');
|
|
583
|
+
if (commands.length === 0 || !commands.every((command) => /^memorix(?:\.cmd)?\s+hook(?:\s|$)/i.test(command.trim()))) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
entryCount += 1;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return entryCount > 0;
|
|
587
590
|
} catch {
|
|
588
591
|
return false;
|
|
589
592
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert an MCP file URI to a path for the host platform. MCP Roots always
|
|
5
|
+
* use file URIs; replacing slashes unconditionally corrupts POSIX roots.
|
|
6
|
+
*/
|
|
7
|
+
export function mcpFileUriToPath(uri: string, platform: NodeJS.Platform = process.platform): string | null {
|
|
8
|
+
let parsed: URL;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(uri);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
if (parsed.protocol !== 'file:') return null;
|
|
15
|
+
|
|
16
|
+
let pathname: string;
|
|
17
|
+
try {
|
|
18
|
+
pathname = decodeURIComponent(parsed.pathname);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hostname = parsed.hostname;
|
|
24
|
+
if (platform === 'win32') {
|
|
25
|
+
if (hostname && hostname !== 'localhost') {
|
|
26
|
+
return path.win32.normalize(`\\\\${hostname}${pathname.replace(/\//g, '\\')}`);
|
|
27
|
+
}
|
|
28
|
+
if (/^\/[A-Za-z]:/.test(pathname)) pathname = pathname.slice(1);
|
|
29
|
+
return path.win32.normalize(pathname.replace(/\//g, '\\'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (hostname && hostname !== 'localhost') {
|
|
33
|
+
return path.posix.normalize(`//${hostname}${pathname}`);
|
|
34
|
+
}
|
|
35
|
+
return path.posix.normalize(pathname);
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,68 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Package entry point.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* node dist/index.js # Start as MCP server (stdio)
|
|
11
|
-
* memorix init # CLI: Initialize project (P1)
|
|
12
|
-
* memorix sync # CLI: Sync rules across agents (P2)
|
|
6
|
+
* `memorix serve` is the documented MCP command. Keep the historical
|
|
7
|
+
* `node dist/index.js` route working by delegating to that exact runtime,
|
|
8
|
+
* while making a normal package import side-effect free.
|
|
13
9
|
*/
|
|
14
10
|
|
|
15
|
-
import
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
async function main(): Promise<void> {
|
|
19
|
-
const { homedir } = await import('node:os');
|
|
20
|
-
const { existsSync, readFileSync } = await import('node:fs');
|
|
21
|
-
const path = await import('node:path');
|
|
22
|
-
const { detectProject, findGitInSubdirs, isSystemDirectory } = await import('./project/detector.js');
|
|
23
|
-
const { resolveServeProject } = await import('./cli/commands/serve-shared.js');
|
|
24
|
-
|
|
25
|
-
let safeCwd: string;
|
|
26
|
-
try { safeCwd = process.cwd(); } catch { safeCwd = homedir(); }
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
27
13
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (existsSync(lastRootFile)) {
|
|
31
|
-
try {
|
|
32
|
-
const lastRoot = readFileSync(lastRootFile, 'utf-8').trim();
|
|
33
|
-
if (lastRoot && existsSync(lastRoot)) {
|
|
34
|
-
lastKnownProjectRoot = lastRoot;
|
|
35
|
-
}
|
|
36
|
-
} catch { /* ignore */ }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const resolution = resolveServeProject(
|
|
40
|
-
{
|
|
41
|
-
processCwd: safeCwd,
|
|
42
|
-
homeDir: homedir(),
|
|
43
|
-
lastKnownProjectRoot,
|
|
44
|
-
},
|
|
45
|
-
{ detectProject, findGitInSubdirs, isSystemDirectory },
|
|
46
|
-
);
|
|
14
|
+
export { createMemorixServer } from './server.js';
|
|
15
|
+
export type { CreateMemorixServerOptions } from './server.js';
|
|
47
16
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
17
|
+
function isDirectEntrypoint(): boolean {
|
|
18
|
+
const entry = process.argv[1];
|
|
19
|
+
return Boolean(entry && import.meta.url === pathToFileURL(path.resolve(entry)).href);
|
|
20
|
+
}
|
|
51
21
|
|
|
52
|
-
|
|
53
|
-
|
|
22
|
+
function directServeArgs(argv: string[]): { cwd?: string; mode?: string; 'allow-untracked': boolean } {
|
|
23
|
+
const args: { cwd?: string; mode?: string; 'allow-untracked': boolean } = { 'allow-untracked': false };
|
|
24
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
25
|
+
const value = argv[index];
|
|
26
|
+
if (value === '--allow-untracked') {
|
|
27
|
+
args['allow-untracked'] = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if ((value === '--cwd' || value === '--mode') && argv[index + 1]) {
|
|
31
|
+
args[value.slice(2) as 'cwd' | 'mode'] = argv[index + 1];
|
|
32
|
+
index += 1;
|
|
33
|
+
}
|
|
54
34
|
}
|
|
35
|
+
return args;
|
|
36
|
+
}
|
|
55
37
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
console.error(`[memorix] MCP Server running on stdio (project: ${projectId})`);
|
|
62
|
-
deferredInit().catch(e => console.error(`[memorix] Deferred init error:`, e));
|
|
38
|
+
async function runDirectStdioServer(): Promise<void> {
|
|
39
|
+
const { default: serveCommand } = await import('./cli/commands/serve.js');
|
|
40
|
+
const run = serveCommand.run as ((input: { args: ReturnType<typeof directServeArgs> }) => Promise<void>) | undefined;
|
|
41
|
+
if (!run) throw new Error('Memorix stdio server command is unavailable');
|
|
42
|
+
await run({ args: directServeArgs(process.argv.slice(2)) });
|
|
63
43
|
}
|
|
64
44
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
45
|
+
if (isDirectEntrypoint()) {
|
|
46
|
+
runDirectStdioServer().catch((error) => {
|
|
47
|
+
console.error('[memorix] Fatal error:', error);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/media/asset-store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { copyFile, lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { copyFile, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { sanitizeCredentials } from '../memory/secret-filter.js';
|
|
@@ -273,7 +273,7 @@ export async function removeMediaAsset(input: {
|
|
|
273
273
|
projectId: string;
|
|
274
274
|
assetId: string;
|
|
275
275
|
force?: boolean;
|
|
276
|
-
}): Promise<{ asset: MediaAsset; detachedLinks: number }> {
|
|
276
|
+
}): Promise<{ asset: MediaAsset; detachedLinks: number; pendingDelete: boolean }> {
|
|
277
277
|
const store = new MediaStore(input.dataDir);
|
|
278
278
|
const asset = store.getAsset(input.projectId, input.assetId);
|
|
279
279
|
if (!asset) throw new Error(`Media asset not found: ${input.assetId}`);
|
|
@@ -305,21 +305,81 @@ export async function removeMediaAsset(input: {
|
|
|
305
305
|
// A locked file can be retried by a later cleanup; it is already outside the
|
|
306
306
|
// active asset tree and no database row refers to it anymore.
|
|
307
307
|
await rm(stagedPath, { force: true }).catch(() => {});
|
|
308
|
-
return { asset, detachedLinks };
|
|
308
|
+
return { asset, detachedLinks, pendingDelete: await destinationExists(stagedPath) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface MediaTrashCleanupResult {
|
|
312
|
+
reclaimedBytes: number;
|
|
313
|
+
pendingBytes: number;
|
|
314
|
+
pendingFiles: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Retry files that were atomically staged after a metadata deletion but were
|
|
318
|
+
* locked by Windows or another process. Only Memorix's own staging suffix is
|
|
319
|
+
* considered, so cleanup never sweeps arbitrary files from the data directory.
|
|
320
|
+
*/
|
|
321
|
+
export async function cleanupMediaTrash(dataDir: string): Promise<MediaTrashCleanupResult> {
|
|
322
|
+
const root = mediaRoot(dataDir);
|
|
323
|
+
const trashDir = validateUnderRoot(root, path.join(root, '.trash'));
|
|
324
|
+
let entries: string[];
|
|
325
|
+
try {
|
|
326
|
+
entries = await readdir(trashDir);
|
|
327
|
+
} catch (error: any) {
|
|
328
|
+
if (error?.code === 'ENOENT') return { reclaimedBytes: 0, pendingBytes: 0, pendingFiles: 0 };
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let reclaimedBytes = 0;
|
|
333
|
+
let pendingBytes = 0;
|
|
334
|
+
let pendingFiles = 0;
|
|
335
|
+
for (const name of entries) {
|
|
336
|
+
if (!name.endsWith('.pending-delete')) continue;
|
|
337
|
+
const candidate = validateUnderRoot(trashDir, path.join(trashDir, name));
|
|
338
|
+
try {
|
|
339
|
+
const metadata = await lstat(candidate);
|
|
340
|
+
if (!metadata.isFile()) continue;
|
|
341
|
+
await rm(candidate, { force: true });
|
|
342
|
+
reclaimedBytes += metadata.size;
|
|
343
|
+
} catch (error: any) {
|
|
344
|
+
if (error?.code === 'ENOENT') continue;
|
|
345
|
+
try {
|
|
346
|
+
pendingBytes += (await stat(candidate)).size;
|
|
347
|
+
} catch { /* The file disappeared or cannot be measured; keep its count. */ }
|
|
348
|
+
pendingFiles += 1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { reclaimedBytes, pendingBytes, pendingFiles };
|
|
309
352
|
}
|
|
310
353
|
|
|
311
354
|
export async function cleanupMediaQuota(input: {
|
|
312
355
|
dataDir: string;
|
|
313
356
|
projectId: string;
|
|
314
357
|
maxBytes?: number;
|
|
315
|
-
}): Promise<{
|
|
358
|
+
}): Promise<{
|
|
359
|
+
beforeBytes: number;
|
|
360
|
+
afterBytes: number;
|
|
361
|
+
removed: MediaAsset[];
|
|
362
|
+
reclaimedTrashBytes: number;
|
|
363
|
+
pendingTrashBytes: number;
|
|
364
|
+
pendingTrashFiles: number;
|
|
365
|
+
}> {
|
|
316
366
|
const store = new MediaStore(input.dataDir);
|
|
317
367
|
const maxBytes = input.maxBytes ?? DEFAULT_MEDIA_QUOTA_BYTES;
|
|
318
368
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error('maxBytes must be a non-negative integer');
|
|
369
|
+
const initialTrash = await cleanupMediaTrash(input.dataDir);
|
|
319
370
|
const beforeBytes = store.activeByteSize(input.projectId);
|
|
320
371
|
let currentBytes = beforeBytes;
|
|
321
372
|
const removed: MediaAsset[] = [];
|
|
322
|
-
if (currentBytes <= maxBytes)
|
|
373
|
+
if (currentBytes <= maxBytes) {
|
|
374
|
+
return {
|
|
375
|
+
beforeBytes,
|
|
376
|
+
afterBytes: currentBytes,
|
|
377
|
+
removed,
|
|
378
|
+
reclaimedTrashBytes: initialTrash.reclaimedBytes,
|
|
379
|
+
pendingTrashBytes: initialTrash.pendingBytes,
|
|
380
|
+
pendingTrashFiles: initialTrash.pendingFiles,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
323
383
|
|
|
324
384
|
// Fail closed: all candidates come from the link-aware query. A query error leaves all assets intact.
|
|
325
385
|
const candidates = store.listUnlinkedAssets(input.projectId);
|
|
@@ -334,5 +394,13 @@ export async function cleanupMediaQuota(input: {
|
|
|
334
394
|
currentBytes -= asset.byteSize;
|
|
335
395
|
removed.push(asset);
|
|
336
396
|
}
|
|
337
|
-
|
|
397
|
+
const finalTrash = await cleanupMediaTrash(input.dataDir);
|
|
398
|
+
return {
|
|
399
|
+
beforeBytes,
|
|
400
|
+
afterBytes: Math.max(0, currentBytes),
|
|
401
|
+
removed,
|
|
402
|
+
reclaimedTrashBytes: initialTrash.reclaimedBytes + finalTrash.reclaimedBytes,
|
|
403
|
+
pendingTrashBytes: finalTrash.pendingBytes,
|
|
404
|
+
pendingTrashFiles: finalTrash.pendingFiles,
|
|
405
|
+
};
|
|
338
406
|
}
|
package/src/media/attachment.ts
CHANGED
|
@@ -19,9 +19,9 @@ export interface AttachMediaAssetInput {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Create normal text retrieval evidence
|
|
23
|
-
* The media row remains the binary source of truth;
|
|
24
|
-
* small, auditable retrieval projection.
|
|
22
|
+
* Create normal text retrieval evidence and its explicit asset link in the
|
|
23
|
+
* same SQLite transaction. The media row remains the binary source of truth;
|
|
24
|
+
* the Observation is only a small, auditable retrieval projection.
|
|
25
25
|
*/
|
|
26
26
|
export async function attachMediaAssetToObservation(input: AttachMediaAssetInput): Promise<Observation> {
|
|
27
27
|
const asset = input.asset;
|
|
@@ -34,6 +34,7 @@ export async function attachMediaAssetToObservation(input: AttachMediaAssetInput
|
|
|
34
34
|
`MIME type: ${asset.mimeType}`,
|
|
35
35
|
...(input.facts ?? []),
|
|
36
36
|
];
|
|
37
|
+
const store = new MediaStore(input.dataDir);
|
|
37
38
|
const result = await storeObservation({
|
|
38
39
|
entityName: input.entityName?.trim() || sourceLabel.replace(/\.[^.]+$/, '') || 'media-asset',
|
|
39
40
|
type: 'discovery',
|
|
@@ -47,12 +48,14 @@ export async function attachMediaAssetToObservation(input: AttachMediaAssetInput
|
|
|
47
48
|
...(input.visibility ? { visibility: input.visibility } : {}),
|
|
48
49
|
...(input.createdByAgentId ? { createdByAgentId: input.createdByAgentId } : {}),
|
|
49
50
|
...(input.visibilityReader ? { visibilityReader: input.visibilityReader } : {}),
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
onPersisted: (observation) => {
|
|
52
|
+
store.linkAsset({
|
|
53
|
+
projectId: input.projectId,
|
|
54
|
+
assetId: asset.id,
|
|
55
|
+
observationId: observation.id,
|
|
56
|
+
role: input.role ?? 'attachment',
|
|
57
|
+
});
|
|
58
|
+
},
|
|
56
59
|
});
|
|
57
60
|
return result.observation;
|
|
58
61
|
}
|
package/src/media/media-store.ts
CHANGED
|
@@ -242,6 +242,13 @@ export class MediaStore {
|
|
|
242
242
|
`).run(projectId, assetId).changes ?? 0);
|
|
243
243
|
this.db.prepare('DELETE FROM media_derivations WHERE project_id = ? AND asset_id = ?').run(projectId, assetId);
|
|
244
244
|
this.db.prepare('DELETE FROM media_embeddings WHERE project_id = ? AND asset_id = ?').run(projectId, assetId);
|
|
245
|
+
// Assets are soft-deleted so SQLite's ON DELETE SET NULL does not fire.
|
|
246
|
+
// Do not leave completed jobs advertising an asset that `media show` can
|
|
247
|
+
// no longer retrieve.
|
|
248
|
+
this.db.prepare(`
|
|
249
|
+
UPDATE media_jobs SET asset_id = NULL, updated_at = ?
|
|
250
|
+
WHERE project_id = ? AND asset_id = ?
|
|
251
|
+
`).run(Date.now(), projectId, assetId);
|
|
245
252
|
const result = this.db.prepare(`
|
|
246
253
|
UPDATE media_assets SET deleted_at = ?, updated_at = ?
|
|
247
254
|
WHERE project_id = ? AND id = ? AND deleted_at IS NULL
|
|
@@ -431,16 +438,42 @@ export class MediaStore {
|
|
|
431
438
|
lastError?: string;
|
|
432
439
|
incrementAttempts?: boolean;
|
|
433
440
|
}): MediaJob {
|
|
441
|
+
const updated = this.updateJobInternal(projectId, id, input, false);
|
|
442
|
+
if (!updated) throw new Error(`Media job not found: ${id}`);
|
|
443
|
+
return updated;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Atomically preserve a user cancellation against a worker that was already
|
|
448
|
+
* downloading or attaching media when the cancellation arrived.
|
|
449
|
+
*/
|
|
450
|
+
updateJobIfNotCancelled(projectId: string, id: string, input: {
|
|
451
|
+
status: MediaJobStatus;
|
|
452
|
+
providerTaskId?: string;
|
|
453
|
+
assetId?: string;
|
|
454
|
+
lastError?: string;
|
|
455
|
+
incrementAttempts?: boolean;
|
|
456
|
+
}): MediaJob | undefined {
|
|
457
|
+
return this.updateJobInternal(projectId, id, input, true);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private updateJobInternal(projectId: string, id: string, input: {
|
|
461
|
+
status: MediaJobStatus;
|
|
462
|
+
providerTaskId?: string;
|
|
463
|
+
assetId?: string;
|
|
464
|
+
lastError?: string;
|
|
465
|
+
incrementAttempts?: boolean;
|
|
466
|
+
}, rejectCancelled: boolean): MediaJob | undefined {
|
|
434
467
|
const existing = this.getJob(projectId, id);
|
|
435
|
-
if (!existing)
|
|
468
|
+
if (!existing) return undefined;
|
|
436
469
|
const now = Date.now();
|
|
437
470
|
const attempts = existing.attempts + (input.incrementAttempts ? 1 : 0);
|
|
438
471
|
const completedAt = input.status === 'completed' ? now : null;
|
|
439
|
-
this.db.prepare(`
|
|
472
|
+
const result = this.db.prepare(`
|
|
440
473
|
UPDATE media_jobs
|
|
441
474
|
SET status = ?, provider_task_id = COALESCE(?, provider_task_id), asset_id = COALESCE(?, asset_id),
|
|
442
475
|
last_error = ?, attempts = ?, updated_at = ?, completed_at = ?
|
|
443
|
-
WHERE project_id = ? AND id = ?
|
|
476
|
+
WHERE project_id = ? AND id = ? ${rejectCancelled ? "AND status <> 'cancelled'" : ''}
|
|
444
477
|
`).run(
|
|
445
478
|
input.status,
|
|
446
479
|
input.providerTaskId ?? null,
|
|
@@ -452,7 +485,8 @@ export class MediaStore {
|
|
|
452
485
|
projectId,
|
|
453
486
|
id,
|
|
454
487
|
);
|
|
455
|
-
|
|
488
|
+
if (Number(result.changes ?? 0) !== 1) return undefined;
|
|
489
|
+
return this.getJob(projectId, id);
|
|
456
490
|
}
|
|
457
491
|
|
|
458
492
|
cancelJob(projectId: string, id: string): MediaJob {
|