@anraktech/sync 0.13.1 → 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 +252 -8
  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);
@@ -875,7 +979,19 @@ Rules:
875
979
  - Present file lists in a clean table/list format with names and sizes.
876
980
  - Call tools to perform actions. Summarize results naturally after.
877
981
  - If a tool returns an error, report it clearly to the user.
878
- - 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
+ ` : ""}`;
879
995
  }
880
996
  async function executeTool(name, args, ctx) {
881
997
  switch (name) {
@@ -974,6 +1090,28 @@ async function executeTool(name, args, ctx) {
974
1090
  const hash = await hashFile(filePath);
975
1091
  markSynced(filePath, hash, fileStat.size, matchedCase.id, result.linkedDocumentIds[0]);
976
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
+ }
977
1115
  return JSON.stringify({
978
1116
  success: true,
979
1117
  filename: basename4(filePath),
@@ -1506,8 +1644,26 @@ function startAIAgent(ctx) {
1506
1644
  }
1507
1645
  };
1508
1646
  let currentSlashArgs = "";
1647
+ let lastPendingCount = 0;
1509
1648
  async function promptLoop() {
1510
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
+ }
1511
1667
  let input;
1512
1668
  try {
1513
1669
  input = await rl.question(PROMPT);
@@ -1558,6 +1714,7 @@ function startAIAgent(ctx) {
1558
1714
  while (history.length > 21) {
1559
1715
  history.splice(1, 1);
1560
1716
  }
1717
+ history[0] = { role: "system", content: buildSystemPrompt(ctx.config) };
1561
1718
  try {
1562
1719
  const response = await agentTurn([...history], ctx);
1563
1720
  if (response) {
@@ -1573,6 +1730,92 @@ function startAIAgent(ctx) {
1573
1730
  void promptLoop();
1574
1731
  }
1575
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
+
1576
1819
  // src/watcher.ts
1577
1820
  async function scanFolder(config) {
1578
1821
  const folders = getWatchFolders(config);
@@ -1673,10 +1916,11 @@ async function scanExternalFolder(config, folderPath, cases) {
1673
1916
  }
1674
1917
  async function startWatching(config) {
1675
1918
  const folders = getWatchFolders(config);
1919
+ const smartMapper = async (folderName, fileName, caseList) => smartMapFolder(config, folderName, fileName, caseList);
1676
1920
  let cases = await listCases(config);
1677
1921
  const { scanned, queued } = await scanFolder(config);
1678
1922
  if (queued > 0) {
1679
- const result = await processQueue(config, cases);
1923
+ const result = await processQueue(config, cases, { smartMapper });
1680
1924
  if (result.failed > 0) {
1681
1925
  log.warn(`Initial sync: ${result.uploaded} uploaded, ${result.failed} failed`);
1682
1926
  }
@@ -1693,7 +1937,7 @@ async function startWatching(config) {
1693
1937
  if (debounceTimer) clearTimeout(debounceTimer);
1694
1938
  debounceTimer = setTimeout(async () => {
1695
1939
  if (queueSize() > 0) {
1696
- const result = await processQueue(config, cases);
1940
+ const result = await processQueue(config, cases, { smartMapper });
1697
1941
  if (result.uploaded > 0 || result.failed > 0) {
1698
1942
  log.info(
1699
1943
  `Batch: ${result.uploaded} uploaded, ${result.failed} failed`
@@ -1753,7 +1997,7 @@ async function startWatching(config) {
1753
1997
  const result = await scanFolder(config);
1754
1998
  log.info(`Scanned ${result.scanned} files, ${result.queued} need syncing`);
1755
1999
  if (result.queued > 0) {
1756
- const syncResult = await processQueue(config, cases);
2000
+ const syncResult = await processQueue(config, cases, { smartMapper });
1757
2001
  log.info(`Synced: ${syncResult.uploaded} uploaded, ${syncResult.failed} failed`);
1758
2002
  }
1759
2003
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anraktech/sync",
3
- "version": "0.13.1",
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": {