@anraktech/sync 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +316 -9
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -454,6 +454,40 @@ import { basename as basename3, dirname, relative } from "path";
454
454
 
455
455
  // src/mapper.ts
456
456
  import { basename as basename2 } from "path";
457
+
458
+ // src/events.ts
459
+ var pendingFiles = [];
460
+ var recentMappings = [];
461
+ var MAX_RECENT = 20;
462
+ function addRecentMapping(msg) {
463
+ recentMappings.push(msg);
464
+ if (recentMappings.length > MAX_RECENT) {
465
+ recentMappings.shift();
466
+ }
467
+ }
468
+ function addPendingFile(file) {
469
+ if (!pendingFiles.some((f) => f.filePath === file.filePath)) {
470
+ pendingFiles.push(file);
471
+ }
472
+ }
473
+ function removePendingFile(filePath) {
474
+ const idx = pendingFiles.findIndex((f) => f.filePath === filePath);
475
+ if (idx >= 0) {
476
+ return pendingFiles.splice(idx, 1)[0];
477
+ }
478
+ return void 0;
479
+ }
480
+ function removePendingByFolder(folderName) {
481
+ const removed = [];
482
+ for (let i = pendingFiles.length - 1; i >= 0; i--) {
483
+ if (pendingFiles[i].folderName === folderName) {
484
+ removed.push(pendingFiles.splice(i, 1)[0]);
485
+ }
486
+ }
487
+ return removed;
488
+ }
489
+
490
+ // src/mapper.ts
457
491
  function normalize(s) {
458
492
  return s.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
459
493
  }
@@ -506,7 +540,7 @@ function deriveCaseInfo(folderName) {
506
540
  caseName: sanitized
507
541
  };
508
542
  }
