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/cli/index.js CHANGED
@@ -18,6 +18,7 @@ import { createApiClient } from "../shared/api_client.js";
18
18
  import { getOpenApiOperationMapping } from "../shared/contract_mappings.js";
19
19
  import { getEntityDisplayName } from "../shared/entity_display_name.js";
20
20
  import { getRecordDisplaySummary } from "../shared/record_display_summary.js";
21
+ import { getMimeTypeFromExtension } from "../services/file_text_extraction.js";
21
22
  import { CONFIG_DIR, CONFIG_PATH, CANDIDATE_API_PORTS, USER_ENV_PATH, clearConfig, discoverApiInstances, isTokenExpired, isProd, readConfig, rememberKnownApiPort, resolveBaseUrl, waitForApiReady, waitForHealth, writeConfig, } from "./config.js";
22
23
  import { getLatestFromRegistry, isUpdateAvailable, formatUpgradeCommand, } from "../version_check.js";
23
24
  import { shutdownLocalTransport } from "../shared/local_transport.js";
@@ -43,6 +44,10 @@ const BANNER_ANSI = {
43
44
  clearLineFull: `${ESC}[2K`,
44
45
  };
45
46
  const MAX_SESSION_HISTORY = 100;
47
+ function inferMimeTypeForPath(filePath) {
48
+ const ext = path.extname(filePath).toLowerCase();
49
+ return getMimeTypeFromExtension(ext) ?? "application/octet-stream";
50
+ }
46
51
  export function getPromptPlaceholder(buffer, placeholderText = "/ for commands") {
47
52
  return placeholderText.length > 0 && buffer.length === 0 ? placeholderText : "";
48
53
  }
@@ -3661,9 +3666,28 @@ const initCommand = program
3661
3666
  .option("--use-current-dir-targets <choice>", "When npm package is detected, choose whether to use current directory targets: yes/no")
3662
3667
  .option("--key-source <source>", "For key_derived auth: create, existing, or skip (skip corresponding prompt when provided)")
3663
3668
  .option("--key-path <path>", "Path to an existing key file for key_derived auth")
