neotoma 0.4.2 → 0.4.5

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 (129) hide show
  1. package/README.md +43 -0
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +332 -150
  4. package/dist/actions.js.map +1 -1
  5. package/dist/cli/commands/mirror.d.ts +52 -0
  6. package/dist/cli/commands/mirror.d.ts.map +1 -0
  7. package/dist/cli/commands/mirror.js +147 -0
  8. package/dist/cli/commands/mirror.js.map +1 -0
  9. package/dist/cli/config.d.ts +8 -0
  10. package/dist/cli/config.d.ts.map +1 -1
  11. package/dist/cli/config.js.map +1 -1
  12. package/dist/cli/doctor.d.ts +76 -0
  13. package/dist/cli/doctor.d.ts.map +1 -0
  14. package/dist/cli/doctor.js +345 -0
  15. package/dist/cli/doctor.js.map +1 -0
  16. package/dist/cli/hooks.d.ts +45 -0
  17. package/dist/cli/hooks.d.ts.map +1 -0
  18. package/dist/cli/hooks.js +273 -0
  19. package/dist/cli/hooks.js.map +1 -0
  20. package/dist/cli/hooks_detect.d.ts +53 -0
  21. package/dist/cli/hooks_detect.d.ts.map +1 -0
  22. package/dist/cli/hooks_detect.js +220 -0
  23. package/dist/cli/hooks_detect.js.map +1 -0
  24. package/dist/cli/index.d.ts.map +1 -1
  25. package/dist/cli/index.js +595 -49
  26. package/dist/cli/index.js.map +1 -1
  27. package/dist/cli/permissions.d.ts +46 -0
  28. package/dist/cli/permissions.d.ts.map +1 -0
  29. package/dist/cli/permissions.js +191 -0
  30. package/dist/cli/permissions.js.map +1 -0
  31. package/dist/cli/setup.d.ts +46 -0
  32. package/dist/cli/setup.d.ts.map +1 -0
  33. package/dist/cli/setup.js +105 -0
  34. package/dist/cli/setup.js.map +1 -0
  35. package/dist/core/operations.d.ts +145 -0
  36. package/dist/core/operations.d.ts.map +1 -0
  37. package/dist/core/operations.js +106 -0
  38. package/dist/core/operations.js.map +1 -0
  39. package/dist/openclaw_entry.d.ts +45 -0
  40. package/dist/openclaw_entry.d.ts.map +1 -0
  41. package/dist/openclaw_entry.js +75 -0
  42. package/dist/openclaw_entry.js.map +1 -0
  43. package/dist/server.d.ts.map +1 -1
  44. package/dist/server.js +107 -748
  45. package/dist/server.js.map +1 -1
  46. package/dist/services/batch_correction.d.ts +129 -0
  47. package/dist/services/batch_correction.d.ts.map +1 -0
  48. package/dist/services/batch_correction.js +231 -0
  49. package/dist/services/batch_correction.js.map +1 -0
  50. package/dist/services/canonical_markdown.d.ts +145 -0
  51. package/dist/services/canonical_markdown.d.ts.map +1 -0
  52. package/dist/services/canonical_markdown.js +425 -0
  53. package/dist/services/canonical_markdown.js.map +1 -0
  54. package/dist/services/canonical_mirror.d.ts +127 -0
  55. package/dist/services/canonical_mirror.d.ts.map +1 -0
  56. package/dist/services/canonical_mirror.js +1047 -0
  57. package/dist/services/canonical_mirror.js.map +1 -0
  58. package/dist/services/canonical_mirror_git.d.ts +71 -0
  59. package/dist/services/canonical_mirror_git.d.ts.map +1 -0
  60. package/dist/services/canonical_mirror_git.js +211 -0
  61. package/dist/services/canonical_mirror_git.js.map +1 -0
  62. package/dist/services/entity_resolution.d.ts +17 -1
  63. package/dist/services/entity_resolution.d.ts.map +1 -1
  64. package/dist/services/entity_resolution.js +97 -24
  65. package/dist/services/entity_resolution.js.map +1 -1
  66. package/dist/services/entity_type_equivalence.d.ts +40 -0
  67. package/dist/services/entity_type_equivalence.d.ts.map +1 -0
  68. package/dist/services/entity_type_equivalence.js +109 -0
  69. package/dist/services/entity_type_equivalence.js.map +1 -0
  70. package/dist/services/entity_type_guard.d.ts +52 -0
  71. package/dist/services/entity_type_guard.d.ts.map +1 -0
  72. package/dist/services/entity_type_guard.js +204 -0
  73. package/dist/services/entity_type_guard.js.map +1 -0
  74. package/dist/services/file_text_extraction.d.ts.map +1 -1
  75. package/dist/services/file_text_extraction.js +5 -0
  76. package/dist/services/file_text_extraction.js.map +1 -1
  77. package/dist/services/flat_packed_detection.d.ts +32 -0
  78. package/dist/services/flat_packed_detection.d.ts.map +1 -0
  79. package/dist/services/flat_packed_detection.js +96 -0
  80. package/dist/services/flat_packed_detection.js.map +1 -0
  81. package/dist/services/interpretation.d.ts.map +1 -1
  82. package/dist/services/interpretation.js +9 -0
  83. package/dist/services/interpretation.js.map +1 -1
  84. package/dist/services/memory_export.d.ts +56 -0
  85. package/dist/services/memory_export.d.ts.map +1 -0
  86. package/dist/services/memory_export.js +295 -0
  87. package/dist/services/memory_export.js.map +1 -0
  88. package/dist/services/raw_fragments.js +1 -1
  89. package/dist/services/raw_fragments.js.map +1 -1
  90. package/dist/services/raw_storage.d.ts +6 -0
  91. package/dist/services/raw_storage.d.ts.map +1 -1
  92. package/dist/services/raw_storage.js +26 -0
  93. package/dist/services/raw_storage.js.map +1 -1
  94. package/dist/services/schema_definitions.d.ts.map +1 -1
  95. package/dist/services/schema_definitions.js +37 -0
  96. package/dist/services/schema_definitions.js.map +1 -1
  97. package/dist/services/schema_reference_linking.d.ts +38 -0
  98. package/dist/services/schema_reference_linking.d.ts.map +1 -0
  99. package/dist/services/schema_reference_linking.js +130 -0
  100. package/dist/services/schema_reference_linking.js.map +1 -0
  101. package/dist/services/schema_registry.d.ts +39 -0
  102. package/dist/services/schema_registry.d.ts.map +1 -1
  103. package/dist/services/schema_registry.js +91 -2
  104. package/dist/services/schema_registry.js.map +1 -1
  105. package/dist/services/snapshot_computation.d.ts.map +1 -1
  106. package/dist/services/snapshot_computation.js +10 -0
  107. package/dist/services/snapshot_computation.js.map +1 -1
  108. package/dist/services/timeline_events.d.ts +24 -5
  109. package/dist/services/timeline_events.d.ts.map +1 -1
  110. package/dist/services/timeline_events.js +89 -13
  111. package/dist/services/timeline_events.js.map +1 -1
  112. package/dist/shared/action_schemas.d.ts +18 -2
  113. package/dist/shared/action_schemas.d.ts.map +1 -1
  114. package/dist/shared/action_schemas.js +8 -0
  115. package/dist/shared/action_schemas.js.map +1 -1
  116. package/dist/shared/local_transport.d.ts +13 -0
  117. package/dist/shared/local_transport.d.ts.map +1 -1
  118. package/dist/shared/local_transport.js +89 -6
  119. package/dist/shared/local_transport.js.map +1 -1
  120. package/dist/tool_definitions.d.ts +22 -0
  121. package/dist/tool_definitions.d.ts.map +1 -0
  122. package/dist/tool_definitions.js +747 -0
  123. package/dist/tool_definitions.js.map +1 -0
  124. package/openapi.yaml +13 -0
  125. package/openclaw.plugin.json +87 -0
  126. package/package.json +31 -1
  127. package/skills/query-memory/SKILL.md +67 -0
  128. package/skills/recover-sqlite-database/SKILL.md +46 -0
  129. package/skills/store-data/SKILL.md +58 -0
