unforgit 0.5.6 → 0.7.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.
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  getDbPath,
18
18
  getTemplate,
19
19
  isInitialized,
20
+ isOpenAIConfigured,
20
21
  loadConfig,
21
22
  mergeAndRank,
22
23
  parseConfidence,
@@ -24,12 +25,13 @@ import {
24
25
  parseThreshold,
25
26
  parseTtl,
26
27
  persistReviewableSuggestions,
28
+ resolveEmbeddingProvider,
27
29
  resolveLifecycleConfig,
28
30
  resolveVisibility,
29
31
  runLocalLifecycleMaintenance,
30
32
  saveConfig,
31
33
  validateMemoryType
32
- } from "./chunk-CKUDYQYP.js";
34
+ } from "./chunk-TGDR7Y7T.js";
33
35
 
34
36
  // src/index.ts
35
37
  import { Command as Command31 } from "commander";
@@ -808,11 +810,130 @@ Examples:
808
810
  });
809
811
 
810
812
  // src/commands/delete.ts
813
+ import { Command as Command9 } from "commander";
814
+
815
+ // src/commands/backups.ts
816
+ import fs3 from "fs";
817
+ import path2 from "path";
811
818
  import { Command as Command8 } from "commander";
812
- var deleteCommand = new Command8("delete").description("Soft delete a memory (can be restored)").argument("<id>", "Memory ID to delete").option("--hard", "Permanently delete (cannot be restored)").option("--remote", "Delete on remote").option("--by <author>", "Author of the deletion").option("--force", "Skip confirmation for hard delete").addHelpText("after", `
819
+ function formatBackupTimestamp(date) {
820
+ return date.toISOString().replace(/[-:]/g, "").replace("T", "-").replace(/\.\d{3}Z$/, "");
821
+ }
822
+ function backupRootForDb(dbPath) {
823
+ return path2.join(path2.dirname(dbPath), "backups");
824
+ }
825
+ function describeBackup(dir) {
826
+ const files = fs3.readdirSync(dir).filter((file) => file === "local.db" || file === "local.db-wal" || file === "local.db-shm").sort();
827
+ const sizeBytes = files.reduce((total, file) => total + fs3.statSync(path2.join(dir, file)).size, 0);
828
+ const stat = fs3.statSync(dir);
829
+ return {
830
+ name: path2.basename(dir),
831
+ dir,
832
+ files,
833
+ sizeBytes,
834
+ createdAt: stat.mtime.toISOString()
835
+ };
836
+ }
837
+ function resolveBackupDir(backupRoot, backupName) {
838
+ if (backupName !== path2.basename(backupName)) {
839
+ throw new Error("Invalid backup name");
840
+ }
841
+ const resolvedRoot = path2.resolve(backupRoot);
842
+ const resolvedBackup = path2.resolve(resolvedRoot, backupName);
843
+ if (!resolvedBackup.startsWith(`${resolvedRoot}${path2.sep}`)) {
844
+ throw new Error("Invalid backup name");
845
+ }
846
+ return resolvedBackup;
847
+ }
848
+ function createLocalDatabaseBackup(dbPath, prefix, now = /* @__PURE__ */ new Date()) {
849
+ if (!fs3.existsSync(dbPath)) {
850
+ return null;
851
+ }
852
+ const backupRoot = backupRootForDb(dbPath);
853
+ const baseName = `${prefix}-${formatBackupTimestamp(now)}`;
854
+ let backupDir = path2.join(backupRoot, baseName);
855
+ let suffix = 1;
856
+ while (fs3.existsSync(backupDir)) {
857
+ backupDir = path2.join(backupRoot, `${baseName}-${suffix++}`);
858
+ }
859
+ fs3.mkdirSync(backupDir, { recursive: true, mode: 448 });
860
+ for (const source of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
861
+ if (!fs3.existsSync(source)) continue;
862
+ fs3.copyFileSync(source, path2.join(backupDir, path2.basename(source)));
863
+ }
864
+ return describeBackup(backupDir);
865
+ }
866
+ function createLocalResetBackup(dbPath, now = /* @__PURE__ */ new Date()) {
867
+ return createLocalDatabaseBackup(dbPath, "reset", now);
868
+ }
869
+ function listLocalResetBackups(dbPath) {
870
+ const backupRoot = backupRootForDb(dbPath);
871
+ if (!fs3.existsSync(backupRoot)) {
872
+ return [];
873
+ }
874
+ return fs3.readdirSync(backupRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.startsWith("reset-")).map((entry) => describeBackup(path2.join(backupRoot, entry.name))).filter((backup) => backup.files.includes("local.db")).sort((a, b) => b.name.localeCompare(a.name));
875
+ }
876
+ function restoreLocalResetBackup(dbPath, backupName, now = /* @__PURE__ */ new Date()) {
877
+ const backupRoot = backupRootForDb(dbPath);
878
+ const backupDir = resolveBackupDir(backupRoot, backupName);
879
+ if (!fs3.existsSync(backupDir) || !fs3.statSync(backupDir).isDirectory()) {
880
+ throw new Error(`Backup not found: ${backupName}`);
881
+ }
882
+ const restoredFrom = describeBackup(backupDir);
883
+ if (!restoredFrom.files.includes("local.db")) {
884
+ throw new Error(`Backup is missing local.db: ${backupName}`);
885
+ }
886
+ const safetyBackup = createLocalResetBackup(dbPath, now);
887
+ fs3.mkdirSync(path2.dirname(dbPath), { recursive: true });
888
+ for (const target of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
889
+ if (fs3.existsSync(target)) {
890
+ fs3.rmSync(target, { force: true });
891
+ }
892
+ }
893
+ for (const file of restoredFrom.files) {
894
+ fs3.copyFileSync(path2.join(backupDir, file), path2.join(path2.dirname(dbPath), file));
895
+ }
896
+ return { restoredFrom, safetyBackup };
897
+ }
898
+ var backupsCommand = new Command8("backups").description("List and restore local reset backups");
899
+ backupsCommand.command("list").description("List local backups created before destructive resets/restores").action(() => {
900
+ const backups = listLocalResetBackups(getDbPath());
901
+ if (backups.length === 0) {
902
+ logger.info("No local reset backups found.");
903
+ return;
904
+ }
905
+ for (const backup of backups) {
906
+ logger.info(`${backup.name} ${backup.sizeBytes} bytes ${backup.dir}`);
907
+ }
908
+ });
909
+ backupsCommand.command("restore").argument("<name>", "Backup directory name, for example reset-20260610-123456").description("Restore a local reset backup into the active local database").option("--force", "Skip confirmation prompt").action(async (name, opts) => {
910
+ if (!opts.force) {
911
+ const ok = await confirm(
912
+ "This will replace the active local database after creating a safety backup. Continue?"
913
+ );
914
+ if (!ok) {
915
+ logger.info("Aborted.");
916
+ return;
917
+ }
918
+ }
919
+ try {
920
+ const result = restoreLocalResetBackup(getDbPath(), name);
921
+ logger.info(`Restored local database from ${result.restoredFrom.name}`);
922
+ if (result.safetyBackup) {
923
+ logger.info(`Previous local database safety backup: ${result.safetyBackup.dir}`);
924
+ }
925
+ } catch (err) {
926
+ logger.error(err instanceof Error ? err.message : String(err));
927
+ process.exit(EXIT_ERROR);
928
+ }
929
+ });
930
+
931
+ // src/commands/delete.ts
932
+ var deleteCommand = new Command9("delete").description("Soft delete a memory (can be restored)").argument("<id>", "Memory ID to delete").option("--hard", "Permanently delete (cannot be restored)").option("--remote", "Delete on remote").option("--by <author>", "Author of the deletion").option("--force", "Skip confirmation for hard delete").option("--no-backup", "Skip automatic local database backup before local hard delete").addHelpText("after", `
813
933
  Examples:
814
934
  unforgit delete abc12345 Soft delete (can be restored)
815
- unforgit delete abc12345 --hard Permanent delete
935
+ unforgit delete abc12345 --hard Permanent delete, with local backup by default
936
+ unforgit delete abc12345 --hard --no-backup
816
937
  unforgit delete abc12345 --remote Delete on remote server`).action(async (id, opts) => {
817
938
  if (opts.hard && !opts.force) {
818
939
  const confirmed = await confirm(
@@ -838,7 +959,21 @@ Examples:
838
959
  }
839
960
  return;
840
961
  }
841
- const store = new LocalStore(getDbPath());
962
+ const dbPath = getDbPath();
963
+ if (opts.hard && opts.backup !== false) {
964
+ try {
965
+ const backup = createLocalDatabaseBackup(dbPath, "hard-delete");
966
+ if (backup) {
967
+ logger.info(`Created local hard-delete backup: ${backup.dir}`);
968
+ }
969
+ } catch (err) {
970
+ logger.error(
971
+ `Failed to create local hard-delete backup: ${err instanceof Error ? err.message : String(err)}`
972
+ );
973
+ process.exit(EXIT_ERROR);
974
+ }
975
+ }
976
+ const store = new LocalStore(dbPath);
842
977
  try {
843
978
  let ok;
844
979
  if (opts.hard) {
@@ -859,7 +994,7 @@ Examples:
859
994
  store.close();
860
995
  }
861
996
  });
862
- var restoreCommand = new Command8("restore").description("Restore a soft-deleted memory").argument("<id>", "Memory ID to restore").option("--remote", "Restore on remote").action(async (id, opts) => {
997
+ var restoreCommand = new Command9("restore").description("Restore a soft-deleted memory").argument("<id>", "Memory ID to restore").option("--remote", "Restore on remote").action(async (id, opts) => {
863
998
  if (opts.remote) {
864
999
  const config = loadConfig();
865
1000
  const client = new RemoteClient(config.remote.url);
@@ -888,11 +1023,11 @@ var restoreCommand = new Command8("restore").description("Restore a soft-deleted
888
1023
  });
889
1024
 
890
1025
  // src/commands/web.ts
891
- import { Command as Command9 } from "commander";
1026
+ import { Command as Command10 } from "commander";
892
1027
  import { spawn } from "child_process";
893
- import path2 from "path";
894
- import fs3 from "fs";
895
- var webCommand = new Command9("web").description("Start the Unforgit web dashboard").option("-p, --port <port>", "Port to run on", "3838").option("--no-open", "Don't open browser automatically").action(async (opts) => {
1028
+ import path3 from "path";
1029
+ import fs4 from "fs";
1030
+ var webCommand = new Command10("web").description("Start the Unforgit web dashboard").option("-p, --port <port>", "Port to run on", "3838").option("--no-open", "Don't open browser automatically").action(async (opts) => {
896
1031
  const cwd4 = process.cwd();
897
1032
  if (!isInitialized(cwd4)) {
898
1033
  logger.error("Unforgit not initialized in this directory. Run 'unforgit init' first.");
@@ -908,9 +1043,9 @@ var webCommand = new Command9("web").description("Start the Unforgit web dashboa
908
1043
  UNFORGIT_WORKSPACE: cwd4,
909
1044
  PORT: opts.port
910
1045
  };
911
- const dotenvPath = path2.join(cwd4, ".env");
912
- if (fs3.existsSync(dotenvPath)) {
913
- const content = fs3.readFileSync(dotenvPath, "utf-8");
1046
+ const dotenvPath = path3.join(cwd4, ".env");
1047
+ if (fs4.existsSync(dotenvPath)) {
1048
+ const content = fs4.readFileSync(dotenvPath, "utf-8");
914
1049
  for (const line of content.split("\n")) {
915
1050
  const match = line.match(/^\s*([^#=]+?)\s*=\s*(.+?)\s*$/);
916
1051
  if (match) {
@@ -920,11 +1055,11 @@ var webCommand = new Command9("web").description("Start the Unforgit web dashboa
920
1055
  }
921
1056
  logger.info(`Starting Unforgit web dashboard on port ${opts.port}...`);
922
1057
  logger.info(`Workspace: ${cwd4}`);
923
- const hasNextBuild = fs3.existsSync(path2.join(webDir, ".next"));
1058
+ const hasNextBuild = fs4.existsSync(path3.join(webDir, ".next"));
924
1059
  const cmd = hasNextBuild ? "next" : "next";
925
1060
  const args = hasNextBuild ? ["start", "-p", opts.port] : ["dev", "-p", opts.port];
926
- const nextBin = path2.join(webDir, "node_modules", ".bin", "next");
927
- const finalCmd = fs3.existsSync(nextBin) ? nextBin : cmd;
1061
+ const nextBin = path3.join(webDir, "node_modules", ".bin", "next");
1062
+ const finalCmd = fs4.existsSync(nextBin) ? nextBin : cmd;
928
1063
  const child = spawn(finalCmd, args, {
929
1064
  cwd: webDir,
930
1065
  env,
@@ -947,12 +1082,12 @@ var webCommand = new Command9("web").description("Start the Unforgit web dashboa
947
1082
  });
948
1083
  function findWebDir() {
949
1084
  const candidates = [
950
- path2.resolve(import.meta.dirname, "../../../web"),
951
- path2.resolve(import.meta.dirname, "../../web"),
952
- path2.join(process.cwd(), "web")
1085
+ path3.resolve(import.meta.dirname, "../../../web"),
1086
+ path3.resolve(import.meta.dirname, "../../web"),
1087
+ path3.join(process.cwd(), "web")
953
1088
  ];
954
1089
  for (const dir of candidates) {
955
- if (fs3.existsSync(dir) && fs3.existsSync(path2.join(dir, "package.json"))) {
1090
+ if (fs4.existsSync(dir) && fs4.existsSync(path3.join(dir, "package.json"))) {
956
1091
  return dir;
957
1092
  }
958
1093
  }
@@ -960,14 +1095,14 @@ function findWebDir() {
960
1095
  }
961
1096
 
962
1097
  // src/commands/link.ts
963
- import { Command as Command10 } from "commander";
1098
+ import { Command as Command11 } from "commander";
964
1099
  var VALID_LINK_TYPES = [
965
1100
  "related_to",
966
1101
  "derived_from",
967
1102
  "contradicts",
968
1103
  "depends_on"
969
1104
  ];
970
- var linkCommand = new Command10("link").description("Create a link between two memories").argument("<source-id>", "Source memory ID").argument("<target-id>", "Target memory ID").requiredOption(
1105
+ var linkCommand = new Command11("link").description("Create a link between two memories").argument("<source-id>", "Source memory ID").argument("<target-id>", "Target memory ID").requiredOption(
971
1106
  "--type <link-type>",
972
1107
  "Link type (related_to, derived_from, contradicts, depends_on)"
973
1108
  ).option("--remote", "Create link on remote").addHelpText("after", `
@@ -1037,7 +1172,7 @@ Examples:
1037
1172
  store.close();
1038
1173
  }