3669
+ .option("--idempotent", "If already initialized, exit 0 with a report instead of prompting or failing")
3664
3670
  .action(async (opts) => {
3665
3671
  try {
3666
3672
  const outputMode = resolveOutputMode();
3673
+ if (opts.idempotent) {
3674
+ try {
3675
+ const { runDoctor } = await import("./doctor.js");
3676
+ const report = await runDoctor({ cwd: process.cwd() });
3677
+ if (report.data.initialized && !opts.force) {
3678
+ writeOutput({
3679
+ ok: true,
3680
+ already_initialized: true,
3681
+ data: report.data,
3682
+ neotoma: { version: report.neotoma.version },
3683
+ }, outputMode);
3684
+ return;
3685
+ }
3686
+ }
3687
+ catch {
3688
+ // Fall through to normal init on any detection error.
3689
+ }
3690
+ }
3667
3691
  const interactiveRequested = Boolean(opts.interactive || opts.advanced);
3668
3692
  let useAdvancedPrompts = interactiveRequested;
3669
3693
  const applyDefaultsWithoutPrompts = Boolean(opts.yes || !interactiveRequested);
@@ -6786,6 +6810,111 @@ program
6786
6810
  process.on("SIGINT", onExit);
6787
6811
  process.on("SIGTERM", onExit);
6788
6812
  });
6813
+ program
6814
+ .command("doctor")
6815
+ .description("Consolidated diagnostics for agent-led onboarding (install/runtime/api/mcp/cli-instructions/permissions)")
6816
+ .action(async () => {
6817
+ const outputMode = resolveOutputMode();
6818
+ const { runDoctor } = await import("./doctor.js");
6819
+ const report = await runDoctor({ cwd: process.cwd() });
6820
+ writeOutput(report, outputMode);
6821
+ });
6822
+ program
6823
+ .command("setup")
6824
+ .description("One-shot setup: init (idempotent) + MCP + CLI instructions + permissions for the current harness")
6825
+ .option("--tool <tool>", "Target harness (claude-code|cursor|codex|openclaw|claude-desktop)")
6826
+ .option("--scope <scope>", "Permission scope (project|user|both)", "project")
6827
+ .option("--dry-run", "Plan the setup without writing files", false)
6828
+ .option("--yes", "Assume yes for prompts (currently unused; runners default to non-interactive)", false)
6829
+ .option("--skip-permissions", "Skip permission-file writes", false)
6830
+ .action(async (opts) => {
6831
+ const outputMode = resolveOutputMode();
6832
+ const { runSetup } = await import("./setup.js");
6833
+ const report = await runSetup({
6834
+ tool: opts.tool ?? null,
6835
+ dryRun: Boolean(opts.dryRun),
6836
+ yes: Boolean(opts.yes),
6837
+ skipPermissions: Boolean(opts.skipPermissions),
6838
+ scope: opts.scope ?? "project",
6839
+ cwd: process.cwd(),
6840
+ });
6841
+ const exitCode = report.overall_ok ? 0 : 1;
6842
+ writeOutput(report, outputMode);
6843
+ process.exitCode = exitCode;
6844
+ });
6845
+ // `neotoma hooks` — lifecycle hook install/uninstall/status. Deliberately
6846
+ // separate from `neotoma setup`: hooks are offered during activation (after
6847
+ // the first-Aha timeline) rather than at install time. See install.md.
6848
+ const hooksCommand = program
6849
+ .command("hooks")
6850
+ .description("Install Neotoma lifecycle hooks for a supported harness (cursor|claude-code|codex|opencode|claude-agent-sdk). Offered during activation, not setup.");
6851
+ hooksCommand
6852
+ .command("status")
6853
+ .description("Print per-harness hook installation state (uses `neotoma doctor`).")
6854
+ .action(async () => {
6855
+ const outputMode = resolveOutputMode();
6856
+ const { runHooksStatus } = await import("./hooks.js");
6857
+ const status = await runHooksStatus({ cwd: process.cwd() });
6858
+ writeOutput(status, outputMode);
6859
+ });
6860
+ hooksCommand
6861
+ .command("install")
6862
+ .description("Install Neotoma hooks for the given harness after running guardrails.")
6863
+ .requiredOption("--tool <tool>", "Target harness (cursor|claude-code|codex|opencode|claude-agent-sdk)")
6864
+ .option("--dry-run", "Plan the install without writing files", false)
6865
+ .option("--yes", "Skip the confirmation prompt", false)
6866
+ .option("--force", "Proceed even when another hook plugin is already present", false)
6867
+ .action(async (opts) => {
6868
+ const outputMode = resolveOutputMode();
6869
+ const { runHooksInstall } = await import("./hooks.js");
6870
+ const { toHookHarness } = await import("./hooks_detect.js");
6871
+ const tool = toHookHarness(opts.tool);
6872
+ if (!tool) {
6873
+ writeOutput({
6874
+ ok: false,
6875
+ action: "install",
6876
+ message: `Unsupported --tool value: ${opts.tool}. Expected one of cursor|claude-code|codex|opencode|claude-agent-sdk.`,
6877
+ }, outputMode);
6878
+ process.exitCode = 1;
6879
+ return;
6880
+ }
6881
+ const result = await runHooksInstall({
6882
+ tool,
6883
+ cwd: process.cwd(),
6884
+ dryRun: Boolean(opts.dryRun),
6885
+ yes: Boolean(opts.yes),
6886
+ force: Boolean(opts.force),
6887
+ });
6888
+ writeOutput(result, outputMode);
6889
+ process.exitCode = result.ok ? 0 : 1;
6890
+ });
6891
+ hooksCommand
6892
+ .command("uninstall")
6893
+ .description("Remove Neotoma hook entries for the given harness (leaves other plugins in place).")
6894
+ .requiredOption("--tool <tool>", "Target harness (cursor|claude-code|codex|opencode|claude-agent-sdk)")
6895
+ .option("--dry-run", "Plan the uninstall without writing files", false)
6896
+ .action(async (opts) => {
6897
+ const outputMode = resolveOutputMode();
6898
+ const { runHooksUninstall } = await import("./hooks.js");
6899
+ const { toHookHarness } = await import("./hooks_detect.js");
6900
+ const tool = toHookHarness(opts.tool);
6901
+ if (!tool) {
6902
+ writeOutput({
6903
+ ok: false,
6904
+ action: "uninstall",
6905
+ message: `Unsupported --tool value: ${opts.tool}. Expected one of cursor|claude-code|codex|opencode|claude-agent-sdk.`,
6906
+ }, outputMode);
6907
+ process.exitCode = 1;
6908
+ return;
6909
+ }
6910
+ const result = await runHooksUninstall({
6911
+ tool,
6912
+ cwd: process.cwd(),
6913
+ dryRun: Boolean(opts.dryRun),
6914
+ });
6915
+ writeOutput(result, outputMode);
6916
+ process.exitCode = result.ok ? 0 : 1;
6917
+ });
6789
6918
  const storageCommand = program.command("storage").description("Storage locations and file paths");
6790
6919
  storageCommand
6791
6920
  .command("info")
@@ -7397,6 +7526,109 @@ backupCommand
7397
7526
  }
7398
7527
  }
7399
7528
  });