509
- async function resolveFolder(config, folderPath, cases) {
543
+ async function resolveFolder(config, folderPath, cases, options) {
510
544
  const folderName = basename2(folderPath);
511
545
  const cached = getFolderMapping(folderName);
512
546
  if (cached) {
@@ -522,6 +556,7 @@ async function resolveFolder(config, folderPath, cases) {
522
556
  if (match) {
523
557
  log.success(`Mapped "${folderName}" -> ${match.caseNumber} (${match.caseName})`);
524
558
  setFolderMapping(folderName, match.id, match.caseName, match.caseNumber);
559
+ addRecentMapping(`Mapped "${folderName}" \u2192 ${match.caseNumber} (${match.caseName})`);
525
560
  return {
526
561
  caseId: match.id,
527
562
  caseName: match.caseName,
@@ -529,12 +564,63 @@ async function resolveFolder(config, folderPath, cases) {
529
564
  created: false
530
565
  };
531
566
  }
567
+ if (options?.smartMapper && allCases.length > 0) {
568
+ try {
569
+ const smartResult = await options.smartMapper(
570
+ folderName,
571
+ options.fileName || folderName,
572
+ allCases
573
+ );
574
+ if (smartResult) {
575
+ if (smartResult.confidence === "high") {
576
+ log.success(`AI mapped "${folderName}" \u2192 ${smartResult.caseNumber} (${smartResult.reason})`);
577
+ setFolderMapping(folderName, smartResult.caseId, smartResult.caseName, smartResult.caseNumber);
578
+ addRecentMapping(
579
+ `AI mapped "${options.fileName || folderName}" \u2192 ${smartResult.caseNumber} (${smartResult.caseName})`
580
+ );
581
+ return {
582
+ caseId: smartResult.caseId,
583
+ caseName: smartResult.caseName,
584
+ caseNumber: smartResult.caseNumber,
585
+ created: false
586
+ };
587
+ } else {
588
+ log.info(
589
+ `AI unsure about "${folderName}" \u2014 suggests ${smartResult.caseNumber} (${smartResult.reason})`
590
+ );
591
+ addPendingFile({
592
+ filePath: "",
593
+ // Set by uploader
594
+ folderName,
595
+ suggestion: {
596
+ caseName: smartResult.caseName,
597
+ caseNumber: smartResult.caseNumber,
598
+ reason: smartResult.reason
599
+ },
600
+ addedAt: Date.now()
601
+ });
602
+ addRecentMapping(
603
+ `Unsure: "${options.fileName || folderName}" in "${folderName}" \u2014 AI suggests ${smartResult.caseNumber} but needs confirmation`
604
+ );
605
+ return {
606
+ caseId: "",
607
+ caseName: "",
608
+ caseNumber: "",
609
+ created: false,
610
+ needsInput: true
611
+ };
612
+ }
613
+ }
614
+ } catch {
615
+ }
616
+ }
532
617
  const { caseNumber, caseName } = deriveCaseInfo(folderName);
533
618
  log.info(`No match for "${folderName}" \u2014 creating case ${caseNumber}`);
534
619
  try {
535
620
  const newCase = await createCase(config, caseNumber, caseName);
536
621
  setFolderMapping(folderName, newCase.id, newCase.caseName, newCase.caseNumber);
537
622
  log.success(`Created case ${newCase.caseNumber} (${newCase.caseName})`);
623
+ addRecentMapping(`Created new case "${newCase.caseNumber}" for folder "${folderName}"`);
538
624
  return {
539
625
  caseId: newCase.id,
540
626
  caseName: newCase.caseName,
@@ -583,7 +669,7 @@ async function enqueue(filePath, watchFolder) {
583
669
  queue.push({ filePath, folderPath, retries: 0 });
584
670
  log.file("queued", relative(watchFolder, filePath));
585
671
  }
586
- async function processQueue(config, cases) {
672
+ async function processQueue(config, cases, options) {
587
673
  if (processing) return { uploaded: 0, failed: 0 };
588
674
  processing = true;
589
675
  let uploaded = 0;
@@ -596,11 +682,29 @@ async function processQueue(config, cases) {
596
682
  await sleep(RATE_LIMIT_INTERVAL_MS - elapsed);
597
683
  }
598
684
  try {
599
- const { caseId, caseName } = await resolveFolder(
685
+ const resolved = await resolveFolder(
600
686
  config,
601
687
  item.folderPath,
602
- cases
688
+ cases,
689
+ { smartMapper: options?.smartMapper, fileName: filename }
603
690
  );
691
+ if (resolved.needsInput) {
692
+ const pending = pendingFiles.find(
693
+ (p) => p.folderName === basename3(item.folderPath) && !p.filePath
694
+ );
695
+ if (pending) {
696
+ pending.filePath = item.filePath;
697
+ } else {
698
+ addPendingFile({
699
+ filePath: item.filePath,
700
+ folderName: basename3(item.folderPath),
701
+ addedAt: Date.now()
702
+ });
703
+ }
704
+ log.info(`Waiting for mapping: ${filename}`);
705
+ continue;
706
+ }
707
+ const { caseId, caseName } = resolved;
604
708
  const hash = await hashFile(item.filePath);
605
709
  const s = await stat2(item.filePath);
606
710
  markPending(item.filePath, hash, s.size, caseId);
@@ -821,6 +925,28 @@ var TOOLS = [
821
925
  description: "Re-scan the watch folder for new or changed files and sync them.",
822
926
  parameters: { type: "object", properties: {} }
823
927
  }
928
+ },
929
+ {
930
+ type: "function",
931
+ function: {
932
+ name: "manage_watch_folders",
933
+ description: "Add, remove, or list watch folders. Use when user wants to watch a new folder, stop watching a folder, or see which folders are being watched.",
934
+ parameters: {
935
+ type: "object",
936
+ properties: {
937
+ action: {
938
+ type: "string",
939
+ enum: ["add", "remove", "list"],
940
+ description: "Action to perform: 'add' a new folder, 'remove' an existing one, or 'list' all watched folders."
941
+ },
942
+ folderPath: {
943
+ type: "string",
944
+ description: "Absolute path to the folder (required for add/remove). Use full absolute paths like /Users/name/Desktop."
945
+ }
946
+ },
947
+ required: ["action"]
948
+ }
949
+ }
824
950
  }
825
951
  ];
826
952
  function buildSystemPrompt(config) {
@@ -834,11 +960,12 @@ Server: ${config.apiUrl}
834
960
  Home directory: ${HOME}
835
961
  Platform: ${IS_MAC ? "macOS" : platform()}
836
962
 
837
- You can browse local folders, scan & sync files, list cases, show sync status, and more.
963
+ You can browse local folders, scan & sync files, list cases, show sync status, manage watch folders, and more.
838
964
 
839
965
  Rules:
840
966
  - Be concise. This is a terminal \u2014 1-3 lines unless showing a list.
841
967
  - Do NOT use markdown. Plain text only.
968
+ - WATCH FOLDERS: When user asks to "watch", "add", or "also sync" a folder, use manage_watch_folders with action "add". When they say "stop watching" or "remove" a folder, use action "remove".
842
969
  - IMPORTANT: When user mentions a folder like "downloads" or "desktop", ALWAYS use the full absolute path. Examples:
843
970
  "downloads" or "my downloads" \u2192 ${join2(HOME, "Downloads")}
844
971
  "desktop" \u2192 ${join2(HOME, "Desktop")}
@@ -852,7 +979,19 @@ Rules:
852
979
  - Present file lists in a clean table/list format with names and sizes.
853
980
  - Call tools to perform actions. Summarize results naturally after.
854
981
  - If a tool returns an error, report it clearly to the user.
855
- - Do NOT use thinking tags or reasoning tags in your output.`;
982
+ - PENDING MAPPINGS: When there are files waiting for case assignment, proactively ask the user which case to map them to. Use upload_to_case to upload once the user confirms.
983
+ - Do NOT use thinking tags or reasoning tags in your output.
984
+ ${pendingFiles.length > 0 ? `
985
+ FILES WAITING FOR MAPPING (ask user which case):
986
+ ${pendingFiles.map((f) => {
987
+ const name = basename4(f.filePath || "unknown");
988
+ const sug = f.suggestion ? ` \u2014 AI suggests: ${f.suggestion.caseName} (${f.suggestion.reason})` : "";
989
+ return `- ${name} in folder "${f.folderName}"${sug}`;
990
+ }).join("\n")}
991
+ ` : ""}${recentMappings.length > 0 ? `
992
+ Recent mapping activity:
993
+ ${recentMappings.slice(-5).map((m) => `- ${m}`).join("\n")}
994
+ ` : ""}`;
856
995
  }
857
996
  async function executeTool(name, args, ctx) {
858
997
  switch (name) {
@@ -951,6 +1090,28 @@ async function executeTool(name, args, ctx) {
951
1090
  const hash = await hashFile(filePath);
952
1091
  markSynced(filePath, hash, fileStat.size, matchedCase.id, result.linkedDocumentIds[0]);
953
1092
  log.success(`Uploaded ${basename4(filePath)} to ${matchedCase.caseName}`);
1093
+ removePendingFile(filePath);
1094
+ const parentFolder = dirname2(filePath);
1095
+ const folderName = basename4(parentFolder);
1096
+ if (folderName && matchedCase) {
1097
+ setFolderMapping(folderName, matchedCase.id, matchedCase.caseName, matchedCase.caseNumber);
1098
+ const sameFolderPending = removePendingByFolder(folderName);
1099
+ for (const pf of sameFolderPending) {
1100
+ if (pf.filePath && pf.filePath !== filePath) {
1101
+ try {
1102
+ const pfResult = await uploadFile(ctx.config, matchedCase.id, pf.filePath);
1103
+ if (pfResult.success && pfResult.linkedDocumentIds.length > 0) {
1104
+ const pfHash = await hashFile(pf.filePath);
1105
+ const pfStat = statSync(pf.filePath);
1106
+ markSynced(pf.filePath, pfHash, pfStat.size, matchedCase.id, pfResult.linkedDocumentIds[0]);
1107
+ log.success(`Also uploaded ${basename4(pf.filePath)} to ${matchedCase.caseName}`);
1108
+ }
1109
+ } catch {
1110
+ log.warn(`Failed to upload pending file: ${basename4(pf.filePath)}`);
1111
+ }
1112
+ }
1113
+ }
1114
+ }
954
1115
  return JSON.stringify({
955
1116
  success: true,
956
1117
  filename: basename4(filePath),
@@ -1076,6 +1237,46 @@ async function executeTool(name, args, ctx) {
1076
1237
  pending: stats.pending
1077
1238
  });
1078
1239
  }
1240
+ case "manage_watch_folders": {
1241
+ const action = args.action;
1242
+ const rawFolder = args.folderPath;
1243
+ if (action === "list") {
1244
+ const folders = getWatchFolders(ctx.config);
1245
+ return JSON.stringify({
1246
+ folders: folders.map((f, i) => ({
1247
+ path: f,
1248
+ primary: f === ctx.config.watchFolder,
1249
+ index: i + 1
1250
+ })),
1251
+ total: folders.length
1252
+ });
1253
+ }
1254
+ if (action === "add") {
1255
+ if (!rawFolder) return JSON.stringify({ error: "Missing folderPath" });
1256
+ const folderPath = normalizePath(rawFolder);
1257
+ if (!ctx.addWatchFolder) {
1258
+ return JSON.stringify({ error: "Watch folder management not available" });
1259
+ }
1260
+ const result = await ctx.addWatchFolder(folderPath);
1261
+ if (result.added) {
1262
+ return JSON.stringify({ success: true, added: result.path || folderPath, total: getWatchFolders(ctx.config).length });
1263
+ }
1264
+ return JSON.stringify({ error: result.error || "Could not add folder" });
1265
+ }
1266
+ if (action === "remove") {
1267
+ if (!rawFolder) return JSON.stringify({ error: "Missing folderPath" });
1268
+ const folderPath = normalizePath(rawFolder);
1269
+ if (!ctx.removeWatchFolder) {
1270
+ return JSON.stringify({ error: "Watch folder management not available" });
1271
+ }
1272
+ const result = ctx.removeWatchFolder(folderPath);
1273
+ if (result.removed) {
1274
+ return JSON.stringify({ success: true, removed: folderPath, total: getWatchFolders(ctx.config).length });
1275
+ }
1276
+ return JSON.stringify({ error: result.error || "Could not remove folder" });
1277
+ }
1278
+ return JSON.stringify({ error: `Unknown action: ${action}. Use add, remove, or list.` });
1279
+ }
1079
1280
  default:
1080
1281
  return JSON.stringify({ error: `Unknown tool: ${name}` });
1081
1282
  }
@@ -1443,8 +1644,26 @@ function startAIAgent(ctx) {
1443
1644
  }
1444
1645
  };
1445
1646
  let currentSlashArgs = "";
1647
+ let lastPendingCount = 0;
1446
1648
  async function promptLoop() {
1447
1649
  while (true) {
1650
+ if (pendingFiles.length > 0 && pendingFiles.length !== lastPendingCount) {
1651
+ lastPendingCount = pendingFiles.length;
1652
+ console.log("");
1653
+ console.log(chalk2.yellow(` ${pendingFiles.length} file(s) need case mapping:`));
1654
+ for (const f of pendingFiles) {
1655
+ const name = basename4(f.filePath || "unknown");
1656
+ if (f.suggestion) {
1657
+ console.log(
1658
+ ` ${chalk2.cyan(name)} ${chalk2.dim("in")} ${f.folderName} ${chalk2.dim("\u2014 AI suggests:")} ${chalk2.green(f.suggestion.caseName)}`
1659
+ );
1660
+ } else {
1661
+ console.log(` ${chalk2.cyan(name)} ${chalk2.dim("in")} ${f.folderName} ${chalk2.dim("\u2014 no match found")}`);
1662
+ }
1663
+ }
1664
+ console.log(chalk2.dim(" Tell me which case, or say 'yes' to accept suggestions."));
1665
+ console.log("");
1666
+ }
1448
1667
  let input;
1449
1668
  try {
1450
1669
  input = await rl.question(PROMPT);
@@ -1495,6 +1714,7 @@ function startAIAgent(ctx) {
1495
1714
  while (history.length > 21) {
1496
1715
  history.splice(1, 1);
1497
1716
  }
1717
+ history[0] = { role: "system", content: buildSystemPrompt(ctx.config) };
1498
1718
  try {
1499
1719
  const response = await agentTurn([...history], ctx);
1500
1720
  if (response) {
@@ -1510,6 +1730,92 @@ function startAIAgent(ctx) {
1510
1730
  void promptLoop();
1511
1731
  }
1512
1732
 
1733
+ // src/smart-mapper.ts
1734
+ async function smartMapFolder(config, folderName, fileName, cases) {
1735
+ if (cases.length === 0) return null;
1736
+ let token;
1737
+ try {
1738
+ token = await getAccessToken(config);
1739
+ } catch {
1740
+ return null;
1741
+ }
1742
+ const caseList = cases.map((c, i) => `${i + 1}. ${c.caseNumber}: ${c.caseName}`).join("\n");
1743
+ const messages = [
1744
+ {
1745
+ role: "system",
1746
+ content: "You are a legal file organizer. Match files to legal cases. Respond ONLY with a JSON object. No markdown, no explanation outside JSON."
1747
+ },
1748
+ {
1749
+ role: "user",
1750
+ content: `Match this file to one of the cases below.
1751
+
1752
+ File: ${fileName}
1753
+ Folder: ${folderName}
1754
+
1755
+ Cases:
1756
+ ${caseList}
1757
+
1758
+ Respond with ONLY valid JSON:
1759
+ If a case matches: {"caseIndex": <number 1-${cases.length}>, "confidence": "high" or "low", "reason": "<10 words max>"}
1760
+ If no case matches: {"caseIndex": null, "confidence": "none", "reason": "<why>"}`
1761
+ }
1762
+ ];
1763
+ try {
1764
+ const response = await fetch(`${config.apiUrl}/api/sync/chat`, {
1765
+ method: "POST",
1766
+ headers: {
1767
+ Authorization: `Bearer ${token}`,
1768
+ "Content-Type": "application/json"
1769
+ },
1770
+ body: JSON.stringify({ messages }),
1771
+ signal: AbortSignal.timeout(15e3)
1772
+ });
1773
+ if (!response.ok) return null;
1774
+ let text = "";
1775
+ const reader = response.body.getReader();
1776
+ const decoder = new TextDecoder();
1777
+ while (true) {
1778
+ const { done, value } = await reader.read();
1779
+ if (done) break;
1780
+ const chunk = decoder.decode(value, { stream: true });
1781
+ for (const line of chunk.split("\n")) {
1782
+ const trimmed = line.trim();
1783
+ if (!trimmed.startsWith("data: ")) continue;
1784
+ const data = trimmed.slice(6);
1785
+ if (data === "[DONE]") continue;
1786
+ try {
1787
+ const parsed = JSON.parse(data);
1788
+ const content = parsed.choices?.[0]?.delta?.content;
1789
+ if (content) text += content;
1790
+ } catch {
1791
+ }
1792
+ }
1793
+ }
1794
+ text = text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
1795
+ const jsonMatch = text.match(/\{[\s\S]*?\}/);
1796
+ if (!jsonMatch) return null;
1797
+ const result = JSON.parse(jsonMatch[0]);
1798
+ if (result.caseIndex == null || result.confidence === "none") {
1799
+ return null;
1800
+ }
1801
+ const idx = result.caseIndex - 1;
1802
+ if (idx < 0 || idx >= cases.length) return null;
1803
+ const matched = cases[idx];
1804
+ return {
1805
+ caseId: matched.id,
1806
+ caseName: matched.caseName,
1807
+ caseNumber: matched.caseNumber,
1808
+ confidence: result.confidence === "high" ? "high" : "low",
1809
+ reason: result.reason || ""
1810
+ };
1811
+ } catch (err) {
1812
+ log.debug(
1813
+ `Smart mapper error: ${err instanceof Error ? err.message : String(err)}`
1814
+ );
1815
+ return null;
1816
+ }
1817
+ }
1818
+
1513
1819
  // src/watcher.ts
1514
1820
  async function scanFolder(config) {
1515
1821
  const folders = getWatchFolders(config);
@@ -1610,10 +1916,11 @@ async function scanExternalFolder(config, folderPath, cases) {
1610
1916
  }
1611
1917
  async function startWatching(config) {
1612
1918
  const folders = getWatchFolders(config);
1919
+ const smartMapper = async (folderName, fileName, caseList) => smartMapFolder(config, folderName, fileName, caseList);
1613
1920
  let cases = await listCases(config);
1614
1921
  const { scanned, queued } = await scanFolder(config);
1615
1922
  if (queued > 0) {
1616
- const result = await processQueue(config, cases);
1923
+ const result = await processQueue(config, cases, { smartMapper });
1617
1924
  if (result.failed > 0) {
1618
1925
  log.warn(`Initial sync: ${result.uploaded} uploaded, ${result.failed} failed`);
1619
1926
  }
@@ -1630,7 +1937,7 @@ async function startWatching(config) {
1630
1937
  if (debounceTimer) clearTimeout(debounceTimer);
1631
1938
  debounceTimer = setTimeout(async () => {
1632
1939
  if (queueSize() > 0) {
1633
- const result = await processQueue(config, cases);
1940
+ const result = await processQueue(config, cases, { smartMapper });
1634
1941
  if (result.uploaded > 0 || result.failed > 0) {
1635
1942
  log.info(
1636
1943
  `Batch: ${result.uploaded} uploaded, ${result.failed} failed`
@@ -1690,7 +1997,7 @@ async function startWatching(config) {
1690
1997
  const result = await scanFolder(config);
1691
1998
  log.info(`Scanned ${result.scanned} files, ${result.queued} need syncing`);
1692
1999
  if (result.queued > 0) {
1693
- const syncResult = await processQueue(config, cases);
2000
+ const syncResult = await processQueue(config, cases, { smartMapper });
1694
2001
  log.info(`Synced: ${syncResult.uploaded} uploaded, ${syncResult.failed} failed`);
1695
2002
  }
1696
2003
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anraktech/sync",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "AnrakLegal desktop file sync agent — watches local folders and syncs to case management",
5
5
  "type": "module",
6
6
  "bin": {