1039
1174
  });
1040
- var unlinkCommand = new Command10("unlink").description("Remove a link between two memories").argument("<source-id>", "Source memory ID").argument("<target-id>", "Target memory ID").requiredOption(
1175
+ var unlinkCommand = new Command11("unlink").description("Remove a link between two memories").argument("<source-id>", "Source memory ID").argument("<target-id>", "Target memory ID").requiredOption(
1041
1176
  "--type <link-type>",
1042
1177
  "Link type (related_to, derived_from, contradicts, depends_on)"
1043
1178
  ).option("--remote", "Remove link on remote").action(async (sourceId, targetId, opts) => {
@@ -1077,7 +1212,7 @@ var unlinkCommand = new Command10("unlink").description("Remove a link between t
1077
1212
  store.close();
1078
1213
  }
1079
1214
  });
1080
- var linksCommand = new Command10("links").description("List all links for a memory").argument("<memory-id>", "Memory ID to get links for").option(
1215
+ var linksCommand = new Command11("links").description("List all links for a memory").argument("<memory-id>", "Memory ID to get links for").option(
1081
1216
  "--type <link-type>",
1082
1217
  "Filter by link type (related_to, derived_from, contradicts, depends_on)"
1083
1218
  ).option("--remote", "List links on remote").action(async (memoryId, opts) => {
@@ -1147,9 +1282,9 @@ var linksCommand = new Command10("links").description("List all links for a memo
1147
1282
  });
1148
1283
 
1149
1284
  // src/commands/merge.ts
1150
- import { Command as Command11 } from "commander";
1285
+ import { Command as Command12 } from "commander";
1151
1286
  var cwd = process.cwd();
1152
- var mergeCommand = new Command11("merge").description(
1287
+ var mergeCommand = new Command12("merge").description(
1153
1288
  "Consolidate multiple local memories into one unified memory while preserving history"
1154
1289
  ).argument("<ids...>", "Memory IDs to consolidate (minimum 2)").requiredOption(
1155
1290
  "-t, --text <text>",
@@ -1221,7 +1356,7 @@ var mergeCommand = new Command11("merge").description(
1221
1356
  store.close();
1222
1357
  }
1223
1358
  });
1224
- var remergeCommand = new Command11("remerge").description(
1359
+ var remergeCommand = new Command12("remerge").description(
1225
1360
  "Update an existing consolidation with new information or additional sources"
1226
1361
  ).argument("<consolidation-id>", "ID of existing consolidated memory to update").requiredOption("-t, --text <text>", "Updated consolidated text").option(
1227
1362
  "--add <ids>",
@@ -1260,7 +1395,7 @@ var remergeCommand = new Command11("remerge").description(
1260
1395
  store.close();
1261
1396
  }
1262
1397
  });
1263
- var similarCommand = new Command11("similar").description("Find memories similar to a given memory (candidates for merging)").argument("<memory-id>", "Memory ID to find similar ones for").option(
1398
+ var similarCommand = new Command12("similar").description("Find memories similar to a given memory (candidates for merging)").argument("<memory-id>", "Memory ID to find similar ones for").option(
1264
1399
  "-k, --limit <n>",
1265
1400
  "Max number of similar memories to return",
1266
1401
  "10"
@@ -1324,7 +1459,7 @@ Examples:
1324
1459
  store.close();
1325
1460
  }
1326
1461
  });
1327
- var historyCommand = new Command11("history").description("Show consolidation history for a memory").argument("<memory-id>", "Memory ID to show history for").action(async (memoryId) => {
1462
+ var historyCommand = new Command12("history").description("Show consolidation history for a memory").argument("<memory-id>", "Memory ID to show history for").action(async (memoryId) => {
1328
1463
  if (!isInitialized(cwd)) {
1329
1464
  logger.error("Unforgit not initialized. Run 'unforgit init' first.");
1330
1465
  process.exit(EXIT_CONFIG_ERROR);
@@ -1389,7 +1524,7 @@ var historyCommand = new Command11("history").description("Show consolidation hi
1389
1524
  });
1390
1525
 
1391
1526
  // src/commands/auto-consolidate.ts
1392
- import { Command as Command12 } from "commander";
1527
+ import { Command as Command13 } from "commander";
1393
1528
  import * as readline2 from "readline";
1394
1529
  var cwd2 = process.cwd();
1395
1530
  function createReadlineInterface() {
@@ -1405,7 +1540,7 @@ async function askConfirmation(rl, question) {
1405
1540
  });
1406
1541
  });
1407
1542
  }
1408
- var autoConsolidateCommand = new Command12("auto-consolidate").description(
1543
+ var autoConsolidateCommand = new Command13("auto-consolidate").description(
1409
1544
  "Automatically find and consolidate similar memories using AI"
1410
1545
  ).option(
1411
1546
  "--threshold <score>",
@@ -1570,8 +1705,8 @@ Original memories are preserved with status 'superseded'.`
1570
1705
  });
1571
1706
 
1572
1707
  // src/commands/unconsolidate.ts
1573
- import { Command as Command13 } from "commander";
1574
- var unconsolidateCommand = new Command13("unconsolidate").description("Revert a consolidation, restoring original memories to active status").argument("<consolidation-id>", "ID of the consolidated memory to revert").option("--dry-run", "Show what would be restored without making changes").option("--force", "Skip confirmation").addHelpText("after", `
1708
+ import { Command as Command14 } from "commander";
1709
+ var unconsolidateCommand = new Command14("unconsolidate").description("Revert a consolidation, restoring original memories to active status").argument("<consolidation-id>", "ID of the consolidated memory to revert").option("--dry-run", "Show what would be restored without making changes").option("--force", "Skip confirmation").addHelpText("after", `
1575
1710
  Examples:
1576
1711
  unforgit unconsolidate abc123 --dry-run Preview what would be restored
1577
1712
  unforgit unconsolidate abc123 Revert a consolidation`).action(async (consolidationId, opts) => {
@@ -1645,8 +1780,8 @@ Examples:
1645
1780
  });
1646
1781
 
1647
1782
  // src/commands/status.ts
1648
- import { Command as Command14 } from "commander";
1649
- var statusCommand = new Command14("status").description("Show the working tree status (pending sync state)").option("-s, --short", "Give the output in short format").addHelpText("after", `
1783
+ import { Command as Command15 } from "commander";
1784
+ var statusCommand = new Command15("status").description("Show the working tree status (pending sync state)").option("-s, --short", "Give the output in short format").addHelpText("after", `
1650
1785
  Examples:
1651
1786
  unforgit status Show full sync status
1652
1787
  unforgit status -s Short format
@@ -1664,32 +1799,50 @@ Examples:
1664
1799
  const remoteUrl = config.remote.url;
1665
1800
  const remoteName = "origin";
1666
1801
  const pendingPush = store.getPendingPush();
1802
+ const pendingPull = store.getSyncStatesByStatus("pending_pull").map((syncState) => {
1803
+ const memory = store.getById(syncState.memoryId);
1804
+ return memory ? { memory, syncState } : void 0;
1805
+ }).filter((item) => Boolean(item));
1667
1806
  const conflicts = store.getConflicts();
1668
1807
  const untracked = store.getUntrackedMemories(orgId, repoId);
1669
1808
  const summary = store.getSyncSummary(orgId, repoId);
1670
1809
  if (isJsonMode()) {
1810
+ const clean = pendingPush.length === 0 && pendingPull.length === 0 && conflicts.length === 0 && untracked.length === 0;
1671
1811
  outputJson({
1672
1812
  remote: remoteUrl || null,
1813
+ remoteConfigured: Boolean(remoteUrl),
1814
+ synced: summary.synced,
1673
1815
  pendingPush: pendingPush.length,
1816
+ pendingPull: pendingPull.length,
1674
1817
  conflicts: conflicts.length,
1675
1818
  untracked: untracked.length,
1676
- synced: summary.synced
1819
+ clean,
1820
+ recommendations: buildRecommendations(Boolean(remoteUrl), pendingPush.length, pendingPull.length, conflicts.length, untracked.length),
1821
+ details: {
1822
+ pendingPush: pendingPush.map(toDetailsItem),
1823
+ pendingPull: pendingPull.map(toDetailsItem),
1824
+ conflicts: conflicts.map(toDetailsItem),
1825
+ untracked: untracked.map((memory) => ({ id: memory.id, preview: truncate(memory.text, 80), status: memory.status }))
1826
+ }
1677
1827
  });
1678
1828
  return;
1679
1829
  }
1680
1830
  if (opts.short) {
1681
- printShortStatus(pendingPush, conflicts, untracked);
1831
+ printShortStatus(pendingPush, pendingPull, conflicts, untracked);
1682
1832
  } else {
1683
- printLongStatus(remoteName, remoteUrl, pendingPush, conflicts, untracked, summary);
1833
+ printLongStatus(remoteName, remoteUrl, pendingPush, pendingPull, conflicts, untracked, summary);
1684
1834
  }
1685
1835
  } finally {
1686
1836
  store.close();
1687
1837
  }
1688
1838
  });
1689
- function printShortStatus(pendingPush, conflicts, untracked) {
1839
+ function printShortStatus(pendingPush, pendingPull, conflicts, untracked) {
1690
1840
  for (const { memory } of pendingPush) {
1691
1841
  logger.info(`M ${memory.id.slice(0, 8)} ${truncate(memory.text, 50)}`);
1692
1842
  }
1843
+ for (const { memory } of pendingPull) {
1844
+ logger.info(`P ${memory.id.slice(0, 8)} ${truncate(memory.text, 50)}`);
1845
+ }
1693
1846
  for (const { memory } of conflicts) {
1694
1847
  logger.info(`C ${memory.id.slice(0, 8)} ${truncate(memory.text, 50)}`);
1695
1848
  }
@@ -1697,15 +1850,15 @@ function printShortStatus(pendingPush, conflicts, untracked) {
1697
1850
  logger.info(`?? ${memory.id.slice(0, 8)} ${truncate(memory.text, 50)}`);
1698
1851
  }
1699
1852
  }
1700
- function printLongStatus(remoteName, remoteUrl, pendingPush, conflicts, untracked, summary) {
1853
+ function printLongStatus(remoteName, remoteUrl, pendingPush, pendingPull, conflicts, untracked, summary) {
1701
1854
  if (remoteUrl) {
1702
1855
  logger.info(`Remote '${remoteName}' at ${remoteUrl}`);
1703
1856
  } else {
1704
1857
  logger.info("No remote configured. Use 'unforgit remote add origin <url>' to add one.");
1705
1858
  }
1706
1859
  logger.info("");
1707
- if (pendingPush.length === 0 && conflicts.length === 0 && untracked.length === 0) {
1708
- logger.info("Nothing to push, working tree clean");
1860
+ if (pendingPush.length === 0 && pendingPull.length === 0 && conflicts.length === 0 && untracked.length === 0) {
1861
+ logger.info("Nothing to push or pull, working tree clean");
1709
1862
  if (summary.synced > 0) {
1710
1863
  logger.info(` ${summary.synced} memories synced with remote`);
1711
1864
  }
@@ -1721,6 +1874,15 @@ function printLongStatus(remoteName, remoteUrl, pendingPush, conflicts, untracke
1721
1874
  }
1722
1875
  logger.info("");
1723
1876
  }
1877
+ if (pendingPull.length > 0) {
1878
+ logger.info("Changes to be pulled:");
1879
+ logger.info(' (use "unforgit pull" to sync from remote)');
1880
+ logger.info("");
1881
+ for (const { memory } of pendingPull) {
1882
+ logger.info(` remote update: ${memory.id.slice(0, 8)}... "${truncate(memory.text, 40)}"`);
1883
+ }
1884
+ logger.info("");
1885
+ }
1724
1886
  if (conflicts.length > 0) {
1725
1887
  logger.info("Conflicts:");
1726
1888
  logger.info(' (use "unforgit push --force" to overwrite remote or "unforgit pull --force" to accept remote)');
@@ -1740,13 +1902,43 @@ function printLongStatus(remoteName, remoteUrl, pendingPush, conflicts, untracke
1740
1902
  }
1741
1903
  logger.info("");
1742
1904
  }
1743
- const total = pendingPush.length + conflicts.length + untracked.length;
1905
+ const total = pendingPush.length + pendingPull.length + conflicts.length + untracked.length;
1744
1906
  logger.info(`${total} change(s) pending`);
1745
1907
  }
1908
+ function buildRecommendations(remoteConfigured, pendingPush, pendingPull, conflicts, untracked) {
1909
+ const recommendations = [];
1910
+ if (!remoteConfigured && (pendingPush > 0 || pendingPull > 0 || untracked > 0)) {
1911
+ recommendations.push("Configure a remote with 'unforgit remote add origin <url>' or keep this repository intentionally local-only.");
1912
+ return recommendations;
1913
+ }
1914
+ if (conflicts > 0) {
1915
+ recommendations.push("Review conflicts, then run 'unforgit pull --force' to accept remote or 'unforgit push --force' to keep local.");
1916
+ }
1917
+ if (pendingPush > 0 || untracked > 0) {
1918
+ recommendations.push("Run 'unforgit push' to publish local memory changes.");
1919
+ }
1920
+ if (pendingPull > 0) {
1921
+ recommendations.push("Run 'unforgit pull' to fetch remote memory changes.");
1922
+ }
1923
+ return recommendations;
1924
+ }
1925
+ function toDetailsItem(item) {
1926
+ const { memory, syncState } = item;
1927
+ return {
1928
+ id: memory.id,
1929
+ preview: truncate(memory.text, 80),
1930
+ status: memory.status,
1931
+ syncStatus: syncState.syncStatus,
1932
+ localVersion: syncState.localVersion,
1933
+ remoteVersion: syncState.remoteVersion,
1934
+ lastPushedAt: syncState.lastPushedAt?.toISOString(),
1935
+ lastPulledAt: syncState.lastPulledAt?.toISOString()
1936
+ };
1937
+ }
1746
1938
 
1747
1939
  // src/commands/push.ts
1748
- import { Command as Command15 } from "commander";
1749
- var pushCommand = new Command15("push").description("Push local memories to remote").argument("[remote]", "Remote name to push to", "origin").option("-f, --force", "Force push, overwriting remote conflicts").option("--dry-run", "Show what would be pushed without actually pushing").option("-a, --all", "Push all memories including untracked ones").action(async (remote, opts) => {
1940
+ import { Command as Command16 } from "commander";
1941
+ var pushCommand = new Command16("push").description("Push local memories to remote").argument("[remote]", "Remote name to push to", "origin").option("-f, --force", "Force push, overwriting remote conflicts").option("--dry-run", "Show what would be pushed without actually pushing").option("-a, --all", "Push all memories including untracked ones").action(async (remote, opts) => {
1750
1942
  if (!isInitialized()) {
1751
1943
  logger.fatal("not an unforgit repository");
1752
1944
  process.exit(EXIT_CONFIG_ERROR);
@@ -1890,8 +2082,8 @@ Total: ${allToPush.length} memories, ${supersededToSync.length} status updates,
1890
2082
  });
1891
2083
 
1892
2084
  // src/commands/pull.ts
1893
- import { Command as Command16 } from "commander";
1894
- var pullCommand = new Command16("pull").description("Pull remote memories to local").argument("[remote]", "Remote name to pull from", "origin").option("-f, --force", "Force pull, overwriting local conflicts").option("--dry-run", "Show what would be pulled without actually pulling").action(async (remote, opts) => {
2085
+ import { Command as Command17 } from "commander";
2086
+ var pullCommand = new Command17("pull").description("Pull remote memories to local").argument("[remote]", "Remote name to pull from", "origin").option("-f, --force", "Force pull, overwriting local conflicts").option("--dry-run", "Show what would be pulled without actually pulling").action(async (remote, opts) => {
1895
2087
  if (!isInitialized()) {
1896
2088
  logger.fatal("not an unforgit repository");
1897
2089
  process.exit(EXIT_CONFIG_ERROR);
@@ -2036,8 +2228,8 @@ Would pull:`);
2036
2228
  });
2037
2229
 
2038
2230
  // src/commands/remote.ts
2039
- import { Command as Command17 } from "commander";
2040
- var remoteCommand = new Command17("remote").description("Manage set of tracked remote repositories").addHelpText("after", `
2231
+ import { Command as Command18 } from "commander";
2232
+ var remoteCommand = new Command18("remote").description("Manage set of tracked remote repositories").addHelpText("after", `
2041
2233
  Examples:
2042
2234
  unforgit remote List remotes
2043
2235
  unforgit remote add origin <url> Add a remote
@@ -2057,7 +2249,7 @@ Examples:
2057
2249
  logger.info(`${name} ${remote.url}`);
2058
2250
  }
2059
2251
  });
2060
- var remoteAddCommand = new Command17("add").description("Add a new remote").argument("<name>", "Name for the remote (e.g., origin)").argument("<url>", "URL of the remote unforgit server").option("--org <orgId>", "Organization ID").option("--repo <repoId>", "Repository ID").action((name, url, opts) => {
2252
+ var remoteAddCommand = new Command18("add").description("Add a new remote").argument("<name>", "Name for the remote (e.g., origin)").argument("<url>", "URL of the remote unforgit server").option("--org <orgId>", "Organization ID").option("--repo <repoId>", "Repository ID").action((name, url, opts) => {
2061
2253
  if (!isInitialized()) {
2062
2254
  logger.fatal("not an unforgit repository");
2063
2255
  process.exit(EXIT_CONFIG_ERROR);
@@ -2077,7 +2269,7 @@ var remoteAddCommand = new Command17("add").description("Add a new remote").argu
2077
2269
  saveRemotes(config, remotes);
2078
2270
  logger.info(`Remote '${name}' added: ${url}`);
2079
2271
  });
2080
- var remoteRemoveCommand = new Command17("remove").alias("rm").description("Remove a remote").argument("<name>", "Name of the remote to remove").action((name) => {
2272
+ var remoteRemoveCommand = new Command18("remove").alias("rm").description("Remove a remote").argument("<name>", "Name of the remote to remove").action((name) => {
2081
2273
  if (!isInitialized()) {
2082
2274
  logger.fatal("not an unforgit repository");
2083
2275
  process.exit(EXIT_CONFIG_ERROR);
@@ -2092,7 +2284,7 @@ var remoteRemoveCommand = new Command17("remove").alias("rm").description("Remov
2092
2284
  saveRemotes(config, remotes);
2093
2285
  logger.info(`Remote '${name}' removed.`);
2094
2286
  });
2095
- var remoteSetUrlCommand = new Command17("set-url").description("Change the URL for a remote").argument("<name>", "Name of the remote").argument("<newurl>", "New URL for the remote").action((name, newurl) => {
2287
+ var remoteSetUrlCommand = new Command18("set-url").description("Change the URL for a remote").argument("<name>", "Name of the remote").argument("<newurl>", "New URL for the remote").action((name, newurl) => {
2096
2288
  if (!isInitialized()) {
2097
2289
  logger.fatal("not an unforgit repository");
2098
2290
  process.exit(EXIT_CONFIG_ERROR);
@@ -2108,7 +2300,7 @@ var remoteSetUrlCommand = new Command17("set-url").description("Change the URL f
2108
2300
  saveRemotes(config, remotes);
2109
2301
  logger.info(`Remote '${name}' URL changed to: ${newurl}`);
2110
2302
  });
2111
- var remoteShowCommand = new Command17("show").description("Show information about a remote").argument("<name>", "Name of the remote").action((name) => {
2303
+ var remoteShowCommand = new Command18("show").description("Show information about a remote").argument("<name>", "Name of the remote").action((name) => {
2112
2304
  if (!isInitialized()) {
2113
2305
  logger.fatal("not an unforgit repository");
2114
2306
  process.exit(EXIT_CONFIG_ERROR);
@@ -2158,8 +2350,8 @@ function saveRemotes(config, remotes) {
2158
2350
  }
2159
2351
 
2160
2352
  // src/commands/log.ts
2161
- import { Command as Command18 } from "commander";
2162
- var logCommand = new Command18("log").description("Show memory history log").option("-n, --max-count <n>", "Limit the number of memories shown", "20").option("--oneline", "Show each memory on a single line").option("--all", "Show all memories including deprecated/superseded").option("--type <type>", "Filter by memory type (episodic|semantic|procedural)").option("--tags <tags>", "Filter by tags (comma-separated)").option("--page <n>", "Page number for pagination", "1").option("--per-page <n>", "Items per page", "20").addHelpText("after", `
2353
+ import { Command as Command19 } from "commander";
2354
+ var logCommand = new Command19("log").description("Show memory history log").option("-n, --max-count <n>", "Limit the number of memories shown", "20").option("--oneline", "Show each memory on a single line").option("--all", "Show all memories including deprecated/superseded").option("--type <type>", "Filter by memory type (episodic|semantic|procedural)").option("--tags <tags>", "Filter by tags (comma-separated)").option("--page <n>", "Page number for pagination", "1").option("--per-page <n>", "Items per page", "20").addHelpText("after", `
2163
2355
  Examples:
2164
2356
  unforgit log Show recent memories
2165
2357
  unforgit log --all Include deprecated/superseded
@@ -2251,8 +2443,8 @@ Examples:
2251
2443
  });
2252
2444
 
2253
2445
  // src/commands/diff.ts
2254
- import { Command as Command19 } from "commander";
2255
- var diffCommand = new Command19("diff").description("Show differences between local and remote memories").argument("[memoryId]", "Specific memory ID to diff").option("--stat", "Show only statistics").addHelpText("after", `
2446
+ import { Command as Command20 } from "commander";
2447
+ var diffCommand = new Command20("diff").description("Show differences between local and remote memories").argument("[memoryId]", "Specific memory ID to diff").option("--stat", "Show only statistics").addHelpText("after", `
2256
2448
  Examples:
2257
2449
  unforgit diff Show all differences
2258
2450
  unforgit diff --stat Show difference statistics only
@@ -2418,7 +2610,7 @@ function findFullId(store, partialId, orgId, repoId) {
2418
2610
  }
2419
2611
 
2420
2612
  // src/commands/keys.ts
2421
- import { Command as Command20 } from "commander";
2613
+ import { Command as Command21 } from "commander";
2422
2614
  function requireRemote() {
2423
2615
  if (!isInitialized()) {
2424
2616
  logger.fatal("not an unforgit repository");
@@ -2438,7 +2630,7 @@ function requireRemote() {
2438
2630
  }
2439
2631
  return { url: config.remote.url, apiKey };
2440
2632
  }
2441
- var keysCommand = new Command20("keys").description("Manage API keys for remote authentication");
2633
+ var keysCommand = new Command21("keys").description("Manage API keys for remote authentication");
2442
2634
  keysCommand.command("create").description("Create a new API key").requiredOption("--name <name>", "Name for the API key").requiredOption("--org <orgId>", "Organization ID for the key").addHelpText("after", `
2443
2635
  Examples:
2444
2636
  unforgit keys create --name "CI pipeline" --org my-org
@@ -2507,8 +2699,8 @@ keysCommand.command("revoke").description("Revoke an API key").argument("<id>",
2507
2699
  });
2508
2700
 
2509
2701
  // src/commands/auth.ts
2510
- import { Command as Command21 } from "commander";
2511
- var authCommand = new Command21("auth").description("Check authentication status for remote server and APIs");
2702
+ import { Command as Command22 } from "commander";
2703
+ var authCommand = new Command22("auth").description("Check authentication status for remote server and APIs");
2512
2704
  authCommand.command("status").description("Check authentication status").action(async () => {
2513
2705
  if (!isInitialized()) {
2514
2706
  logger.fatal("not an unforgit repository");
@@ -2559,8 +2751,8 @@ authCommand.command("status").description("Check authentication status").action(
2559
2751
  });
2560
2752
 
2561
2753
  // src/commands/config.ts
2562
- import { Command as Command22 } from "commander";
2563
- var configCommand = new Command22("config").description("Manage unforgit configuration");
2754
+ import { Command as Command23 } from "commander";
2755
+ var configCommand = new Command23("config").description("Manage unforgit configuration");
2564
2756
  configCommand.command("list").alias("ls").description("List all configuration values").action(() => {
2565
2757
  if (!isInitialized()) {
2566
2758
  logger.fatal("not an unforgit repository");
@@ -2721,19 +2913,49 @@ function coerceConfigValue(value) {
2721
2913
  }
2722
2914
 
2723
2915
  // src/commands/embeddings.ts
2724
- import { Command as Command23 } from "commander";
2916
+ import { Command as Command24 } from "commander";
2725
2917
  var cwd3 = process.cwd();
2726
- var embeddingsCommand = new Command23("embeddings").description("Manage memory embeddings for semantic search");
2727
- embeddingsCommand.command("backfill").description("Generate embeddings for memories that don't have them").option("--batch-size <n>", "Number of memories to process in parallel", "5").option("--delay <ms>", "Delay between batches (ms)", "500").option("--dry-run", "Show what would be done without making changes").option("--model <model>", "OpenAI embedding model", "text-embedding-3-small").action(async (opts) => {
2918
+ function embeddingCoverage(stats) {
2919
+ return stats.total > 0 ? Number((stats.withEmbedding / stats.total * 100).toFixed(1)) : 0;
2920
+ }
2921
+ function buildBackfillJsonPayload(options) {
2922
+ const statsAfter = options.statsAfter ?? options.statsBefore;
2923
+ const failures = options.failures ?? [];
2924
+ return {
2925
+ dryRun: options.dryRun,
2926
+ model: options.model,
2927
+ ...options.provider ? { provider: options.provider } : {},
2928
+ planned: options.planned,
2929
+ processed: options.processed,
2930
+ errors: failures.length,
2931
+ failures,
2932
+ statsBefore: {
2933
+ ...options.statsBefore,
2934
+ coverage: embeddingCoverage(options.statsBefore)
2935
+ },
2936
+ statsAfter: {
2937
+ ...statsAfter,
2938
+ coverage: embeddingCoverage(statsAfter)
2939
+ },
2940
+ ...options.previews ? { memories: options.previews, truncated: Boolean(options.truncated) } : {}
2941
+ };
2942
+ }
2943
+ var embeddingsCommand = new Command24("embeddings").description("Manage memory embeddings for semantic search");
2944
+ embeddingsCommand.command("backfill").description("Generate embeddings for memories that don't have them").option("--batch-size <n>", "Number of memories to process in parallel", "5").option("--delay <ms>", "Delay between batches (ms)", "500").option("--dry-run", "Show what would be done without making changes").option("--provider <provider>", "Embedding provider: auto, local, openai, disabled").option("--model <model>", "Embedding model (defaults to configured provider model)").action(async (opts) => {
2728
2945
  if (!isInitialized(cwd3)) {
2729
2946
  logger.error("Unforgit not initialized. Run 'unforgit init' first.");
2730
2947
  process.exit(EXIT_CONFIG_ERROR);
2731
2948
  }
2732
2949
  const config = loadConfig(cwd3);
2733
- const apiKey = process.env.OPENAI_API_KEY;
2734
- if (!apiKey && !opts.dryRun) {
2735
- logger.error("OpenAI API key not set.");
2736
- logger.error("Set the OPENAI_API_KEY environment variable.");
2950
+ const embeddingConfig = {
2951
+ provider: opts.provider ?? config.embeddings?.provider ?? "auto",
2952
+ model: opts.model ?? config.embeddings?.model,
2953
+ apiKey: process.env.OPENAI_API_KEY
2954
+ };
2955
+ const provider = resolveEmbeddingProvider(embeddingConfig);
2956
+ if (!provider.available && !opts.dryRun) {
2957
+ logger.error(provider.reason ?? "Embedding provider is not available.");
2958
+ logger.error("Use 'unforgit config set embeddings.provider local' for no-key local embeddings.");
2737
2959
  process.exit(EXIT_ERROR);
2738
2960
  }
2739
2961
  const dbPath = getDbPath(cwd3);
@@ -2741,18 +2963,51 @@ embeddingsCommand.command("backfill").description("Generate embeddings for memor
2741
2963
  const orgId = config.remote.orgId || "local";
2742
2964
  const repoId = config.remote.repoId || "local";
2743
2965
  try {
2744
- const memories = store.getMemoriesWithoutEmbeddings(orgId, repoId);
2966
+ const memories = store.getMemoriesWithoutEmbeddings(orgId, repoId, {
2967
+ model: provider.model,
2968
+ provider: provider.provider,
2969
+ dimensions: provider.dimensions
2970
+ });
2745
2971
  const stats = store.getEmbeddingStats(orgId, repoId);
2972
+ if (isJsonMode() && opts.dryRun) {
2973
+ outputJson(buildBackfillJsonPayload({
2974
+ dryRun: true,
2975
+ model: provider.model,
2976
+ provider: provider.provider,
2977
+ statsBefore: stats,
2978
+ planned: memories.length,
2979
+ processed: 0,
2980
+ previews: memories.slice(0, 10).map((memory) => ({
2981
+ id: memory.id,
2982
+ textPreview: memory.text.slice(0, 80)
2983
+ })),
2984
+ truncated: memories.length > 10
2985
+ }));
2986
+ return;
2987
+ }
2746
2988
  logger.info(`Embedding stats:`);
2747
2989
  logger.info(` Total memories: ${stats.total}`);
2748
2990
  logger.info(` With embedding: ${stats.withEmbedding}`);
2749
2991
  logger.info(` Without embedding: ${stats.withoutEmbedding}`);
2750
2992
  logger.info("");
2751
2993
  if (memories.length === 0) {
2994
+ if (isJsonMode()) {
2995
+ outputJson(buildBackfillJsonPayload({
2996
+ dryRun: Boolean(opts.dryRun),
2997
+ model: provider.model,
2998
+ provider: provider.provider,
2999
+ statsBefore: stats,
3000
+ planned: 0,
3001
+ processed: 0
3002
+ }));
3003
+ return;
3004
+ }
2752
3005
  logger.info("All memories already have embeddings.");
2753
3006
  return;
2754
3007
  }
2755
3008
  logger.info(`Found ${memories.length} memories without embeddings.`);
3009
+ logger.info(`Embedding provider: ${provider.provider}`);
3010
+ logger.info(`Embedding model: ${provider.model}`);
2756
3011
  if (opts.dryRun) {
2757
3012
  logger.info("\n[Dry run - no changes made]");
2758
3013
  logger.info("Would generate embeddings for:");
@@ -2767,7 +3022,7 @@ embeddingsCommand.command("backfill").description("Generate embeddings for memor
2767
3022
  const batchSize = parsePositiveInt(opts.batchSize, "batch-size");
2768
3023
  const delay = parsePositiveInt(opts.delay, "delay");
2769
3024
  let processed = 0;
2770
- let errors = 0;
3025
+ const failures = [];
2771
3026
  logger.info(`
2772
3027
  Generating embeddings (batch size: ${batchSize}, delay: ${delay}ms)...`);
2773
3028
  for (let i = 0; i < memories.length; i += batchSize) {
@@ -2776,15 +3031,19 @@ Generating embeddings (batch size: ${batchSize}, delay: ${delay}ms)...`);
2776
3031
  batch.map(async (memory) => {
2777
3032
  try {
2778
3033
  const result = await generateEmbedding(memory.text, {
2779
- apiKey,
2780
- model: opts.model
3034
+ ...embeddingConfig
2781
3035
  });
2782
- await store.storeEmbedding(memory.id, result.embedding, result.model);
3036
+ await store.storeEmbedding(memory.id, result.embedding, result.model, result.provider);
2783
3037
  processed++;
2784
3038
  logger.progress(processed, memories.length, "embeddings");
2785
3039
  } catch (err) {
2786
- errors++;
2787
- logger.error(`${memory.id.slice(0, 8)}: ${err instanceof Error ? err.message : err}`);
3040
+ const message = err instanceof Error ? err.message : String(err);
3041
+ failures.push({
3042
+ id: memory.id,
3043
+ textPreview: memory.text.slice(0, 80),
3044
+ error: message
3045
+ });
3046
+ logger.error(`${memory.id.slice(0, 8)}: ${message}`);
2788
3047
  }
2789
3048
  })
2790
3049
  );
@@ -2795,7 +3054,23 @@ Generating embeddings (batch size: ${batchSize}, delay: ${delay}ms)...`);
2795
3054
  logger.info(`
2796
3055
  Backfill complete:`);
2797
3056
  logger.info(` Processed: ${processed}`);
2798
- logger.info(` Errors: ${errors}`);
3057
+ logger.info(` Errors: ${failures.length}`);
3058
+ const statsAfter = store.getEmbeddingStats(orgId, repoId);
3059
+ if (isJsonMode()) {
3060
+ outputJson(buildBackfillJsonPayload({
3061
+ dryRun: false,
3062
+ model: provider.model,
3063
+ provider: provider.provider,
3064
+ statsBefore: stats,
3065
+ statsAfter,
3066
+ planned: memories.length,
3067
+ processed,
3068
+ failures
3069
+ }));
3070
+ }
3071
+ if (failures.length > 0) {
3072
+ process.exitCode = EXIT_ERROR;
3073
+ }
2799
3074
  } finally {
2800
3075
  store.close();
2801
3076
  }
@@ -2831,7 +3106,7 @@ Run 'unforgit embeddings backfill' to generate missing embeddings.`);
2831
3106
  store.close();
2832
3107
  }
2833
3108
  });
2834
- embeddingsCommand.command("clear").description("Remove all embeddings (requires regeneration)").option("--yes", "Skip confirmation").action(async (opts) => {
3109
+ embeddingsCommand.command("clear").description("Remove all embeddings (requires regeneration)").option("--yes", "Skip confirmation").option("--no-backup", "Skip automatic local database backup before clearing embeddings").action(async (opts) => {
2835
3110
  if (!isInitialized(cwd3)) {
2836
3111
  logger.error("Unforgit not initialized. Run 'unforgit init' first.");
2837
3112
  process.exit(EXIT_CONFIG_ERROR);
@@ -2842,6 +3117,19 @@ embeddingsCommand.command("clear").description("Remove all embeddings (requires
2842
3117
  return;
2843
3118
  }
2844
3119
  const dbPath = getDbPath(cwd3);
3120
+ if (opts.backup !== false) {
3121
+ try {
3122
+ const backup = createLocalDatabaseBackup(dbPath, "embeddings-clear");
3123
+ if (backup) {
3124
+ logger.info(`Created local embeddings backup: ${backup.dir}`);
3125
+ }
3126
+ } catch (err) {
3127
+ logger.error(
3128
+ `Failed to create local embeddings backup: ${err instanceof Error ? err.message : String(err)}`
3129
+ );
3130
+ process.exit(EXIT_ERROR);
3131
+ }
3132
+ }
2845
3133
  const store = new LocalStore(dbPath);
2846
3134
  try {
2847
3135
  const deleted = store.clearEmbeddings();
@@ -2853,121 +3141,6 @@ embeddingsCommand.command("clear").description("Remove all embeddings (requires
2853
3141
 
2854
3142
  // src/commands/reset.ts
2855
3143
  import { Command as Command25 } from "commander";
2856
-
2857
- // src/commands/backups.ts
2858
- import fs4 from "fs";
2859
- import path3 from "path";
2860
- import { Command as Command24 } from "commander";
2861
- function formatBackupTimestamp(date) {
2862
- return date.toISOString().replace(/[-:]/g, "").replace("T", "-").replace(/\.\d{3}Z$/, "");
2863
- }
2864
- function backupRootForDb(dbPath) {
2865
- return path3.join(path3.dirname(dbPath), "backups");
2866
- }
2867
- function describeBackup(dir) {
2868
- const files = fs4.readdirSync(dir).filter((file) => file === "local.db" || file === "local.db-wal" || file === "local.db-shm").sort();
2869
- const sizeBytes = files.reduce((total, file) => total + fs4.statSync(path3.join(dir, file)).size, 0);
2870
- const stat = fs4.statSync(dir);
2871
- return {
2872
- name: path3.basename(dir),
2873
- dir,
2874
- files,
2875
- sizeBytes,
2876
- createdAt: stat.mtime.toISOString()
2877
- };
2878
- }
2879
- function resolveBackupDir(backupRoot, backupName) {
2880
- if (backupName !== path3.basename(backupName)) {
2881
- throw new Error("Invalid backup name");
2882
- }
2883
- const resolvedRoot = path3.resolve(backupRoot);
2884
- const resolvedBackup = path3.resolve(resolvedRoot, backupName);
2885
- if (!resolvedBackup.startsWith(`${resolvedRoot}${path3.sep}`)) {
2886
- throw new Error("Invalid backup name");
2887
- }
2888
- return resolvedBackup;
2889
- }
2890
- function createLocalResetBackup(dbPath, now = /* @__PURE__ */ new Date()) {
2891
- if (!fs4.existsSync(dbPath)) {
2892
- return null;
2893
- }
2894
- const backupRoot = backupRootForDb(dbPath);
2895
- const baseName = `reset-${formatBackupTimestamp(now)}`;
2896
- let backupDir = path3.join(backupRoot, baseName);
2897
- let suffix = 1;
2898
- while (fs4.existsSync(backupDir)) {
2899
- backupDir = path3.join(backupRoot, `${baseName}-${suffix++}`);
2900
- }
2901
- fs4.mkdirSync(backupDir, { recursive: true, mode: 448 });
2902
- for (const source of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
2903
- if (!fs4.existsSync(source)) continue;
2904
- fs4.copyFileSync(source, path3.join(backupDir, path3.basename(source)));
2905
- }
2906
- return describeBackup(backupDir);
2907
- }
2908
- function listLocalResetBackups(dbPath) {
2909
- const backupRoot = backupRootForDb(dbPath);
2910
- if (!fs4.existsSync(backupRoot)) {
2911
- return [];
2912
- }
2913
- return fs4.readdirSync(backupRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.startsWith("reset-")).map((entry) => describeBackup(path3.join(backupRoot, entry.name))).filter((backup) => backup.files.includes("local.db")).sort((a, b) => b.name.localeCompare(a.name));
2914
- }
2915
- function restoreLocalResetBackup(dbPath, backupName, now = /* @__PURE__ */ new Date()) {
2916
- const backupRoot = backupRootForDb(dbPath);
2917
- const backupDir = resolveBackupDir(backupRoot, backupName);
2918
- if (!fs4.existsSync(backupDir) || !fs4.statSync(backupDir).isDirectory()) {
2919
- throw new Error(`Backup not found: ${backupName}`);
2920
- }
2921
- const restoredFrom = describeBackup(backupDir);
2922
- if (!restoredFrom.files.includes("local.db")) {
2923
- throw new Error(`Backup is missing local.db: ${backupName}`);
2924
- }
2925
- const safetyBackup = createLocalResetBackup(dbPath, now);
2926
- fs4.mkdirSync(path3.dirname(dbPath), { recursive: true });
2927
- for (const target of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
2928
- if (fs4.existsSync(target)) {
2929
- fs4.rmSync(target, { force: true });
2930
- }
2931
- }
2932
- for (const file of restoredFrom.files) {
2933
- fs4.copyFileSync(path3.join(backupDir, file), path3.join(path3.dirname(dbPath), file));
2934
- }
2935
- return { restoredFrom, safetyBackup };
2936
- }
2937
- var backupsCommand = new Command24("backups").description("List and restore local reset backups");
2938
- backupsCommand.command("list").description("List local backups created before destructive resets/restores").action(() => {
2939
- const backups = listLocalResetBackups(getDbPath());
2940
- if (backups.length === 0) {
2941
- logger.info("No local reset backups found.");
2942
- return;
2943
- }
2944
- for (const backup of backups) {
2945
- logger.info(`${backup.name} ${backup.sizeBytes} bytes ${backup.dir}`);
2946
- }
2947
- });
2948
- backupsCommand.command("restore").argument("<name>", "Backup directory name, for example reset-20260610-123456").description("Restore a local reset backup into the active local database").option("--force", "Skip confirmation prompt").action(async (name, opts) => {
2949
- if (!opts.force) {
2950
- const ok = await confirm(
2951
- "This will replace the active local database after creating a safety backup. Continue?"
2952
- );
2953
- if (!ok) {
2954
- logger.info("Aborted.");
2955
- return;
2956
- }
2957
- }
2958
- try {
2959
- const result = restoreLocalResetBackup(getDbPath(), name);
2960
- logger.info(`Restored local database from ${result.restoredFrom.name}`);
2961
- if (result.safetyBackup) {
2962
- logger.info(`Previous local database safety backup: ${result.safetyBackup.dir}`);
2963
- }
2964
- } catch (err) {
2965
- logger.error(err instanceof Error ? err.message : String(err));
2966
- process.exit(EXIT_ERROR);
2967
- }
2968
- });
2969
-
2970
- // src/commands/reset.ts
2971
3144
  var resetCommand = new Command25("reset").description("Permanently delete all memories and related data").option("--local", "Reset local store only").option("--remote", "Reset remote store only").option("--force", "Skip confirmation prompt").option("--no-backup", "Skip automatic local database backup before local reset").addHelpText("after", `
2972
3145
  Examples:
2973
3146
  unforgit reset Reset both local and remote
@@ -3211,22 +3384,45 @@ var doctorCommand = new Command27("doctor").description("Check system health and
3211
3384
  status: "ok",
3212
3385
  message: `${memoryStats.total} memories (${memoryStats.byType.episodic} episodic, ${memoryStats.byType.semantic} semantic, ${memoryStats.byType.procedural} procedural)`
3213
3386
  });
3387
+ const provider = resolveEmbeddingProvider({
3388
+ provider: config.embeddings?.provider ?? "auto",
3389
+ model: config.embeddings?.model,
3390
+ apiKey: process.env.OPENAI_API_KEY
3391
+ });
3392
+ results.push({
3393
+ check: "embedding-provider",
3394
+ status: provider.available ? "ok" : "warn",
3395
+ message: `${provider.provider} embeddings (${provider.model}, ${provider.dimensions} dimensions)${provider.reason ? `: ${provider.reason}` : ""}`,
3396
+ details: {
3397
+ provider: provider.provider,
3398
+ model: provider.model,
3399
+ dimensions: provider.dimensions,
3400
+ available: provider.available
3401
+ }
3402
+ });
3214
3403
  const stats = store.getEmbeddingStats(orgId, repoId);
3404
+ const incompatibleOrMissing = store.getMemoriesWithoutEmbeddings(orgId, repoId, {
3405
+ model: provider.model,
3406
+ provider: provider.provider,
3407
+ dimensions: provider.dimensions
3408
+ }).length;
3215
3409
  if (stats.total === 0) {
3216
3410
  results.push({ check: "embeddings", status: "ok", message: "No memories yet" });
3217
- } else if (stats.withoutEmbedding === 0) {
3218
- results.push({ check: "embeddings", status: "ok", message: `All ${stats.total} memories have embeddings` });
3411
+ } else if (incompatibleOrMissing === 0) {
3412
+ results.push({ check: "embeddings", status: "ok", message: `All ${stats.total} memories have compatible embeddings` });
3219
3413
  } else {
3220
- const pct = (stats.withEmbedding / stats.total * 100).toFixed(1);
3414
+ const compatible = stats.total - incompatibleOrMissing;
3415
+ const pct = (compatible / stats.total * 100).toFixed(1);
3221
3416
  results.push({
3222
3417
  check: "embeddings",
3223
3418
  status: "warn",
3224
- message: `${stats.withoutEmbedding}/${stats.total} memories lack embeddings (${pct}% coverage). Run 'unforgit embeddings backfill'`,
3419
+ message: `${incompatibleOrMissing}/${stats.total} memories lack compatible embeddings for ${provider.provider}/${provider.model} (${pct}% coverage). Run 'unforgit embeddings backfill'`,
3225
3420
  fix: "Run 'unforgit embeddings backfill'."
3226
3421
  });
3227
3422
  }
3228
3423
  const pendingPush = store.getPendingPush();
3229
3424
  const conflicts = store.getConflicts();
3425
+ const syncSummary = store.getSyncSummary(orgId, repoId);
3230
3426
  const unsyncedTombstones = store.getUnsyncedTombstones(orgId, repoId);
3231
3427
  if (unsyncedTombstones.length > 0) {
3232
3428
  results.push({
@@ -3243,16 +3439,22 @@ var doctorCommand = new Command27("doctor").description("Check system health and
3243
3439
  check: "sync",
3244
3440
  status: "warn",
3245
3441
  message: `${conflicts.length} sync conflict(s) need resolution`,
3246
- fix: "Resolve conflicts with 'unforgit pull --force' or 'unforgit push --force' after reviewing the desired source of truth."
3442
+ fix: "Resolve conflicts with 'unforgit pull --force' or 'unforgit push --force' after reviewing the desired source of truth.",
3443
+ details: syncSummary
3247
3444
  });
3248
- } else if (pendingPush.length > 0) {
3445
+ } else if (pendingPush.length > 0 || syncSummary.pendingPull > 0) {
3446
+ const parts = [];
3447
+ if (pendingPush.length > 0) parts.push(`${pendingPush.length} memory(s) pending push`);
3448
+ if (syncSummary.pendingPull > 0) parts.push(`${syncSummary.pendingPull} memory(s) pending pull`);
3249
3449
  results.push({
3250
3450
  check: "sync",
3251
- status: "ok",
3252
- message: `${pendingPush.length} memory(s) pending push`
3451
+ status: "warn",
3452
+ message: parts.join(", "),
3453
+ fix: "Run 'unforgit push' to publish local memory changes, or configure/disable sync if this repository is intentionally local-only.",
3454
+ details: syncSummary
3253
3455
  });
3254
3456
  } else {
3255
- results.push({ check: "sync", status: "ok", message: "Sync state clean" });
3457
+ results.push({ check: "sync", status: "ok", message: "Sync state clean", details: syncSummary });
3256
3458
  }
3257
3459
  } finally {
3258
3460
  store.close();
@@ -3300,11 +3502,12 @@ var doctorCommand = new Command27("doctor").description("Check system health and
3300
3502
  });
3301
3503
  }
3302
3504
  } catch (err) {
3505
+ const localhostRemote = isLocalhostUrl(config.remote.url);
3303
3506
  results.push({
3304
3507
  check: "remote",
3305
3508
  status: "error",
3306
- message: `Cannot connect to ${config.remote.url}: ${err instanceof Error ? err.message : err}`,
3307
- fix: "Start the Unforgit API server or update remote.url in unforgit.yaml."
3509
+ message: localhostRemote ? `Cannot connect to local remote API at ${config.remote.url}: ${err instanceof Error ? err.message : err}. Local memory and local embeddings still work; only remote sync is unavailable.` : `Cannot connect to ${config.remote.url}: ${err instanceof Error ? err.message : err}`,
3510
+ fix: localhostRemote ? "remote.url points to localhost. Start the Unforgit API server on this machine/port, update remote.url, or disable sync if this repository is intentionally local-only." : "Start the Unforgit API server or update remote.url in unforgit.yaml."
3308
3511
  });
3309
3512
  }
3310
3513
  } else {
@@ -3315,15 +3518,19 @@ var doctorCommand = new Command27("doctor").description("Check system health and
3315
3518
  fix: "Run 'unforgit remote add origin <url>' if this repo should sync remotely."
3316
3519
  });
3317
3520
  }
3318
- const openaiKey = process.env.OPENAI_API_KEY;
3319
- if (openaiKey) {
3320
- results.push({ check: "openai", status: "ok", message: "OpenAI API key configured ([REDACTED])" });
3521
+ if (isOpenAIConfigured()) {
3522
+ results.push({ check: "openai", status: "ok", message: "OpenAI API key configured ([REDACTED]) for optional cloud AI features" });
3321
3523
  } else {
3524
+ const embeddingProvider = resolveEmbeddingProvider({
3525
+ provider: config.embeddings?.provider ?? "auto",
3526
+ model: config.embeddings?.model
3527
+ });
3528
+ const message = embeddingProvider.provider === "local" ? "No OpenAI API key configured; local embeddings are active. OpenAI is only needed for OpenAI-backed embeddings or AI consolidation." : "No OpenAI API key. OpenAI-backed embeddings and AI consolidation require it.";
3322
3529
  results.push({
3323
3530
  check: "openai",
3324
- status: "warn",
3325
- message: "No OpenAI API key. Auto-consolidation and embeddings backfill require it.",
3326
- fix: "Set OPENAI_API_KEY before running embedding backfill or AI consolidation."
3531
+ status: embeddingProvider.provider === "local" ? "ok" : "warn",
3532
+ message,
3533
+ fix: embeddingProvider.provider === "local" ? void 0 : "Set OPENAI_API_KEY or configure embeddings.provider=local."
3327
3534
  });
3328
3535
  }
3329
3536
  } catch (err) {
@@ -3352,6 +3559,14 @@ function summarize(results) {
3352
3559
  function buildPayload(results) {
3353
3560
  return { summary: summarize(results), results };
3354
3561
  }
3562
+ function isLocalhostUrl(value) {
3563
+ try {
3564
+ const url = new URL(value);
3565
+ return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
3566
+ } catch {
3567
+ return false;
3568
+ }
3569
+ }
3355
3570
  function exitForResults(results) {
3356
3571
  if (results.some((r) => r.status === "error")) {
3357
3572
  process.exit(EXIT_ERROR);