7529
+ // ── Mirror ────────────────────────────────────────────────────────────────
7530
+ const mirrorCommand = program
7531
+ .command("mirror")
7532
+ .description("Filesystem markdown mirror of entities/relationships/sources/timeline/schemas");
7533
+ mirrorCommand
7534
+ .command("enable")
7535
+ .description("Enable the markdown mirror and optionally configure path/kinds/git")
7536
+ .option("--path <dir>", "Mirror root directory (default: data/mirror)")
7537
+ .option("--kinds <list>", "Comma-separated kinds to mirror (entities,relationships,sources,timeline,schemas)")
7538
+ .option("--git", "Enable git-backed mirror (Phase 3)")
7539
+ .option("--no-git", "Disable git-backed mirror")
7540
+ .action(async (opts) => {
7541
+ const outputMode = resolveOutputMode();
7542
+ try {
7543
+ const { runMirrorEnable, formatMirrorConfig } = await import("./commands/mirror.js");
7544
+ const cfg = await runMirrorEnable({
7545
+ path: opts.path,
7546
+ kinds: opts.kinds,
7547
+ git: opts.git === true ? true : undefined,
7548
+ noGit: opts.git === false ? true : undefined,
7549
+ });
7550
+ if (outputMode === "json") {
7551
+ writeOutput(cfg, outputMode);
7552
+ }
7553
+ else {
7554
+ process.stdout.write(formatMirrorConfig(cfg) + "\n");
7555
+ process.stdout.write("\nRun `neotoma mirror rebuild` to populate the mirror from the database.\n");
7556
+ }
7557
+ }
7558
+ catch (err) {
7559
+ writeCliError(err instanceof Error ? err.message : String(err));
7560
+ process.exitCode = 1;
7561
+ }
7562
+ });
7563
+ mirrorCommand
7564
+ .command("disable")
7565
+ .description("Disable the markdown mirror (existing files are left in place)")
7566
+ .action(async () => {
7567
+ const outputMode = resolveOutputMode();
7568
+ try {
7569
+ const { runMirrorDisable, formatMirrorConfig } = await import("./commands/mirror.js");
7570
+ const cfg = await runMirrorDisable();
7571
+ if (outputMode === "json") {
7572
+ writeOutput(cfg, outputMode);
7573
+ }
7574
+ else {
7575
+ process.stdout.write(formatMirrorConfig(cfg) + "\n");
7576
+ }
7577
+ }
7578
+ catch (err) {
7579
+ writeCliError(err instanceof Error ? err.message : String(err));
7580
+ process.exitCode = 1;
7581
+ }
7582
+ });
7583
+ mirrorCommand
7584
+ .command("rebuild")
7585
+ .description("Regenerate markdown files for one or all mirror kinds from the database")
7586
+ .option("--kind <kind>", "One of: all, entities, relationships, sources, timeline, schemas", "all")
7587
+ .option("--entity-type <type>", "Restrict entity rebuild to a single entity_type")
7588
+ .option("--entity-id <id>", "Restrict entity rebuild to a single entity_id")
7589
+ .option("--clean", "Remove stale files in the mirror that no longer correspond to database rows", false)
7590
+ .action(async (opts) => {
7591
+ const outputMode = resolveOutputMode();
7592
+ try {
7593
+ const { runMirrorRebuild, formatRebuildReport } = await import("./commands/mirror.js");
7594
+ const result = await runMirrorRebuild({
7595
+ kind: opts.kind,
7596
+ entityType: opts.entityType,
7597
+ entityId: opts.entityId,
7598
+ clean: Boolean(opts.clean),
7599
+ });
7600
+ if (outputMode === "json") {
7601
+ writeOutput(result, outputMode);
7602
+ }
7603
+ else {
7604
+ process.stdout.write(formatRebuildReport(result) + "\n");
7605
+ }
7606
+ }
7607
+ catch (err) {
7608
+ writeCliError(err instanceof Error ? err.message : String(err));
7609
+ process.exitCode = 1;
7610
+ }
7611
+ });
7612
+ mirrorCommand
7613
+ .command("status")
7614
+ .description("Show mirror configuration and counts of mirrored files by kind")
7615
+ .action(async () => {
7616
+ const outputMode = resolveOutputMode();
7617
+ try {
7618
+ const { runMirrorStatus, formatMirrorStatus } = await import("./commands/mirror.js");
7619
+ const status = await runMirrorStatus();
7620
+ if (outputMode === "json") {
7621
+ writeOutput(status, outputMode);
7622
+ }
7623
+ else {
7624
+ process.stdout.write(formatMirrorStatus(status) + "\n");
7625
+ }
7626
+ }
7627
+ catch (err) {
7628
+ writeCliError(err instanceof Error ? err.message : String(err));
7629
+ process.exitCode = 1;
7630
+ }
7631
+ });
7400
7632
  // ── Logs ──────────────────────────────────────────────────────────────────
7401
7633
  const logsCommand = program.command("logs").description("View and decrypt persistent log files");
7402
7634
  logsCommand
@@ -7693,6 +7925,18 @@ apiCommand
7693
7925
  process.stderr.write(`neotoma api start: ${error}\n`);
7694
7926
  return;
7695
7927
  }
7928
+ // Persist preferred_env so plain CLI commands (e.g. `neotoma store`) route to
7929
+ // the same SQLite DB file as the running API, even when no --env flag or env
7930
+ // var is set. Read by `resolveLocalTransportEnv` in src/shared/local_transport.ts.
7931
+ try {
7932
+ const existingConfig = await readConfig();
7933
+ if (existingConfig.preferred_env !== envOpt) {
7934
+ await writeConfig({ ...existingConfig, preferred_env: envOpt });
7935
+ }
7936
+ }
7937
+ catch {
7938
+ // Non-fatal: preferred_env is an optimization, not required for api start.
7939
+ }
7696
7940
  const effectiveEnv = envOpt === "prod" ? "production" : "development";