package/dist/actions.js CHANGED
@@ -14,7 +14,7 @@ import { ensurePublicKeyRegistered, getPublicKey, getUserIdFromBearerToken, isBe
14
14
  import { verifyRequest, parseAuthHeader } from "./crypto/auth.js";
15
15
  import { encryptResponseMiddleware } from "./middleware/encrypt_response.js";
16
16
  import { initServerKeys } from "./services/encryption_service.js";
17
- import { storeRawContent, downloadRawContent, SourceFileNotFoundError } from "./services/raw_storage.js";
17
+ import { storeRawContent, downloadRawContent, resolveLocalSourceFilePath, SourceFileNotFoundError, } from "./services/raw_storage.js";
18
18
  import { attachSourceLabelsToObservations } from "./services/observation_source_label.js";
19
19
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
20
20
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
@@ -26,6 +26,7 @@ import { getSqliteDb } from "./repositories/sqlite/sqlite_client.js";
26
26
  import { getMcpAuthToken } from "./crypto/mcp_auth_token.js";
27
27
  import { isOauthKeyCredentialValid, normalizeOauthNextPath, OAuthKeySessionStore, } from "./services/oauth_key_gate.js";
28
28
  import { AnalyzeSchemaCandidatesRequestSchema, CorrectEntityRequestSchema, CreateRelationshipRequestSchema, DeleteEntityRequestSchema, DeleteRelationshipRequestSchema, EntitiesQueryRequestSchema, EntitySnapshotRequestSchema, FieldProvenanceRequestSchema, GetSchemaRecommendationsRequestSchema, ListObservationsRequestSchema, ListRelationshipsRequestSchema, MergeEntitiesRequestSchema, ObservationsQueryRequestSchema, RegisterSchemaRequestSchema, RelationshipSnapshotRequestSchema, RestoreEntityRequestSchema, RestoreRelationshipRequestSchema, RetrieveEntityByIdentifierSchema, RetrieveGraphNeighborhoodSchema, RetrieveRelatedEntitiesSchema, StoreRequestSchema, StoreUnstructuredRequestSchema, UpdateSchemaIncrementalRequestSchema, } from "./shared/action_schemas.js";
29
+ import { getMimeTypeFromExtension } from "./services/file_text_extraction.js";
29
30
  import { queryEntitiesWithCount } from "./shared/action_handlers/entity_handlers.js";
30
31
  import { retrieveEntityByIdentifierWithFallback } from "./shared/action_handlers/entity_identifier_handler.js";
31
32
  import { prepareEntitySnapshotWithEmbedding, upsertEntitySnapshotWithEmbedding, } from "./services/entity_snapshot_embedding.js";
@@ -1611,6 +1612,115 @@ app.get("/entities/:id", async (req, res) => {
1611
1612
  return sendError(res, 500, "DB_QUERY_FAILED", message);
1612
1613
  }
1613
1614
  });
