memorix 1.3.3 → 1.4.0

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 (36) hide show
  1. package/CHANGELOG.md +23 -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 +23 -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/1.3.3-MULTIMODAL-MEDIA-AND-MCP-COMPAT.md +13 -13
  12. package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -3
  13. package/package.json +1 -1
  14. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  15. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  16. package/plugins/copilot/memorix/plugin.json +1 -1
  17. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  18. package/plugins/omp/memorix/package.json +1 -1
  19. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  20. package/plugins/pi/memorix/package.json +1 -1
  21. package/server.json +2 -2
  22. package/src/cli/commands/media.ts +10 -2
  23. package/src/cli/commands/serve-http.ts +7 -38
  24. package/src/cli/commands/serve-shared.ts +18 -46
  25. package/src/cli/commands/serve.ts +3 -41
  26. package/src/cli/commands/setup.ts +24 -21
  27. package/src/cli/mcp-root-path.ts +36 -0
  28. package/src/index.ts +37 -55
  29. package/src/media/asset-store.ts +74 -6
  30. package/src/media/attachment.ts +12 -9
  31. package/src/media/media-store.ts +38 -4
  32. package/src/media/video-jobs.ts +39 -14
  33. package/src/memory/observations.ts +21 -2
  34. package/src/orchestrate/adapters/spawn-helper.ts +2 -0
  35. package/src/server/tool-profile.ts +7 -4
  36. package/src/server.ts +6 -2