7697
7941
  const apiLogsDir = path.join(CONFIG_DIR, effectiveEnv === "production" ? "logs_prod" : "logs");
7698
7942
  const apiLogPath = path.join(apiLogsDir, "api.log");
@@ -8790,6 +9034,8 @@ schemasCommand
8790
9034
  params: {
8791
9035
  query: {
8792
9036
  entity_type: opts.entityType,
9037
+ user_id: opts.userId,
9038
+ user_specific: opts.userSpecific,
8793
9039
  limit: Number(opts.limit),
8794
9040
  offset: Number(opts.offset),
8795
9041
  },
@@ -8942,14 +9188,74 @@ schemasCommand
8942
9188
  }
8943
9189
  fieldsToRemove = parsed;
8944
9190
  }
9191
+ const { data: existingSchemaData, error: existingSchemaError } = await api.GET("/schemas/{entity_type}", {
9192
+ params: {
9193
+ path: { entity_type: entityType },
9194
+ query: { user_id: opts.userId },
9195
+ },
9196
+ });
9197
+ const existingSchema = !existingSchemaError && existingSchemaData
9198
+ ? existingSchemaData
9199
+ : null;
9200
+ if (!existingSchema && fieldsToAdd && fieldsToAdd.length > 0) {
9201
+ const removalSet = new Set(fieldsToRemove || []);
9202
+ const bootstrapFields = Object.fromEntries(fieldsToAdd
9203
+ .filter((field) => !removalSet.has(field.field_name))
9204
+ .map((field) => [
9205
+ field.field_name,
9206
+ { type: field.field_type, required: field.required ?? false },
9207
+ ]));
9208
+ if (Object.keys(bootstrapFields).length === 0) {
9209
+ throw new Error("at least one field must remain after removals");
9210
+ }
9211
+ const bootstrapPolicies = Object.fromEntries(fieldsToAdd
9212
+ .filter((field) => !removalSet.has(field.field_name))
9213
+ .map((field) => [
9214
+ field.field_name,
9215
+ {
9216
+ strategy: field.reducer_strategy || "last_write",
9217
+ tie_breaker: "observed_at",
9218
+ },
9219
+ ]));
9220
+ const { data, error } = await api.POST("/register_schema", {
9221
+ body: {
9222
+ entity_type: entityType,
9223
+ schema_definition: { fields: bootstrapFields },
9224
+ reducer_config: { merge_policies: bootstrapPolicies },
9225
+ schema_version: opts.schemaVersion,
9226
+ activate: true,
9227
+ user_id: opts.userId,
9228
+ user_specific: opts.userSpecific,
9229
+ },
9230
+ });
9231
+ if (error)
9232
+ throw new Error("Failed to update schema");
9233
+ const registerResult = data;
9234
+ if (registerResult &&
9235
+ typeof registerResult["schema"] === "object" &&
9236
+ registerResult["schema"] !== null) {
9237
+ const schemaObj = registerResult["schema"];
9238
+ const normalizedSchema = {
9239
+ ...schemaObj,
9240
+ fields: schemaObj["schema_definition"]?.["fields"] ??
9241
+ schemaObj["fields"],
9242
+ };
9243
+ writeOutput({ ...registerResult, schema: normalizedSchema }, outputMode);
9244
+ }
9245
+ else {
9246
+ writeOutput(data, outputMode);
9247
+ }
9248
+ return;
9249
+ }
8945
9250
  const body = {
8946
9251
  entity_type: entityType,
8947
9252
  user_id: opts.userId,
8948
- activate: opts.activate,
8949
9253
  migrate_existing: opts.migrateExisting,
8950
9254
  schema_version: opts.schemaVersion,
8951
9255
  user_specific: opts.userSpecific,
8952
9256
  };
9257
+ if (opts.activate)
9258
+ body.activate = true;
8953
9259
  if (fieldsToAdd)
8954
9260
  body.fields_to_add = fieldsToAdd;
8955
9261
  if (fieldsToRemove)
@@ -9021,7 +9327,7 @@ schemasCommand
9021
9327
  ]));
9022
9328
  }
9023
9329
  const schemaDefinition = { fields: schemaFields };