1615
+ // GET /api/entities/:id/markdown - Canonical markdown rendering of an entity
1616
+ // snapshot (Phase 4). Backs Inspector's markdown preview panel and the
1617
+ // `neotoma memory-export` CLI. Deterministic: same inputs produce byte-for-byte
1618
+ // identical output across calls, matching the filesystem mirror.
1619
+ app.get("/entities/:id/markdown", async (req, res) => {
1620
+ try {
1621
+ const entityId = req.params.id;
1622
+ const userId = await getAuthenticatedUserId(req, req.query.user_id);
1623
+ const { data: entity, error: entityError } = await db
1624
+ .from("entities")
1625
+ .select("id, user_id")
1626
+ .eq("id", entityId)
1627
+ .eq("user_id", userId)
1628
+ .single();
1629
+ if (entityError || !entity) {
1630
+ return sendError(res, 404, "RESOURCE_NOT_FOUND", "Entity not found");
1631
+ }
1632
+ const { getEntityWithProvenance } = await import("./services/entity_queries.js");
1633
+ const current = await getEntityWithProvenance(entityId);
1634
+ if (!current) {
1635
+ return sendError(res, 404, "RESOURCE_NOT_FOUND", "Entity not found");
1636
+ }
1637
+ let schemaFieldOrder;
1638
+ try {
1639
+ const { schemaRegistry } = await import("./services/schema_registry.js");
1640
+ const schema = await schemaRegistry.loadActiveSchema(current.entity_type, userId);
1641
+ if (schema?.schema_definition?.fields) {
1642
+ schemaFieldOrder = Object.keys(schema.schema_definition.fields);
1643
+ }
1644
+ }
1645
+ catch {
1646
+ // Alphabetical fallback when schema is unavailable.
1647
+ }
1648
+ const { renderEntityMarkdown } = await import("./services/canonical_markdown.js");
1649
+ const markdown = renderEntityMarkdown({
1650
+ entity_id: current.entity_id ?? entityId,
1651
+ entity_type: current.entity_type,
1652
+ schema_version: current.schema_version ?? "1.0",
1653
+ snapshot: current.snapshot ?? {},
1654
+ computed_at: current.computed_at ?? null,
1655
+ observation_count: current.observation_count ?? 0,
1656
+ last_observation_at: current.last_observation_at ?? null,
1657
+ }, schemaFieldOrder);
1658
+ // etag is derived from last_observation_at — any new observation moves it,
1659
+ // which is exactly the Phase 4 optimistic concurrency boundary.
1660
+ const etag = current.last_observation_at
1661
+ ? `W/"${Buffer.from(current.last_observation_at).toString("base64")}"`
1662
+ : undefined;
1663
+ if (etag)
1664
+ res.setHeader("ETag", etag);
1665
+ res.setHeader("Content-Type", "text/markdown; charset=utf-8");
1666
+ return res.send(markdown);
1667
+ }
1668
+ catch (error) {
1669
+ if (error instanceof Error && error.message.includes("Not authenticated")) {
1670
+ return sendError(res, 401, "AUTH_REQUIRED", error.message);
1671
+ }
1672
+ logError("APIError:entity_markdown", req, error);
1673
+ const message = error instanceof Error ? error.message : "Failed to render entity markdown";
1674
+ return sendError(res, 500, "DB_QUERY_FAILED", message);
1675
+ }
1676
+ });
1677
+ // POST /entities/:id/batch_correct - Atomic multi-field correction (Phase 4).
1678
+ // Shares the same backend as Phase 4b `neotoma edit` CLI; the Inspector Edit
1679
+ // tab issues one of these per save instead of N POST /correct calls so the
1680
+ // optimistic concurrency check and schema validation are atomic.
1681
+ app.post("/entities/:id/batch_correct", async (req, res) => {
1682
+ try {
1683
+ const entityId = req.params.id;
1684
+ const userId = await getAuthenticatedUserId(req, req.body?.user_id);
1685
+ const { data: entity, error: entityError } = await db
1686
+ .from("entities")
1687
+ .select("id, user_id")
1688
+ .eq("id", entityId)
1689
+ .eq("user_id", userId)
1690
+ .single();
1691
+ if (entityError || !entity) {
1692
+ return sendError(res, 404, "RESOURCE_NOT_FOUND", "Entity not found");
1693
+ }
1694
+ const body = req.body;
1695
+ if (!body?.changes || !Array.isArray(body.changes)) {
1696
+ return sendError(res, 400, "VALIDATION_ERROR", "`changes` array is required");
1697
+ }
1698
+ const { applyBatchCorrection } = await import("./services/batch_correction.js");
1699
+ const result = await applyBatchCorrection({
1700
+ entity_id: entityId,
1701
+ user_id: userId,
1702
+ expected_last_observation_at: body.expected_last_observation_at ?? null,
1703
+ overwrite: body.overwrite === true,
1704
+ changes: body.changes,
1705
+ idempotency_prefix: body.idempotency_prefix,
1706
+ });
1707
+ if (result.status === "conflict") {
1708
+ return res.status(409).json({ success: false, ...result });
1709
+ }
1710
+ if (result.status === "validation_error") {
1711
+ return res.status(400).json({ success: false, ...result });
1712
+ }
1713
+ return res.json({ success: true, ...result });
1714
+ }
1715
+ catch (error) {
1716
+ if (error instanceof Error && error.message.includes("Not authenticated")) {
1717
+ return sendError(res, 401, "AUTH_REQUIRED", error.message);
1718
+ }
1719
+ logError("APIError:entity_batch_correct", req, error);
1720
+ const message = error instanceof Error ? error.message : "Failed to apply batch correction";
1721
+ return sendError(res, 500, "DB_QUERY_FAILED", message);
1722
+ }
1723
+ });
1614
1724
  // GET /api/entities/:id/observations - Get observations for entity (FU-601)
