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.
Files changed (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/index.js +1162 -1098
  3. package/dist/index.d.ts +513 -0
  4. package/dist/index.js +14751 -14044
  5. package/dist/maintenance-runner.js +105 -52
  6. package/dist/media-video-runner.js +105 -52
  7. package/dist/memcode-runtime/CHANGELOG.md +14 -0
  8. package/dist/sdk.d.ts +5 -513
  9. package/dist/sdk.js +186 -75
  10. package/dist/vector-backfill-runner.js +105 -52
  11. package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -3
  12. package/package.json +1 -1
  13. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  14. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  15. package/plugins/copilot/memorix/plugin.json +1 -1
  16. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  17. package/plugins/omp/memorix/package.json +1 -1
  18. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  19. package/plugins/pi/memorix/package.json +1 -1
  20. package/server.json +2 -2
  21. package/src/cli/commands/media.ts +10 -2
  22. package/src/cli/commands/serve-http.ts +7 -38
  23. package/src/cli/commands/serve-shared.ts +18 -46
  24. package/src/cli/commands/serve.ts +3 -41
  25. package/src/cli/commands/setup.ts +24 -21
  26. package/src/cli/mcp-root-path.ts +36 -0
  27. package/src/index.ts +37 -55
  28. package/src/media/asset-store.ts +74 -6
  29. package/src/media/attachment.ts +12 -9
  30. package/src/media/media-store.ts +38 -4
  31. package/src/media/video-jobs.ts +39 -14
  32. package/src/memory/observations.ts +21 -2
  33. package/src/orchestrate/adapters/spawn-helper.ts +2 -0
  34. package/src/server/tool-profile.ts +7 -4
  35. 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' | 'last-known' | 'home' | 'home-subdir' | 'unresolved';
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
- // Convert file:// URI to filesystem path
134
- let rootPath = decodeURIComponent(root.uri.replace('file://', ''));
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
- const commands: string[] = [];
584
- collectHookCommands(parsed.hooks, commands);
585
- return commands.length > 0
586
- && commands.every((command) => /^memorix(?:\.cmd)?\s+hook(?:\s|$)/i.test(command.trim()));
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
- * Memorix — Cross-Agent Memory Bridge
4
+ * Package entry point.
5
5
  *
6
- * Entry point for the MCP Server.
7
- * Connects via stdio transport for compatibility with all MCP-supporting agents.
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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
- import { createMemorixServer } from './server.js';
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
- const lastRootFile = path.join(homedir(), '.memorix', 'last-project-root');
29
- let lastKnownProjectRoot: string | undefined;
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
- for (const message of resolution.messages) {
49
- console.error(message);
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
- if (!resolution.detectedProject) {
53
- throw new Error(resolution.error);
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
- const { server, projectId, deferredInit } = await createMemorixServer(resolution.projectRoot);
57
-
58
- const transport = new StdioServerTransport();
59
- await server.connect(transport);
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
- main().catch((error) => {
66
- console.error('[memorix] Fatal error:', error);
67
- process.exit(1);
68
- });
45
+ if (isDirectEntrypoint()) {
46
+ runDirectStdioServer().catch((error) => {
47
+ console.error('[memorix] Fatal error:', error);
48
+ process.exitCode = 1;
49
+ });
50
+ }
@@ -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<{ beforeBytes: number; afterBytes: number; removed: MediaAsset[] }> {
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) return { beforeBytes, afterBytes: currentBytes, removed };
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
- return { beforeBytes, afterBytes: Math.max(0, currentBytes), removed };
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
  }
@@ -19,9 +19,9 @@ export interface AttachMediaAssetInput {
19
19
  }
20
20
 
21
21
  /**
22
- * Create normal text retrieval evidence first, then add an explicit asset link.
23
- * The media row remains the binary source of truth; the Observation is only a
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
- new MediaStore(input.dataDir).linkAsset({
52
- projectId: input.projectId,
53
- assetId: asset.id,
54
- observationId: result.observation.id,
55
- role: input.role ?? 'attachment',
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
  }
@@ -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) throw new Error(`Media job not found: ${id}`);
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
- return this.getJob(projectId, id)!;
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 {