@@ -1318,8 +1318,8 @@ async function acquireLock(lockPath) {
1318
1318
  const code = err instanceof Error && "code" in err ? err.code : void 0;
1319
1319
  if (code === "EEXIST" || code === "EPERM") {
1320
1320
  try {
1321
- const stat3 = await fs2.stat(lockPath);
1322
- if (Date.now() - stat3.mtimeMs > LOCK_STALE_MS) {
1321
+ const stat4 = await fs2.stat(lockPath);
1322
+ if (Date.now() - stat4.mtimeMs > LOCK_STALE_MS) {
1323
1323
  await fs2.unlink(lockPath).catch(() => {
1324
1324
  });
1325
1325
  continue;
@@ -7895,6 +7895,10 @@ var init_media_store = __esm({
7895
7895
  `).run(projectId, assetId).changes ?? 0);
7896
7896
  this.db.prepare("DELETE FROM media_derivations WHERE project_id = ? AND asset_id = ?").run(projectId, assetId);
7897
7897
  this.db.prepare("DELETE FROM media_embeddings WHERE project_id = ? AND asset_id = ?").run(projectId, assetId);
7898
+ this.db.prepare(`
7899
+ UPDATE media_jobs SET asset_id = NULL, updated_at = ?
7900
+ WHERE project_id = ? AND asset_id = ?
7901
+ `).run(Date.now(), projectId, assetId);
7898
7902
  const result = this.db.prepare(`
7899
7903
  UPDATE media_assets SET deleted_at = ?, updated_at = ?
7900
7904
  WHERE project_id = ? AND id = ? AND deleted_at IS NULL
@@ -8069,16 +8073,28 @@ var init_media_store = __esm({
8069
8073
  return row ? rowToJob2(row) : void 0;
8070
8074
  }
8071
8075
  updateJob(projectId, id, input) {
8076
+ const updated = this.updateJobInternal(projectId, id, input, false);
8077
+ if (!updated) throw new Error(`Media job not found: ${id}`);
8078
+ return updated;
8079
+ }
8080
+ /**
8081
+ * Atomically preserve a user cancellation against a worker that was already
8082
+ * downloading or attaching media when the cancellation arrived.
8083
+ */
8084
+ updateJobIfNotCancelled(projectId, id, input) {
8085
+ return this.updateJobInternal(projectId, id, input, true);
8086
+ }
8087
+ updateJobInternal(projectId, id, input, rejectCancelled) {
8072
8088
  const existing = this.getJob(projectId, id);
8073
- if (!existing) throw new Error(`Media job not found: ${id}`);
8089
+ if (!existing) return void 0;
8074
8090
  const now3 = Date.now();
8075
8091
  const attempts = existing.attempts + (input.incrementAttempts ? 1 : 0);
8076
8092
  const completedAt = input.status === "completed" ? now3 : null;
8077
- this.db.prepare(`
8093
+ const result = this.db.prepare(`
8078
8094
  UPDATE media_jobs
8079
8095
  SET status = ?, provider_task_id = COALESCE(?, provider_task_id), asset_id = COALESCE(?, asset_id),
8080
8096
  last_error = ?, attempts = ?, updated_at = ?, completed_at = ?
8081
- WHERE project_id = ? AND id = ?
8097
+ WHERE project_id = ? AND id = ? ${rejectCancelled ? "AND status <> 'cancelled'" : ""}
8082
8098
  `).run(
8083
8099
  input.status,
8084
8100
  input.providerTaskId ?? null,
@@ -8090,6 +8106,7 @@ var init_media_store = __esm({
8090
8106
  projectId,
8091
8107
  id
8092
8108
  );
8109
+ if (Number(result.changes ?? 0) !== 1) return void 0;
8093
8110
  return this.getJob(projectId, id);
8094
8111
  }
8095
8112
  cancelJob(projectId, id) {
@@ -8135,6 +8152,7 @@ async function attachMediaAssetToObservation(input) {
8135
8152
  `MIME type: ${asset.mimeType}`,
8136
8153
  ...input.facts ?? []
8137
8154
  ];
8155
+ const store = new MediaStore(input.dataDir);
8138
8156
  const result = await storeObservation({
8139
8157
  entityName: input.entityName?.trim() || sourceLabel2.replace(/\.[^.]+$/, "") || "media-asset",
8140
8158
  type: "discovery",
@@ -8147,13 +8165,15 @@ async function attachMediaAssetToObservation(input) {
8147
8165
  sourceDetail: "explicit",
8148
8166
  ...input.visibility ? { visibility: input.visibility } : {},
8149
8167
  ...input.createdByAgentId ? { createdByAgentId: input.createdByAgentId } : {},
8150
- ...input.visibilityReader ? { visibilityReader: input.visibilityReader } : {}
8151
- });
8152
- new MediaStore(input.dataDir).linkAsset({
8153
- projectId: input.projectId,
8154
- assetId: asset.id,
8155
- observationId: result.observation.id,
8156
- role: input.role ?? "attachment"
8168
+ ...input.visibilityReader ? { visibilityReader: input.visibilityReader } : {},
8169
+ onPersisted: (observation) => {
8170
+ store.linkAsset({
8171
+ projectId: input.projectId,
8172
+ assetId: asset.id,
8173
+ observationId: observation.id,
8174
+ role: input.role ?? "attachment"
8175
+ });
8176
+ }
8157
8177
  });
8158
8178
  return result.observation;
8159
8179
  }
@@ -8179,7 +8199,7 @@ var init_types2 = __esm({
8179
8199
 
8180
8200
  // src/media/asset-store.ts
8181
8201
  import { createHash as createHash4, randomUUID as randomUUID5 } from "crypto";
8182
- import { copyFile, lstat, mkdir as mkdir3, open, readFile as readFile3, rename, rm, writeFile as writeFile3 } from "fs/promises";
8202
+ import { copyFile, lstat, mkdir as mkdir3, open, readFile as readFile3, readdir, rename, rm, stat, writeFile as writeFile3 } from "fs/promises";
8183
8203
  import path9 from "path";
8184
8204
  function mediaRoot(dataDir) {
8185
8205
  return path9.resolve(dataDir, "media");
@@ -59292,7 +59312,7 @@ var require_node_domexception = __commonJS({
59292
59312
  // node_modules/fetch-blob/from.js
59293
59313
  import { statSync as statSync2, createReadStream, promises as fs7 } from "fs";
59294
59314
  import { basename } from "path";
59295
- var import_node_domexception, stat, blobFromSync, blobFrom, fileFrom, fileFromSync, fromBlob, fromFile, BlobDataItem;
59315
+ var import_node_domexception, stat2, blobFromSync, blobFrom, fileFrom, fileFromSync, fromBlob, fromFile, BlobDataItem;
59296
59316
  var init_from = __esm({
59297
59317
  "node_modules/fetch-blob/from.js"() {
59298
59318
  "use strict";
@@ -59300,23 +59320,23 @@ var init_from = __esm({
59300
59320
  import_node_domexception = __toESM(require_node_domexception(), 1);
59301
59321
  init_file();
59302
59322
  init_fetch_blob();
59303
- ({ stat } = fs7);
59323
+ ({ stat: stat2 } = fs7);
59304
59324
  blobFromSync = (path19, type) => fromBlob(statSync2(path19), path19, type);
59305
- blobFrom = (path19, type) => stat(path19).then((stat3) => fromBlob(stat3, path19, type));
59306
- fileFrom = (path19, type) => stat(path19).then((stat3) => fromFile(stat3, path19, type));
59325
+ blobFrom = (path19, type) => stat2(path19).then((stat4) => fromBlob(stat4, path19, type));
59326
+ fileFrom = (path19, type) => stat2(path19).then((stat4) => fromFile(stat4, path19, type));
59307
59327
  fileFromSync = (path19, type) => fromFile(statSync2(path19), path19, type);
59308
- fromBlob = (stat3, path19, type = "") => new fetch_blob_default([new BlobDataItem({
59328
+ fromBlob = (stat4, path19, type = "") => new fetch_blob_default([new BlobDataItem({
59309
59329
  path: path19,
59310
- size: stat3.size,
59311
- lastModified: stat3.mtimeMs,
59330
+ size: stat4.size,
59331
+ lastModified: stat4.mtimeMs,
59312
59332
  start: 0
59313
59333
  })], { type });
59314
- fromFile = (stat3, path19, type = "") => new file_default([new BlobDataItem({
59334
+ fromFile = (stat4, path19, type = "") => new file_default([new BlobDataItem({
59315
59335
  path: path19,
59316
- size: stat3.size,
59317
- lastModified: stat3.mtimeMs,
59336
+ size: stat4.size,
59337
+ lastModified: stat4.mtimeMs,
59318
59338
  start: 0
59319
- })], basename(path19), { type, lastModified: stat3.mtimeMs });
59339
+ })], basename(path19), { type, lastModified: stat4.mtimeMs });
59320
59340
  BlobDataItem = class _BlobDataItem {
59321
59341
  #path;
59322
59342
  #start;
@@ -59339,7 +59359,7 @@ var init_from = __esm({
59339
59359
  });
59340
59360
  }
59341
59361
  async *stream() {
59342
- const { mtimeMs } = await stat(this.#path);
59362
+ const { mtimeMs } = await stat2(this.#path);
59343
59363
  if (mtimeMs > this.lastModified) {
59344
59364
  throw new import_node_domexception.default("The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired.", "NotReadableError");
59345
59365
  }
@@ -141505,10 +141525,17 @@ function sourceLabel(request, providerTaskId) {
141505
141525
  }
141506
141526
  async function finishWithAsset(input) {
141507
141527
  const { dataDir, projectId, store, mediaJob, request, providerTask, dependencies } = input;
141508
- let asset = mediaJob.assetId ? store.getAsset(projectId, mediaJob.assetId) : void 0;
141528
+ let activeJob = store.getJob(projectId, mediaJob.id);
141529
+ if (!activeJob || terminal(activeJob)) return { action: "complete" };
141530
+ let asset = activeJob.assetId ? store.getAsset(projectId, activeJob.assetId) : void 0;
141509
141531
  if (!asset) {
141510
141532
  if (!providerTask.downloadUrl) throw new Error("Succeeded MiniMax video task did not include a download URL");
141511
- store.updateJob(projectId, mediaJob.id, { status: "downloading", providerTaskId: providerTask.taskId });
141533
+ const downloading = store.updateJobIfNotCancelled(projectId, activeJob.id, {
141534
+ status: "downloading",
141535
+ providerTaskId: providerTask.taskId
141536
+ });
141537
+ if (!downloading) return { action: "complete" };
141538
+ activeJob = downloading;
141512
141539
  const bytes = await (dependencies.download ?? ((downloadInput) => downloadProviderMedia(downloadInput)))({
141513
141540
  url: providerTask.downloadUrl,
141514
141541
  maxBytes: request.maxBytes
@@ -141525,21 +141552,30 @@ async function finishWithAsset(input) {
141525
141552
  maxBytes: request.maxBytes
141526
141553
  });
141527
141554
  asset = imported.asset;
141555
+ const persistedAsset = store.updateJobIfNotCancelled(projectId, activeJob.id, {
141556
+ status: "downloading",
141557
+ providerTaskId: providerTask.taskId,
141558
+ assetId: asset.id
141559
+ });
141560
+ if (!persistedAsset) return { action: "complete" };
141561
+ activeJob = persistedAsset;
141528
141562
  }
141529
- if (mediaJob.attachOnComplete) {
141563
+ if (activeJob.attachOnComplete) {
141564
+ activeJob = store.getJob(projectId, activeJob.id);
141565
+ if (!activeJob || terminal(activeJob)) return { action: "complete" };
141530
141566
  const hasAttachment = store.listLinks(projectId, asset.id).some((link) => link.role === "attachment" && link.observationId !== void 0);
141531
141567
  if (!hasAttachment) {
141532
141568
  await (dependencies.attach ?? attachMediaAssetToObservation)({
141533
141569
  dataDir,
141534
141570
  projectId,
141535
141571
  asset,
141536
- title: mediaJob.observationTitle ?? `MiniMax video: ${asset.sourceLabel ?? asset.id}`,
141572
+ title: activeJob.observationTitle ?? `MiniMax video: ${asset.sourceLabel ?? asset.id}`,
141537
141573
  narrative: `Generated with ${request.provider}/${request.model}. Prompt: ${request.prompt}`,
141538
141574
  concepts: ["generated-video", "minimax", request.model]
141539
141575
  });
141540
141576
  }
141541
141577
  }
141542
- store.updateJob(projectId, mediaJob.id, {
141578
+ store.updateJobIfNotCancelled(projectId, activeJob.id, {
141543
141579
  status: "completed",
141544
141580
  providerTaskId: providerTask.taskId,
141545
141581
  assetId: asset.id
@@ -141549,10 +141585,12 @@ async function finishWithAsset(input) {
141549
141585
  function retryAfterProviderError(store, projectId, mediaJob, maintenanceJob, error) {
141550
141586
  const message = safeError2(error);
141551
141587
  if (maintenanceJob.attempts >= maintenanceJob.maxAttempts) {
141552
- store.updateJob(projectId, mediaJob.id, { status: "failed", lastError: message, incrementAttempts: true });
141588
+ store.updateJobIfNotCancelled(projectId, mediaJob.id, { status: "failed", lastError: message, incrementAttempts: true });
141589
+ return { action: "complete" };
141590
+ }
141591
+ if (!store.updateJobIfNotCancelled(projectId, mediaJob.id, { status: "retry", lastError: message, incrementAttempts: true })) {
141553
141592
  return { action: "complete" };
141554
141593
  }
141555
- store.updateJob(projectId, mediaJob.id, { status: "retry", lastError: message, incrementAttempts: true });
141556
141594
  return { action: "reschedule", delayMs: MINI_MAX_VIDEO_POLL_MS, status: "retry", lastError: message };
141557
141595
  }
141558
141596
  function queueMiniMaxVideoGeneration(input) {
@@ -141593,22 +141631,25 @@ async function runMiniMaxVideoGenerationJob(maintenanceJob, context, dependencie
141593
141631
  if (mediaJob.kind !== "minimax-video-generation") throw new Error(`Unsupported media job kind: ${mediaJob.kind}`);
141594
141632
  const request = parseStoredRequest(mediaJob.request);
141595
141633
  if (!mediaJob.providerTaskId) {
141596
- store.updateJob(context.projectId, mediaJob.id, { status: "submitting", incrementAttempts: true });
141634
+ if (!store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: "submitting", incrementAttempts: true })) {
141635
+ return { action: "complete" };
141636
+ }
141597
141637
  let submitted;
141598
141638
  try {
141599
141639
  submitted = await (dependencies.createTask ?? createMiniMaxVideoTask)(toVideoRequest(request));
141600
141640
  } catch (error) {
141601
- store.updateJob(context.projectId, mediaJob.id, { status: "failed", lastError: safeError2(error) });
141641
+ store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: "failed", lastError: safeError2(error) });
141602
141642
  return { action: "complete" };
141603
141643
  }
141604
141644
  if (submitted.status === "failed") {
141605
- store.updateJob(context.projectId, mediaJob.id, { status: "failed", providerTaskId: submitted.taskId, lastError: submitted.error });
141645
+ store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: "failed", providerTaskId: submitted.taskId, lastError: submitted.error });
141606
141646
  return { action: "complete" };
141607
141647
  }
141608
- const pending = store.updateJob(context.projectId, mediaJob.id, {
141648
+ const pending = store.updateJobIfNotCancelled(context.projectId, mediaJob.id, {
141609
141649
  status: submitted.status === "succeeded" ? "downloading" : "provider-pending",
141610
141650
  providerTaskId: submitted.taskId
141611
141651
  });
141652
+ if (!pending) return { action: "complete" };
141612
141653
  if (submitted.status === "succeeded") {
141613
141654
  try {
141614
141655
  return await finishWithAsset({
@@ -141636,11 +141677,13 @@ async function runMiniMaxVideoGenerationJob(maintenanceJob, context, dependencie
141636
141677
  return retryAfterProviderError(store, context.projectId, mediaJob, maintenanceJob, error);
141637
141678
  }
141638
141679
  if (task.status === "pending") {
141639
- store.updateJob(context.projectId, mediaJob.id, { status: "provider-pending", providerTaskId: task.taskId });
141680
+ if (!store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: "provider-pending", providerTaskId: task.taskId })) {
141681
+ return { action: "complete" };
141682
+ }
141640
141683
  return { action: "reschedule", delayMs: MINI_MAX_VIDEO_POLL_MS, resetAttempts: true, clearLastError: true };
141641
141684
  }
141642
141685
  if (task.status === "failed") {
141643
- store.updateJob(context.projectId, mediaJob.id, { status: "failed", providerTaskId: task.taskId, lastError: task.error, incrementAttempts: true });
141686
+ store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: "failed", providerTaskId: task.taskId, lastError: task.error, incrementAttempts: true });
141644
141687
  return { action: "complete" };
141645
141688
  }
141646
141689
  try {
@@ -142923,7 +142966,7 @@ function indexFileLite(projectId, projectRoot, abs, indexedAt, fileStat) {
142923
142966
  const rel = normalizeCodePath(relative(projectRoot, abs));
142924
142967
  try {
142925
142968
  const text = readFileSync6(abs, "utf-8");
142926
- const stat3 = fileStat ?? statSync3(abs);
142969
+ const stat4 = fileStat ?? statSync3(abs);
142927
142970
  const file = {
142928
142971
  id: makeCodeFileId(projectId, rel),
142929
142972
  projectId,
@@ -142932,8 +142975,8 @@ function indexFileLite(projectId, projectRoot, abs, indexedAt, fileStat) {
142932
142975
  contentHash: hashText(text),
142933
142976
  // Preserve filesystem precision. Rounding here made same-size edits in
142934
142977
  // a short Windows timestamp window indistinguishable to refresh.
142935
- mtimeMs: Number(stat3.mtimeMs),
142936
- sizeBytes: Number(stat3.size),
142978
+ mtimeMs: Number(stat4.mtimeMs),
142979
+ sizeBytes: Number(stat4.size),
142937
142980
  indexedAt
142938
142981
  };
142939
142982
  return {
@@ -142960,18 +143003,18 @@ async function indexProjectLite(options) {
142960
143003
  let skippedOversizedFiles = 0;
142961
143004
  let unreadableFiles = 0;
142962
143005
  for (const abs of paths) {
142963
- let stat3;
143006
+ let stat4;
142964
143007
  try {
142965
- stat3 = statSync3(abs);
143008
+ stat4 = statSync3(abs);
142966
143009
  } catch {
142967
143010
  unreadableFiles++;
142968
143011
  continue;
142969
143012
  }
142970
- if (Number(stat3.size) > maxFileBytes) {
143013
+ if (Number(stat4.size) > maxFileBytes) {
142971
143014
  skippedOversizedFiles++;
142972
143015
  continue;
142973
143016
  }
142974
- const attempt = indexFileLite(options.projectId, options.projectRoot, abs, indexedAt, stat3);
143017
+ const attempt = indexFileLite(options.projectId, options.projectRoot, abs, indexedAt, stat4);
142975
143018
  if (!attempt.delta) {
142976
143019
  if (attempt.unreadable) unreadableFiles++;
142977
143020
  continue;
@@ -142999,16 +143042,16 @@ async function refreshProjectLite(store, options) {
142999
143042
  for (const abs of paths) {
143000
143043
  const rel = normalizeCodePath(relative(options.projectRoot, abs));
143001
143044
  seenPaths.add(rel);
143002
- let stat3;
143045
+ let stat4;
143003
143046
  try {
143004
- stat3 = statSync3(abs);
143047
+ stat4 = statSync3(abs);
143005
143048
  } catch {
143006
143049
  unreadableFiles++;
143007
143050
  continue;
143008
143051
  }
143009
143052
  const existing = existingByPath.get(rel);
143010
- const mtimeMs = Number(stat3.mtimeMs);
143011
- const sizeBytes = Number(stat3.size);
143053
+ const mtimeMs = Number(stat4.mtimeMs);
143054
+ const sizeBytes = Number(stat4.size);
143012
143055
  if (sizeBytes > maxFileBytes) {
143013
143056
  skippedOversizedFiles++;
143014
143057
  if (existing) oversizedExistingFileIds.add(existing.id);
@@ -143018,7 +143061,7 @@ async function refreshProjectLite(store, options) {
143018
143061
  unchangedFiles++;
143019
143062
  continue;
143020
143063
  }
143021
- const attempt = indexFileLite(options.projectId, options.projectRoot, abs, indexedAt, stat3);
143064
+ const attempt = indexFileLite(options.projectId, options.projectRoot, abs, indexedAt, stat4);
143022
143065
  if (!attempt.delta) {
143023
143066
  if (attempt.unreadable) unreadableFiles++;
143024
143067
  continue;
@@ -146459,7 +146502,9 @@ async function storeObservation(input) {
146459
146502
  if (input.visibilityReader && !canManageObservation(existing, input.visibilityReader)) {
146460
146503
  throw new Error("Cannot update a memory outside this session's write scope.");
146461
146504
  }
146462
- return { observation: await upsertObservation(existing, input, now3), upserted: true };
146505
+ const observation2 = await upsertObservation(existing, input, now3);
146506
+ await input.onPersisted?.(observation2);
146507
+ return { observation: observation2, upserted: true };
146463
146508
  }
146464
146509
  }
146465
146510
  const contentForExtraction = [input.title, input.narrative, ...input.facts ?? []].join(" ");
@@ -146484,6 +146529,7 @@ async function storeObservation(input) {
146484
146529
  let doc;
146485
146530
  let upsertedInsideLock = false;
146486
146531
  let reloadCacheAfterCommit = false;
146532
+ let persistedCallbackInvoked = false;
146487
146533
  const assignAndPersist = async () => {
146488
146534
  if (projectDir) {
146489
146535
  const store = getObservationStore();
@@ -146539,6 +146585,8 @@ async function storeObservation(input) {
146539
146585
  writeGeneration: transactionGeneration + 1
146540
146586
  };
146541
146587
  await tx.insert(observation);
146588
+ await input.onPersisted?.(observation);
146589
+ persistedCallbackInvoked = true;
146542
146590
  await tx.saveIdCounter(id + 1);
146543
146591
  });
146544
146592
  observation.writeGeneration = store.getGeneration();
@@ -146588,6 +146636,8 @@ async function storeObservation(input) {
146588
146636
  writeGeneration: 0
146589
146637
  };
146590
146638
  observations.push(observation);
146639
+ await input.onPersisted?.(observation);
146640
+ persistedCallbackInvoked = true;
146591
146641
  }
146592
146642
  doc = {
146593
146643
  id: makeOramaObservationId(input.projectId, observation.id),
@@ -146619,8 +146669,11 @@ async function storeObservation(input) {
146619
146669
  };
146620
146670
  await assignAndPersist();
146621
146671
  if (upsertedInsideLock) {
146622
- return { observation: await upsertObservation(observation, input, now3), upserted: true };
146672
+ const updated = await upsertObservation(observation, input, now3);
146673
+ await input.onPersisted?.(updated);
146674
+ return { observation: updated, upserted: true };
146623
146675
  }
146676
+ if (!persistedCallbackInvoked) await input.onPersisted?.(observation);
146624
146677
  await bindObservationCodeRefsBestEffort(observation);
146625
146678
  queueObservationQualification(observation);
146626
146679
  if (resolveObservationVisibility(observation) === "project") {
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.4.0] - 2026-08-02
6
+
7
+ ### Changed
8
+ - **Stable multimodal release** -- Promoted the controlled media lifecycle, explicit MiniMax image/video generation, and honest multimodal embedding foundation introduced in 1.3.3 to the stable line after the 1.3.4 Windows, MCP binding, cancellation, cleanup, configuration-migration, and fresh-package hardening work.
9
+
10
+ ### Verified
11
+ - **Release evidence** -- Full local regression, fresh npm-package MCP smoke, GitHub Actions on Windows and Ubuntu, Docker control-plane startup, native SQLite compatibility, npm provenance publication, and official MCP Registry publication all passed before this release.
12
+ - **Release boundary** -- “Stable” means no known release blocker after those gates. It does not claim that any software is mathematically free of future defects.
13
+
14
+ ## [1.3.4] - 2026-08-02
15
+
16
+ ### Fixed
17
+ - **Fail-closed project binding** -- Stdio and HTTP MCP servers no longer restore or keep writing a remembered project root. A launch without a reliable workspace now waits for an explicit root, MCP Roots, or `memorix_session_start`, preventing cross-project memory access.
18
+ - **Portable MCP Roots** -- File URI roots now remain POSIX paths on Linux/macOS and convert correctly on Windows, including drive-letter paths and UNC roots.
19
+ - **Atomic media attachments** -- A media retrieval observation and its asset link now commit in one SQLite transaction. If linking fails, no dangling observation survives. Completed video downloads also persist their local asset before a retryable attachment step.
20
+ - **Authoritative video cancellation** -- A cancellation that lands while a video worker is downloading or finishing can no longer be overwritten back to `completed`; MCP can cancel an existing job even when new MCP media generation is disabled.
21
+ - **Recoverable media cleanup** -- Files Windows cannot immediately release are visibly staged for retry, and media cleanup reports reclaimed or still-pending staged bytes instead of silently implying deletion succeeded.
22
+ - **Non-destructive Codex migration** -- Legacy Codex hook cleanup now preserves customized hook files instead of deleting a file merely because its commands begin with `memorix hook`.
23
+ - **One stdio runtime** -- Direct `node dist/index.js` invocation now delegates to `memorix serve`; importing the package no longer starts an MCP server as a side effect.
24
+
25
+ ### Changed
26
+ - **Bounded default MCP surface** -- The `micro` profile exposes nine tools: agent-ready context, retrieval, one explicit session recovery path, and one controlled media operation. Advanced tools remain opt-in through higher profiles.
27
+
5
28
  ## [1.3.3] - 2026-08-02
6
29
 
7
30
  ### Added