1615
1725
  // REQUIRES AUTHENTICATION - verifies entity belongs to authenticated user
1616
1726
  app.get("/entities/:id/observations", async (req, res) => {
@@ -2206,17 +2316,24 @@ app.get("/sources", async (req, res) => {
2206
2316
  const offset = parseInt(req.query.offset) || 0;
2207
2317
  // Build query - ALWAYS filter by authenticated user_id
2208
2318
  let query = db.from("sources").select("*", { count: "exact" }).eq("user_id", userId); // SECURITY: Always filter by authenticated user
2209
- // Filter by MIME type
2319
+ // Filter by MIME/type query.
2320
+ // UX behavior: "wav" should match either MIME (audio/wav) OR filename extension (.wav).
2210
2321
  if (mimeType) {
2211
- query = query.eq("mime_type", mimeType);
2322
+ const mimeNeedle = mimeType.trim();
2323
+ if (mimeNeedle.includes("/")) {
2324
+ query = query.ilike("mime_type", `%${mimeNeedle}%`);
2325
+ }
2326
+ else {
2327
+ query = query.or(`mime_type.ilike.%${mimeNeedle}%,original_filename.ilike.%${mimeNeedle}%`);
2328
+ }
2212
2329
  }
2213
- // Filter by source type
2330
+ // Filter by source type (partial match for UX consistency)
2214
2331
  if (sourceType) {
2215
- query = query.eq("source_type", sourceType);
2332
+ query = query.ilike("source_type", `%${sourceType}%`);
2216
2333
  }
2217
- // Search in file names and raw text
2334
+ // Search in filenames
2218
2335
  if (search) {
2219
- query = query.or(`file_name.ilike.%${search}%,original_filename.ilike.%${search}%`);
2336
+ query = query.ilike("original_filename", `%${search}%`);
2220
2337
  }
2221
2338
  // Sort by created_at descending (most recent first)
2222
2339
  query = query.order("created_at", { ascending: false });
@@ -2259,7 +2376,11 @@ app.get("/sources/:id", async (req, res) => {
2259
2376
  if (!source) {
2260
2377
  return sendError(res, 404, "RESOURCE_NOT_FOUND", "Source not found");
2261
2378
  }
2262
- return res.json(source);
2379
+ const filesystemAbsolutePath = resolveLocalSourceFilePath(source.storage_url);
2380
+ const payload = filesystemAbsolutePath != null
2381
+ ? { ...source, filesystem_absolute_path: filesystemAbsolutePath }
2382
+ : { ...source };
2383
+ return res.json(payload);
2263
2384
  }
2264
2385
  catch (error) {
2265
2386
  return handleApiError(req, res, error, "Failed to get source", "DB_QUERY_FAILED", "APIError:source_detail");
@@ -2312,9 +2433,37 @@ app.get("/sources/:id/content", async (req, res) => {
2312
2433
  return sendError(res, 404, "NO_CONTENT", "Source has no stored file content");
2313
2434
  }
2314
2435
  const buffer = await downloadRawContent(sourceToServe.storage_url);
2315
- const mimeType = sourceToServe.mime_type || "application/octet-stream";
2316
2436
  const filename = sourceToServe.original_filename || `source-${sourceId}`;
2317
- const inline = /^(application\/pdf|text\/|image\/)/.test(mimeType);
2437
+ const rawMime = (sourceToServe.mime_type || "").trim().toLowerCase();
2438
+ let mimeType = (sourceToServe.mime_type || "").trim() || "application/octet-stream";
2439
+ if (!rawMime || rawMime === "application/octet-stream") {
2440
+ const f = filename.toLowerCase();
2441
+ if (f.endsWith(".wav"))
2442
+ mimeType = "audio/wav";
2443
+ else if (f.endsWith(".mp3"))
2444
+ mimeType = "audio/mpeg";
2445
+ else if (f.endsWith(".m4a"))
2446
+ mimeType = "audio/mp4";
2447
+ else if (f.endsWith(".aac"))
2448
+ mimeType = "audio/aac";
2449
+ else if (f.endsWith(".ogg") || f.endsWith(".oga"))
2450
+ mimeType = "audio/ogg";
2451
+ else if (f.endsWith(".flac"))
2452
+ mimeType = "audio/flac";
2453
+ else if (f.endsWith(".webm"))
2454
+ mimeType = "audio/webm";
2455
+ else if (f.endsWith(".pdf"))
2456
+ mimeType = "application/pdf";
2457
+ else if (f.endsWith(".png"))
2458
+ mimeType = "image/png";
2459
+ else if (f.endsWith(".jpg") || f.endsWith(".jpeg"))
2460
+ mimeType = "image/jpeg";
2461
+ else if (f.endsWith(".gif"))
2462
+ mimeType = "image/gif";
2463
+ else if (f.endsWith(".webp"))
2464
+ mimeType = "image/webp";
2465
+ }
2466
+ const inline = /^(application\/pdf|text\/|image\/|audio\/)/i.test(mimeType);
2318
2467
  res.setHeader("Content-Type", mimeType);
2319
2468
  res.setHeader("Content-Length", buffer.length);
2320
2469
  res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename="${filename.replace(/"/g, '\\"')}"`);
@@ -2473,6 +2622,17 @@ app.post("/observations/create", async (req, res) => {
2473
2622
  async function storeStructuredForApi(params) {
2474
2623
  const { userId, entities, sourcePriority, idempotencyKey, originalFilename } = params;
2475
2624
  const { resolveEntity } = await import("./services/entity_resolution.js");
2625
+ const { detectFlatPackedRows, FlatPackedRowsError } = await import("./services/flat_packed_detection.js");
2626
+ // Reject flat-packed rows (whole tables smuggled into a single entity as
2627
+ // `<prefix>_<index>_<suffix>` keys). These cannot produce per-row snapshots
2628
+ // and are almost always a caller bug. The caller should split into one
2629
+ // entity per row and retry.
2630
+ for (const entityData of entities) {
2631
+ const detection = detectFlatPackedRows(entityData);
2632
+ if (detection.detected) {
2633
+ throw new FlatPackedRowsError(detection);
2634
+ }
2635
+ }
2476
2636
  const { data: existingSource, error: existingSourceError } = await db
2477
2637
  .from("sources")
2478
2638
  .select("id")
@@ -2520,10 +2680,29 @@ async function storeStructuredForApi(params) {
2520
2680
  });
2521
2681
  const createdEntities = [];
2522
2682
  for (const entityData of entities) {
2523
- const entity_type = entityData.entity_type;
2683
+ let entity_type = entityData.entity_type;
2524
2684
  if (!entity_type) {
2525
2685
  throw new Error("entity_type is required for each entity");
2526
2686
  }
2687
+ // Schema-agnostic duplicate-type collapse (e.g. `places` -> `place`,
2688
+ // aliased-type -> canonical). Applied before storing so the resolved
2689
+ // entity_id hashes into the canonical type rather than a near-duplicate.
2690
+ try {
2691
+ const { schemaRegistry } = await import("./services/schema_registry.js");
2692
+ const active = await schemaRegistry.loadActiveSchema(entity_type, userId);
2693
+ if (!active) {
2694
+ const { findEquivalentEntityType } = await import("./services/entity_type_equivalence.js");
2695
+ const match = await findEquivalentEntityType(entity_type, { userId });
2696
+ if (match) {
2697
+ logger.warn(`[STORE] Collapsing new entity_type "${entity_type}" -> existing ` +
2698
+ `"${match.canonical_entity_type}" (reason: ${match.reason}).`);
2699
+ entity_type = match.canonical_entity_type;
2700
+ }
2701
+ }
2702
+ }
2703
+ catch (equivErr) {
2704
+ logger.warn(`Equivalence check failed for ${entity_type}: ${equivErr instanceof Error ? equivErr.message : String(equivErr)}`);
2705
+ }
2527
2706
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- intentional omit
2528
2707
  const { entity_type: _removed, ...fields } = entityData;
2529
2708
  const entity_id = await resolveEntity({
@@ -2550,6 +2729,28 @@ async function storeStructuredForApi(params) {
2550
2729
  catch (snapshotErr) {
2551
2730
  logger.warn(`Snapshot recompute failed for ${entity_id}: ${snapshotErr}`);
2552
2731
  }
2732
+ // Schema-driven auto-linking: if the entity's active schema declares
2733
+ // reference_fields, create typed relationships to the referenced
2734
+ // entities (REFERS_TO by default). Silent fallback when no schema or no
2735
+ // match exists — never invent targets.
2736
+ try {
2737
+ const { schemaRegistry } = await import("./services/schema_registry.js");
2738
+ const schemaEntry = await schemaRegistry.loadActiveSchema(entity_type, userId);
2739
+ if (schemaEntry?.schema_definition?.reference_fields?.length) {
2740
+ const { autoLinkReferenceFields } = await import("./services/schema_reference_linking.js");
2741
+ await autoLinkReferenceFields({
2742
+ entityId: entity_id,
2743
+ entityType: entity_type,
2744
+ fields,
2745
+ schema: schemaEntry.schema_definition,
2746
+ userId,
2747
+ sourceId: storageResult.sourceId,
2748
+ });
2749
+ }
2750
+ }
2751
+ catch (linkErr) {
2752
+ logger.warn(`Auto-link reference fields failed for ${entity_type}/${entity_id}: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`);
2753
+ }
2553
2754
  createdEntities.push({
2554
2755
  entity_id,
2555
2756
  entity_type,
@@ -2565,13 +2766,16 @@ async function storeStructuredForApi(params) {
2565
2766
  };
2566
2767
  }
2567
2768
  async function storeUnstructuredForApi(params) {
2568
- const { fileContent, mimeType, idempotencyKey, originalFilename, userId, } = params;
2569
- const fileBuffer = Buffer.from(fileContent, "base64");
2769
+ const { fileContent, fileBuffer, mimeType, idempotencyKey, originalFilename, userId, } = params;
2770
+ const resolvedFileBuffer = fileBuffer ?? (fileContent !== undefined ? Buffer.from(fileContent, "base64") : undefined);
2771
+ if (!resolvedFileBuffer) {
2772
+ throw new Error("fileContent or fileBuffer is required for unstructured storage");
2773
+ }
2570
2774
  const resolvedIdempotencyKey = idempotencyKey ??
2571
- (await import("node:crypto")).createHash("sha256").update(fileBuffer).digest("hex");
2775
+ (await import("node:crypto")).createHash("sha256").update(resolvedFileBuffer).digest("hex");
2572
2776
  const storageResult = await storeRawContent({
2573
2777
  userId,
2574
- fileBuffer,
2778
+ fileBuffer: resolvedFileBuffer,
2575
2779
  mimeType,
2576
2780
  originalFilename: originalFilename?.trim() || undefined,
2577
2781
  idempotencyKey: resolvedIdempotencyKey,
@@ -2616,36 +2820,30 @@ app.post("/store", async (req, res) => {
2616
2820
  });
2617
2821
  }
2618
2822
  if (hasUnstructured) {
2619
- let fileContent = parsed.data.file_content;
2823
+ const fileContent = parsed.data.file_content;
2620
2824
  let mimeType = parsed.data.mime_type;
2621
2825
  let originalFilename = parsed.data.original_filename;
2826
+ let resolvedFileBuffer;
2622
2827
  if (hasFilePath) {
2623
2828
  const resolvedPath = path.isAbsolute(parsed.data.file_path)
2624
2829
  ? parsed.data.file_path
2625
2830
  : path.resolve(process.cwd(), parsed.data.file_path);
2626
- const fileBuffer = fs.readFileSync(resolvedPath);
2627
- fileContent = fileBuffer.toString("base64");
2831
+ resolvedFileBuffer = fs.readFileSync(resolvedPath);
2628
2832
  if (!mimeType) {
2629
2833
  const ext = path.extname(resolvedPath).toLowerCase();
2630
- mimeType =
2631
- ext === ".pdf"
2632
- ? "application/pdf"
2633
- : ext === ".csv"
2634
- ? "text/csv"
2635
- : ext === ".json"
2636
- ? "application/json"
2637
- : "text/plain";
2834
+ mimeType = getMimeTypeFromExtension(ext) || "application/octet-stream";
2638
2835
  }
2639
2836
  originalFilename = originalFilename || path.basename(resolvedPath);
2640
2837
  }
2641
- if (!fileContent || !mimeType) {
2838
+ if ((!fileContent && !resolvedFileBuffer) || !mimeType) {
2642
2839
  return sendError(res, 400, "VALIDATION_ERROR", "Unstructured store requires file_content+mime_type or file_path");
2643
2840
  }
2644
2841
  unstructuredResult = await storeUnstructuredForApi({
2645
2842
  userId,
2646
2843
  fileContent,
2844
+ fileBuffer: resolvedFileBuffer,
2647
2845
  mimeType,
2648
- idempotencyKey: parsed.data.file_idempotency_key,
2846
+ idempotencyKey: parsed.data.file_idempotency_key ?? (!hasEntities ? parsed.data.idempotency_key : undefined),
2649
2847
  originalFilename,
2650
2848
  });
2651
2849
  }
@@ -2664,6 +2862,34 @@ app.post("/store", async (req, res) => {
2664
2862
  if (error instanceof Error && error.message.includes("Not authenticated")) {
2665
2863
  return sendError(res, 401, "AUTH_REQUIRED", error.message);
2666
2864
  }
2865
+ const errCode = error && typeof error === "object"
2866
+ ? error.code
2867
+ : undefined;
2868
+ if (errCode === "ERR_FORBIDDEN_ENTITY_TYPE" ||
2869
+ errCode === "ERR_PLURAL_ENTITY_TYPE") {
2870
+ const message = error instanceof Error ? error.message : String(error);
2871
+ logWarn("EntityTypeGuardError:store", req, { code: errCode, message });
2872
+ return sendError(res, 400, errCode, message);
2873
+ }
2874
+ if (error &&
2875
+ typeof error === "object" &&
2876
+ errCode === "ERR_FLAT_PACKED_ROWS") {
2877
+ const err = error;
2878
+ logWarn("FlatPackedRowsError:store", req, {
2879
+ prefix: err.detection.prefix,
2880
+ row_count: err.detection.indices?.length ?? 0,
2881
+ });
2882
+ return res.status(400).json({
2883
+ error: {
2884
+ code: "ERR_FLAT_PACKED_ROWS",
2885
+ message: err.message,
2886
+ prefix: err.detection.prefix,
2887
+ row_count: err.detection.indices?.length ?? 0,
2888
+ example_keys: err.detection.exampleKeys ?? [],
2889
+ suggested_entities: err.detection.suggestedEntities ?? [],
2890
+ },
2891
+ });
2892
+ }
2667
2893
  logError("APIError:store", req, error);
2668
2894
  const message = error instanceof Error ? error.message : "Failed to store payload";
2669
2895
  return sendError(res, 500, "DB_QUERY_FAILED", message);
@@ -3384,6 +3610,13 @@ app.post("/get_schema_recommendations", async (req, res) => {
3384
3610
  }
3385
3611
  });
3386
3612
  // POST /update_schema_incremental - Incrementally update schema by adding or removing fields
3613
+ //
3614
+ // Routes through SchemaRegistryService.updateSchemaIncremental so that:
3615
+ // - Version bumps are consistent across CLI, MCP and HTTP.
3616
+ // - Schema-level declarations (canonical_name_fields, temporal_fields,
3617
+ // reference_fields, aliases) are preserved across incremental updates.
3618
+ // - migrate_existing correctly backfills raw_fragments → observations.
3619
+ // - Reducer policies track added/removed fields.
3387
3620
  app.post("/update_schema_incremental", async (req, res) => {
3388
3621
  const parsed = UpdateSchemaIncrementalRequestSchema.safeParse(req.body);
3389
3622
  if (!parsed.success) {
@@ -3394,87 +3627,41 @@ app.post("/update_schema_incremental", async (req, res) => {
3394
3627
  }
3395
3628
  try {
3396
3629
  const userId = await getAuthenticatedUserId(req, parsed.data.user_id);
3397
- const { entity_type, fields_to_add, fields_to_remove, schema_version, user_specific = false, activate = true, } = parsed.data;
3398
- // Get current schema
3399
- let query = db
3400
- .from("schema_registry")
3401
- .select("*")
3402
- .eq("entity_type", entity_type)
3403
- .eq("active", true);
3404
- if (user_specific && userId) {
3405
- query = query.eq("user_id", userId);
3630
+ const { entity_type, fields_to_add, fields_to_remove, schema_version, user_specific = false, activate = true, migrate_existing = false, force = false, } = parsed.data;
3631
+ const { schemaRegistry } = await import("./services/schema_registry.js");
3632
+ let newSchema;
3633
+ try {
3634
+ newSchema = await schemaRegistry.updateSchemaIncremental({
3635
+ entity_type,
3636
+ fields_to_add: (fields_to_add || []),
3637
+ fields_to_remove: fields_to_remove || [],
3638
+ schema_version,
3639
+ user_specific,
3640
+ user_id: userId,
3641
+ activate,
3642
+ migrate_existing,
3643
+ force,
3644
+ });
3406
3645
  }
3407
- else {
3408
- query = query.is("user_id", null);
3409
- }
3410
- const { data: currentSchema, error: fetchError } = await query.single();
3411
- if (fetchError && fetchError.code !== "PGRST116") {
3412
- logError("DbError:update_schema_incremental", req, fetchError);
3413
- return sendError(res, 500, "DB_QUERY_FAILED", fetchError.message);
3414
- }
3415
- // Build new schema definition
3416
- const baseSchema = currentSchema?.schema_definition || { fields: {} };
3417
- const newFields = {};
3418
- (fields_to_add || []).forEach((field) => {
3419
- newFields[field.field_name] = {
3420
- type: field.field_type,
3421
- required: field.required || false,
3422
- reducer_strategy: field.reducer_strategy || "highest_priority",
3423
- };
3424
- });
3425
- const mergedFields = { ...baseSchema.fields, ...newFields };
3426
- // Remove fields (data preserved in observations, excluded from snapshots via reducer projection)
3427
- const removedFields = [];
3428
- for (const fieldName of fields_to_remove || []) {
3429
- if (mergedFields[fieldName]) {
3430
- delete mergedFields[fieldName];
3431
- removedFields.push(fieldName);
3646
+ catch (err) {
3647
+ const code = err?.code;
3648
+ if (code === "ERR_FORBIDDEN_ENTITY_TYPE" ||
3649
+ code === "ERR_PLURAL_ENTITY_TYPE") {
3650
+ return sendError(res, 400, code, err.message);
3432
3651
  }
3433
- }
3434
- const updatedSchema = {
3435
- ...baseSchema,
3436
- fields: mergedFields,
3437
- };
3438
- // Determine version bump: major for removals, minor for additions only
3439
- const hasRemovals = removedFields.length > 0;
3440
- const defaultVersion = hasRemovals
3441
- ? (() => {
3442
- const parts = (currentSchema?.schema_version || "1.0.0").split(".");
3443
- return `${parseInt(parts[0] || "1") + 1}.0.0`;
3444
- })()
3445
- : `${parseInt(currentSchema?.schema_version || "1") + 1}`;
3446
- // Insert new schema version
3447
- const { data: newSchemaVersion, error: insertError } = await db
3448
- .from("schema_registry")
3449
- .insert({
3450
- entity_type,
3451
- schema_version: schema_version || defaultVersion,
3452
- schema_definition: updatedSchema,
3453
- reducer_config: currentSchema?.reducer_config || {},
3454
- user_id: user_specific ? userId : null,
3455
- active: activate,
3456
- created_at: new Date().toISOString(),
3457
- })
3458
- .select()
3459
- .single();
3460
- if (insertError) {
3461
- logError("DbError:update_schema_incremental", req, insertError);
3462
- return sendError(res, 500, "DB_QUERY_FAILED", insertError.message);
3463
- }
3464
- // Deactivate old schema if activating new one
3465
- if (activate && currentSchema) {
3466
- await db.from("schema_registry").update({ active: false }).eq("id", currentSchema.id);
3652
+ throw err;
3467
3653
  }
3468
3654
  logDebug("Success:update_schema_incremental", req, {
3469
3655
  entity_type,
3470
3656
  fields_added: (fields_to_add || []).length,
3471
- fields_removed: removedFields.length,
3657
+ fields_removed: (fields_to_remove || []).length,
3658
+ migrate_existing,
3472
3659
  });
3473
3660
  return res.json({
3474
3661
  success: true,
3475
- schema: newSchemaVersion,
3476
- schema_version: newSchemaVersion.schema_version,
3477
- fields_removed: removedFields,
3662
+ schema: newSchema,
3663
+ schema_version: newSchema.schema_version,
3664
+ fields_removed: fields_to_remove || [],
3478
3665
  });
3479
3666
  }
3480
3667
  catch (error) {
@@ -3482,6 +3669,11 @@ app.post("/update_schema_incremental", async (req, res) => {
3482
3669
  }
3483
3670
  });
3484
3671
  // POST /register_schema - Register new schema or schema version
3672
+ //
3673
+ // Routes through SchemaRegistryService.register so that schema_definition and
3674
+ // reducer_config are validated consistently across CLI, MCP and HTTP, and
3675
+ // schema-level declarations (canonical_name_fields, temporal_fields,
3676
+ // reference_fields, aliases) are rejected early when malformed.
3485
3677
  app.post("/register_schema", async (req, res) => {
3486
3678
  const parsed = RegisterSchemaRequestSchema.safeParse(req.body);
3487
3679
  if (!parsed.success) {
@@ -3492,34 +3684,39 @@ app.post("/register_schema", async (req, res) => {
3492
3684
  }
3493
3685
  try {
3494
3686
  const userId = await getAuthenticatedUserId(req, parsed.data.user_id);
3495
- const { entity_type, schema_definition, reducer_config, schema_version = "1.0", user_specific = false, activate = false, } = parsed.data;
3496
- // Insert new schema
3497
- const { data: newSchema, error } = await db
3498
- .from("schema_registry")
3499
- .insert({
3500
- entity_type,
3501
- schema_version,
3502
- schema_definition,
3503
- reducer_config,
3504
- user_id: user_specific ? userId : null,
3505
- active: activate,
3506
- created_at: new Date().toISOString(),
3507
- })
3508
- .select()
3509
- .single();
3510
- if (error) {
3511
- logError("DbError:register_schema", req, error);
3512
- return sendError(res, 500, "DB_QUERY_FAILED", error.message);
3687
+ const { entity_type, schema_definition, reducer_config, schema_version = "1.0", user_specific = false, activate = false, force = false, } = parsed.data;
3688
+ const { schemaRegistry } = await import("./services/schema_registry.js");
3689
+ let newSchema;
3690
+ try {
3691
+ newSchema = await schemaRegistry.register({
3692
+ entity_type,
3693
+ schema_version,
3694
+ schema_definition: schema_definition,
3695
+ reducer_config: (reducer_config || { merge_policies: {} }),
3696
+ user_id: userId,
3697
+ user_specific,
3698
+ activate,
3699
+ force,
3700
+ });
3701
+ }
3702
+ catch (err) {
3703
+ const code = err?.code;
3704
+ const message = err instanceof Error ? err.message : String(err);
3705
+ logWarn("ValidationError:register_schema", req, { error: message });
3706
+ if (code === "ERR_FORBIDDEN_ENTITY_TYPE" ||
3707
+ code === "ERR_PLURAL_ENTITY_TYPE") {
3708
+ return sendError(res, 400, code, message);
3709
+ }
3710
+ return sendError(res, 400, "SCHEMA_VALIDATION_FAILED", message);
3513
3711
  }
3514
- // If activating, deactivate other schemas for this entity type
3515
3712
  if (activate) {
3516
- await db
3517
- .from("schema_registry")
3518
- .update({ active: false })
3519
- .eq("entity_type", entity_type)
3520
- .not("id", "eq", newSchema.id);
3521
- if (user_specific) {
3522
- await db.from("schema_registry").update({ active: false }).eq("user_id", userId);
3713
+ try {
3714
+ await schemaRegistry.activate(entity_type, newSchema.schema_version);
3715
+ }
3716
+ catch (err) {
3717
+ logWarn("ActivateError:register_schema", req, {
3718
+ error: err instanceof Error ? err.message : String(err),
3719
+ });
3523
3720
  }
3524
3721
  }
3525
3722
  logDebug("Success:register_schema", req, { entity_type, schema_version });
@@ -3711,35 +3908,20 @@ app.post("/recompute_snapshots_by_type", async (req, res) => {
3711
3908
  entity_ids: entityIds,
3712
3909
  });
3713
3910
  }
3714
- const { observationReducer } = await import("./reducers/observation_reducer.js");
3911
+ // Use recomputeSnapshot so that:
3912
+ // - The active schema is loaded and applied (schema-driven canonical_name
3913
+ // and timeline events).
3914
+ // - Embedding preparation, snapshot upsert, and timeline event upsert
3915
+ // all run through the shared pipeline.
3916
+ const { recomputeSnapshot } = await import("./services/snapshot_computation.js");
3715
3917
  let recomputed = 0;
3716
3918
  let errors = 0;
3717
3919
  const errorDetails = [];
3718
3920
  for (const entityId of entityIds) {
3719
3921
  try {
3720
- const { data: observations } = await db
3721
- .from("observations")
3722
- .select("*")
3723
- .eq("entity_id", entityId)
3724
- .order("observed_at", { ascending: false });
3725
- if (!observations || observations.length === 0)
3726
- continue;
3727
- const newSnapshot = await observationReducer.computeSnapshot(entityId, observations);
3728
- if (!newSnapshot)
3729
- continue;
3730
- const rowWithEmbedding = await prepareEntitySnapshotWithEmbedding({
3731
- entity_id: newSnapshot.entity_id,
3732
- entity_type: newSnapshot.entity_type,
3733
- schema_version: newSnapshot.schema_version,
3734
- snapshot: newSnapshot.snapshot,
3735
- computed_at: newSnapshot.computed_at,
3736
- observation_count: newSnapshot.observation_count,
3737
- last_observation_at: newSnapshot.last_observation_at,
3738
- provenance: newSnapshot.provenance,
3739
- user_id: newSnapshot.user_id,
3740
- });
3741
- await upsertEntitySnapshotWithEmbedding(rowWithEmbedding);
3742
- recomputed++;
3922
+ const snapshot = await recomputeSnapshot(entityId, userId);
3923
+ if (snapshot)
3924
+ recomputed++;
3743
3925
  }
3744
3926
  catch (err) {
3745
3927
  errors++;