9024
- const reducerConfig = {};
9330
+ const reducerConfig = { merge_policies: {} };
9025
9331
  const { data, error } = await api.POST("/register_schema", {
9026
9332
  body: {
9027
9333
  entity_type: entityType,
@@ -9057,8 +9363,7 @@ program
9057
9363
  .option("--set <types>", "Comma-separated data types to prioritize (e.g. project_files,chat_transcripts,meeting_notes,notes,code,email,financial,custom)")
9058
9364
  .option("--show", "Show current preferences", false)
9059
9365
  .option("--reset", "Reset preferences to default", false)
9060
- .action(async (cmd) => {
9061
- const opts = cmd.opts();
9366
+ .action(async (opts) => {
9062
9367
  const config = await readConfig();
9063
9368
  const token = await getCliToken();
9064
9369
  const api = createApiClient({
@@ -9146,8 +9451,7 @@ program
9146
9451
  .option("--top <n>", "Number of top candidates to show", "20")
9147
9452
  .option("--mode <mode>", "Discovery mode: quick, guided, full", "quick")
9148
9453
  .option("--output <format>", "Output format: text, json", "text")
9149
- .action(async (paths, cmd) => {
9150
- const opts = cmd.opts();
9454
+ .action(async (paths, opts) => {
9151
9455
  const { discover, formatDiscoveryOutput, formatDiscoveryJson } = await import("./discovery.js");
9152
9456
  const scanPaths = paths.length > 0
9153
9457
  ? paths
@@ -9172,8 +9476,7 @@ program
9172
9476
  .option("--preview", "Preview extracted data without storing", false)
9173
9477
  .option("--limit <n>", "Process only the N most recent conversations")
9174
9478
  .option("--filter <term>", "Only process conversations containing this term")
9175
- .action(async (filePath, cmd) => {
9176
- const opts = cmd.opts();
9479
+ .action(async (filePath, opts) => {
9177
9480
  const { parseTranscript, formatTranscriptPreview } = await import("./transcript_parser.js");
9178
9481
  const result = await parseTranscript({
9179
9482
  filePath,
@@ -9223,39 +9526,33 @@ program
9223
9526
  }
9224
9527
  let unstructuredBody = {};
9225
9528
  if (hasUnstructured) {
9226
- let fileBuffer;
9227
9529
  let originalFilename;
9228
9530
  if (opts.filePath) {
9229
9531
  const resolvedPath = path.isAbsolute(opts.filePath)
9230
9532
  ? opts.filePath
9231
9533
  : path.resolve(process.cwd(), opts.filePath);
9232
- fileBuffer = await fs.readFile(resolvedPath);
9534
+ await fs.access(resolvedPath);
9233
9535
  originalFilename = path.basename(resolvedPath);
9536
+ unstructuredBody = {
9537
+ file_path: resolvedPath,
9538
+ mime_type: inferMimeTypeForPath(resolvedPath),
9539
+ original_filename: originalFilename,
9540
+ };
9234
9541
  }
9235
9542
  else {
9236
- fileBuffer = Buffer.from(opts.fileContent, "utf-8");
9543
+ const fileBuffer = Buffer.from(opts.fileContent, "utf-8");
9237
9544
  originalFilename = undefined;
9545
+ unstructuredBody = {
9546
+ file_content: fileBuffer.toString("base64"),
9547
+ mime_type: "text/plain",
9548
+ original_filename: originalFilename,
9549
+ };
9238
9550
  }
9239
- const ext = originalFilename ? path.extname(originalFilename).toLowerCase() : "";
9240
- const mimeMap = {
9241
- ".json": "application/json",
9242
- ".txt": "text/plain",
9243
- ".csv": "text/csv",
9244
- ".md": "text/markdown",
9245
- ".pdf": "application/pdf",
9246
- };
9247
- const mimeType = mimeMap[ext] ?? "application/octet-stream";
9248
- const contentBase64 = fileBuffer.toString("base64");
9249
- unstructuredBody = {
9250
- file_content: contentBase64,
9251
- mime_type: mimeType,
9252
- original_filename: originalFilename,
9253
- };
9254
9551
  if (opts.fileIdempotencyKey) {
9255
9552
  unstructuredBody.file_idempotency_key = opts.fileIdempotencyKey;
9256
9553
  }
9257
9554
  else if (!hasStructured && opts.idempotencyKey) {
9258
- unstructuredBody.idempotency_key = opts.idempotencyKey;
9555
+ unstructuredBody.file_idempotency_key = opts.idempotencyKey;
9259
9556
  }
9260
9557
  }
9261
9558
  if (entities && !Array.isArray(entities)) {
@@ -9523,23 +9820,9 @@ program
9523
9820
  if (!stat?.isFile()) {
9524
9821
  throw new Error(`File not found or not a file: ${resolvedPath}`);
9525
9822
  }
9526
- const fileBuffer = await fs.readFile(resolvedPath);
9527
9823
  const originalFilename = path.basename(resolvedPath);
9528
- const ext = path.extname(resolvedPath).toLowerCase();
9529
- const mimeMap = {
9530
- ".pdf": "application/pdf",
9531
- ".txt": "text/plain",
9532
- ".csv": "text/csv",
9533
- ".json": "application/json",
9534
- ".md": "text/markdown",
9535
- ".html": "text/html",
9536
- ".xml": "application/xml",
9537
- ".jpg": "image/jpeg",
9538
- ".jpeg": "image/jpeg",
9539
- ".png": "image/png",
9540
- ".gif": "image/gif",
9541
- };
9542
- const mimeType = opts.mimeType ?? mimeMap[ext] ?? "application/octet-stream";
9824
+ const fileBuffer = await fs.readFile(resolvedPath);
9825
+ const mimeType = opts.mimeType ?? inferMimeTypeForPath(resolvedPath);
9543
9826
  if (opts.local) {
9544
9827
  // Run the store+interpretation pipeline in-process without an API server.
9545
9828
  try {
@@ -9578,16 +9861,13 @@ program
9578
9861
  baseUrl: await resolveBaseUrl(program.opts().baseUrl, config),
9579
9862
  token,
9580
9863
  });
9581
- const base64 = fileBuffer.toString("base64");
9582
9864
  const body = {
9583
- file_content: base64,
9865
+ file_path: resolvedPath,
9584
9866
  mime_type: mimeType,
9585
9867
  original_filename: originalFilename,
9868
+ file_idempotency_key: opts.idempotencyKey,
9586
9869
  };
9587
- if (opts.idempotencyKey) {
9588
- body.idempotency_key = opts.idempotencyKey;
9589
- }
9590
- const { data, error } = await api.POST("/store/unstructured", { body });
9870
+ const { data, error } = await api.POST("/store", { body });
9591
9871
  if (error)
9592
9872
  throw new Error("Failed to upload file");
9593
9873
  writeOutput(data, outputMode);
@@ -9712,6 +9992,270 @@ correctionsCommand
9712
9992
  const correctionId = result?.observation?.id ?? result?.correction_id ?? idempotencyKey;
9713
9993
  writeOutput({ correction_id: correctionId, entity_id: entityId, success: result?.success ?? true }, outputMode);
9714
9994
  });
9995
+ // ---------------------------------------------------------------------------
9996
+ // neotoma edit <id>
9997
+ //
9998
+ // One-shot structured editor: opens $EDITOR on a YAML view of the entity
9999
+ // snapshot, diffs on save, and pushes an atomic batch correction through the
10000
+ // shared applyBatchCorrection service (same backend the Inspector Edit tab
10001
+ // uses). Conflicts surface as a resolvable prompt so the user can re-edit
10002
+ // against the rebased snapshot instead of overwriting blindly.
10003
+ // ---------------------------------------------------------------------------
10004
+ program
10005
+ .command("edit")
10006
+ .description("Edit an entity's fields in $EDITOR and save as a batch correction")
10007
+ .argument("<id>", "Entity ID to edit")
10008
+ .option("--user-id <userId>", "User ID")
10009
+ .option("--overwrite", "Overwrite conflicting changes without prompting")
10010
+ .option("--editor <cmd>", "Override editor command (defaults to $EDITOR)")
10011
+ .action(async (entityId, opts) => {
10012
+ const outputMode = resolveOutputMode();
10013
+ const config = await readConfig();
10014
+ const token = await getCliToken();
10015
+ const api = createApiClient({
10016
+ baseUrl: await resolveBaseUrl(program.opts().baseUrl, config),
10017
+ token,
10018
+ });
10019
+ const yaml = await import("js-yaml");
10020
+ async function fetchEntity() {
10021
+ const { data, error, response } = await api.GET("/entities/{id}", {
10022
+ params: { path: { id: entityId } },
10023
+ });
10024
+ if (error) {
10025
+ const status = response?.status;
10026
+ throw new Error(status
10027
+ ? `Failed to load entity: ${status} ${formatApiError(error)}`
10028
+ : `Failed to load entity: ${formatApiError(error)}`);
10029
+ }
10030
+ const body = (data ?? {});
10031
+ return {
10032
+ entity_type: body.entity_type ?? body.type ?? "unknown",
10033
+ snapshot: body.snapshot ??
10034
+ body.computed_snapshot ??
10035
+ {},
10036
+ last_observation_at: body.last_observation_at ?? null,
10037
+ };
10038
+ }
10039
+ function buildEditorBuffer(entity_type, last_observation_at, snapshot) {
10040
+ // Deterministic header plus alphabetized snapshot so repeated edits
10041
+ // produce stable diffs. Trailing '---' is kept so the user sees where
10042
+ // the editable region ends.
10043
+ const sortedSnapshot = Object.fromEntries(Object.keys(snapshot)
10044
+ .sort()
10045
+ .map((k) => [k, snapshot[k]]));
10046
+ const yamlBody = yaml.dump(sortedSnapshot, {
10047
+ noRefs: true,
10048
+ sortKeys: true,
10049
+ lineWidth: 120,
10050
+ });
10051
+ return [
10052
+ `# neotoma edit ${entityId}`,
10053
+ `# entity_type: ${entity_type}`,
10054
+ `# base_last_observation_at: ${last_observation_at ?? "null"}`,
10055
+ "# Edit field values below. Lines starting with '#' are comments.",
10056
+ "# Save and exit to apply changes; quit without saving to cancel.",
10057
+ "---",
10058
+ yamlBody.trimEnd(),
10059
+ "",
10060
+ ].join("\n");
10061
+ }
10062
+ function parseEditorBuffer(text) {
10063
+ // Strip comment-only lines before the first non-comment; yaml already
10064
+ // skips blank lines and leading `---`.
10065
+ const stripped = text
10066
+ .split("\n")
10067
+ .filter((l) => !l.startsWith("#"))
10068
+ .join("\n");
10069
+ const parsed = yaml.load(stripped);
10070
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
10071
+ return {};
10072
+ }
10073
+ return parsed;
10074
+ }
10075
+ async function runEditorOn(filePath) {
10076
+ const editor = opts.editor ?? process.env.EDITOR ?? process.env.VISUAL ?? "vi";
10077
+ // Let the editor inherit the TTY so the user gets an actual interactive
10078
+ // session. spawnSync would block this process.
10079
+ const [cmd, ...cmdArgs] = editor.split(" ");
10080
+ await new Promise((resolve, reject) => {
10081
+ const child = spawn(cmd, [...cmdArgs, filePath], { stdio: "inherit" });
10082
+ child.on("error", reject);
10083
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Editor exited with code ${code}`)));
10084
+ });
10085
+ }
10086
+ const first = await fetchEntity();
10087
+ let current = first;
10088
+ let buffer = buildEditorBuffer(current.entity_type, current.last_observation_at, current.snapshot);
10089
+ // A modest loop to recover from conflicts: re-fetch, re-present the
10090
+ // user's edits on top of the rebased snapshot, let them re-save.
10091
+ let attempts = 0;
10092
+ while (attempts < 5) {
10093
+ attempts += 1;
10094
+ const tmpPath = path.join(os.tmpdir(), `neotoma-edit-${entityId}-${Date.now()}-${attempts}.yaml`);
10095
+ await fs.writeFile(tmpPath, buffer, "utf-8");
10096
+ try {
10097
+ await runEditorOn(tmpPath);
10098
+ const newText = await fs.readFile(tmpPath, "utf-8");
10099
+ if (newText === buffer) {
10100
+ writeOutput({ success: true, status: "no_changes", entity_id: entityId }, outputMode);
10101
+ return;
10102
+ }
10103
+ let desired;
10104
+ try {
10105
+ desired = parseEditorBuffer(newText);
10106
+ }
10107
+ catch (err) {
10108
+ throw new Error(`Failed to parse YAML: ${err instanceof Error ? err.message : String(err)}. ` +
10109
+ `File left at ${tmpPath}.`);
10110
+ }
10111
+ const changes = [];
10112
+ const desiredKeys = new Set(Object.keys(desired));
10113
+ for (const field of Array.from(desiredKeys).sort()) {
10114
+ const next = desired[field];
10115
+ const prev = current.snapshot[field];
10116
+ if (JSON.stringify(next) !== JSON.stringify(prev)) {
10117
+ changes.push({ field, value: next });
10118
+ }
10119
+ }
10120
+ if (changes.length === 0) {
10121
+ writeOutput({ success: true, status: "no_changes", entity_id: entityId }, outputMode);
10122
+ return;
10123
+ }
10124
+ // /entities/:id/batch_correct is not yet in the OpenAPI spec, so we
10125
+ // call it via raw fetch. It returns a structured JSON body whose
10126
+ // `status` discriminates `applied` / `conflict` / `validation_error`.
10127
+ const baseUrl = (await resolveBaseUrl(program.opts().baseUrl, config)).replace(/\/$/, "");
10128
+ const headers = {
10129
+ "Content-Type": "application/json",
10130
+ };
10131
+ if (token)
10132
+ headers["Authorization"] = `Bearer ${token}`;
10133
+ const rawResponse = await fetch(`${baseUrl}/entities/${encodeURIComponent(entityId)}/batch_correct`, {
10134
+ method: "POST",
10135
+ headers,
10136
+ body: JSON.stringify({
10137
+ changes,
10138
+ expected_last_observation_at: current.last_observation_at,
10139
+ overwrite: opts.overwrite === true,
10140
+ idempotency_prefix: `cli-edit-${entityId}-${Date.now()}`,
10141
+ user_id: opts.userId,
10142
+ }),
10143
+ });
10144
+ const status = rawResponse.status;
10145
+ let resultBody = {};
10146
+ try {
10147
+ resultBody = (await rawResponse.json());
10148
+ }
10149
+ catch {
10150
+ /* empty body */
10151
+ }
10152
+ const resultStatus = resultBody.status ?? "";
10153
+ if (status === 409 || resultStatus === "conflict") {
10154
+ if (opts.overwrite) {
10155
+ // Shouldn't happen (server skips conflict check with overwrite)
10156
+ // but be defensive.
10157
+ throw new Error(`Server reported conflict despite --overwrite. Body: ${JSON.stringify(resultBody)}`);
10158
+ }
10159
+ process.stderr.write(warn(`Conflict: entity was modified since you started editing. Rebasing.\n`));
10160
+ // Rebase: refetch, rebuild the buffer with the latest snapshot but
10161
+ // preserve the user's edits where they still differ.
10162
+ const latest = await fetchEntity();
10163
+ const merged = { ...latest.snapshot };
10164
+ for (const { field, value } of changes) {
10165
+ merged[field] = value;
10166
+ }
10167
+ current = latest;
10168
+ buffer = buildEditorBuffer(latest.entity_type, latest.last_observation_at, merged);
10169
+ continue;
10170
+ }
10171
+ if (status === 400 || resultStatus === "validation_error") {
10172
+ const errs = (resultBody.validation_errors ?? []);
10173
+ throw new Error(`Validation failed:\n${errs
10174
+ .map((ve) => ` - ${ve.field}: ${ve.message}`)
10175
+ .join("\n") || JSON.stringify(resultBody)}`);
10176
+ }
10177
+ if (!rawResponse.ok) {
10178
+ throw new Error(`Batch correction failed: ${status} ${JSON.stringify(resultBody)}`);
10179
+ }
10180
+ const applied = Array.isArray(resultBody.applied)
10181
+ ? resultBody.applied.length
10182
+ : changes.length;
10183
+ writeOutput({
10184
+ success: true,
10185
+ status: "applied",
10186
+ entity_id: entityId,
10187
+ fields_changed: changes.map((c) => c.field),
10188
+ applied_count: applied,
10189
+ last_observation_at: resultBody.last_observation_at ?? null,
10190
+ }, outputMode);
10191
+ return;
10192
+ }
10193
+ finally {
10194
+ // Keep the file around on throw for debugging; clean up on success
10195
+ // paths by attempting unlink.
10196
+ try {
10197
+ await fs.unlink(tmpPath);
10198
+ }
10199
+ catch {
10200
+ /* ignore */
10201
+ }
10202
+ }
10203
+ }
10204
+ throw new Error("Exceeded maximum conflict-rebase attempts (5).");
10205
+ });
10206
+ // ---------------------------------------------------------------------------
10207
+ // neotoma memory-export
10208
+ //
10209
+ // Writes a bounded single-file MEMORY.md using the same canonical renderer
10210
+ // that backs MCP `retrieve_entity_snapshot`. Shape and defaults match Phase 5
10211
+ // of the markdown-memory-ceiling plan.
10212
+ // ---------------------------------------------------------------------------
10213
+ program
10214
+ .command("memory-export")
10215
+ .description("Export a bounded MEMORY.md for agent harnesses that expect a single file")
10216
+ .option("--path <path>", "Output path", "MEMORY.md")
10217
+ .option("--limit-lines <n>", "Hard line cap (0 disables truncation)", "200")
10218
+ .option("--include-types <list>", "Comma-separated entity types to include (default: all non-bookkeeping)")
10219
+ .option("--exclude-types <list>", "Comma-separated entity types to exclude (applied in addition to bookkeeping filter)")
10220
+ .option("--include-bookkeeping", "Include chat bookkeeping (conversation, agent_message). Excluded by default.")
10221
+ .option("--order <strategy>", "Ordering strategy: importance | recency", "importance")
10222
+ .option("--max-field-chars <n>", "Per-field character cap for long string values (0 disables)", "400")
10223
+ .option("--user-id <userId>", "Scope export to a specific user")
10224
+ .action(async (opts) => {
10225
+ const outputMode = resolveOutputMode();
10226
+ const order = (opts.order ?? "importance").toLowerCase();
10227
+ if (order !== "recency" && order !== "importance") {
10228
+ throw new Error(`Invalid --order: ${opts.order}. Allowed: importance, recency`);
10229
+ }
10230
+ const limitLines = Number.parseInt(String(opts.limitLines ?? "200"), 10);
10231
+ if (!Number.isFinite(limitLines)) {
10232
+ throw new Error(`Invalid --limit-lines: ${opts.limitLines}`);
10233
+ }
10234
+ const maxFieldChars = Number.parseInt(String(opts.maxFieldChars ?? "400"), 10);
10235
+ if (!Number.isFinite(maxFieldChars) || maxFieldChars < 0) {
10236
+ throw new Error(`Invalid --max-field-chars: ${opts.maxFieldChars}`);
10237
+ }
10238
+ const splitCsv = (raw) => raw
10239
+ ? raw
10240
+ .split(",")
10241
+ .map((s) => s.trim())
10242
+ .filter((s) => s.length > 0)
10243
+ : undefined;
10244
+ const includeTypes = splitCsv(opts.includeTypes);
10245
+ const excludeTypes = splitCsv(opts.excludeTypes);
10246
+ const { exportMemory } = await import("../services/memory_export.js");
10247
+ const result = await exportMemory({
10248
+ path: opts.path,
10249
+ limit_lines: limitLines,
10250
+ include_types: includeTypes,
10251
+ exclude_types: excludeTypes,
10252
+ include_bookkeeping: opts.includeBookkeeping === true,
10253
+ order: order,
10254
+ max_field_chars: maxFieldChars,
10255
+ user_id: opts.userId,
10256
+ });
10257
+ writeOutput(result, outputMode);
10258
+ });
9715
10259
  const statsCmd = program.command("stats").description("Dashboard and entity statistics");
9716
10260
  statsCmd.action(async () => {
9717
10261
  const outputMode = resolveOutputMode();
@@ -12045,12 +12589,14 @@ export async function runCli(argv = process.argv) {
12045
12589
  "api",
12046
12590
  "backup",
12047
12591
  "dev",
12592
+ "doctor",
12048
12593
  "entities",
12049
12594
  "logs",
12050
12595
  "mcp",
12051
12596
  "observations",
12052
12597
  "relationships",
12053
12598
  "schemas",
12599
+ "setup",
12054
12600
  "sources",
12055
12601
  "storage",
12056
12602
  "timeline",