@anraktech/sync 0.13.1 → 0.15.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 +387 -81
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,14 +4,15 @@
4
4
  import { Command } from "commander";
5
5
  import { createInterface as createInterface2 } from "readline/promises";
6
6
  import { stdin as stdin2, stdout as stdout2 } from "process";
7
- import { existsSync as existsSync4, statSync as statSync3 } from "fs";
8
- import { resolve as resolve3 } from "path";
7
+ import { readFileSync as readFileSync3 } from "fs";
8
+ import { resolve as resolve4 } from "path";
9
+ import { homedir as homedir3 } from "os";
9
10
  import chalk4 from "chalk";
10
11
 
11
12
  // src/config.ts
12
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
13
14
  import { homedir } from "os";
14
- import { join } from "path";
15
+ import { join, resolve } from "path";
15
16
 
16
17
  // src/logger.ts
17
18
  import chalk from "chalk";
@@ -31,11 +32,29 @@ var log = {
31
32
  var CONFIG_DIR = join(homedir(), ".anrak-sync");
32
33
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
33
34
  var CACHE_FILE = join(CONFIG_DIR, "cache.json");
35
+ var HOME = homedir();
36
+ var SHORTCUTS = {
37
+ downloads: join(HOME, "Downloads"),
38
+ download: join(HOME, "Downloads"),
39
+ desktop: join(HOME, "Desktop"),
40
+ documents: join(HOME, "Documents"),
41
+ document: join(HOME, "Documents")
42
+ };
43
+ function normalizePath(p) {
44
+ if (!p) return p;
45
+ if (p.startsWith("~/") || p === "~") return resolve(p.replace(/^~/, HOME));
46
+ const lower = p.toLowerCase().replace(/^\//, "");
47
+ if (SHORTCUTS[lower]) return SHORTCUTS[lower];
48
+ if (p.startsWith("/") && !existsSync(p) && SHORTCUTS[p.slice(1).toLowerCase()]) {
49
+ return SHORTCUTS[p.slice(1).toLowerCase()];
50
+ }
51
+ return p;
52
+ }
34
53
  function getWatchFolders(config) {
35
54
  const folders = /* @__PURE__ */ new Set();
36
- if (config.watchFolder) folders.add(config.watchFolder);
55
+ if (config.watchFolder) folders.add(normalizePath(config.watchFolder));
37
56
  if (config.watchFolders) {
38
- for (const f of config.watchFolders) folders.add(f);
57
+ for (const f of config.watchFolders) folders.add(normalizePath(f));
39
58
  }
40
59
  return [...folders];
41
60
  }
@@ -115,7 +134,7 @@ function openBrowser(url) {
115
134
  exec(`${cmd} "${url}"`);
116
135
  }
117
136
  function findFreePort() {
118
- return new Promise((resolve4, reject) => {
137
+ return new Promise((resolve5, reject) => {
119
138
  const srv = createServer();
120
139
  srv.listen(0, () => {
121
140
  const addr = srv.address();
@@ -125,14 +144,14 @@ function findFreePort() {
125
144
  return;
126
145
  }
127
146
  const port = addr.port;
128
- srv.close(() => resolve4(port));
147
+ srv.close(() => resolve5(port));
129
148
  });
130
149
  });
131
150
  }
132
151
  async function browserLogin(apiUrl) {
133
152
  const port = await findFreePort();
134
153
  return new Promise(
135
- (resolve4, reject) => {
154
+ (resolve5, reject) => {
136
155
  let server;
137
156
  const timeout = setTimeout(() => {
138
157
  server?.close();
@@ -179,7 +198,7 @@ async function browserLogin(apiUrl) {
179
198
  const tokens = await resp.json();
180
199
  clearTimeout(timeout);
181
200
  server.close();
182
- resolve4(tokens);
201
+ resolve5(tokens);
183
202
  } catch (err) {
184
203
  clearTimeout(timeout);
185
204
  server.close();
@@ -367,11 +386,11 @@ function persist() {
367
386
  if (cache) saveCache(cache);
368
387
  }
369
388
  function hashFile(filePath) {
370
- return new Promise((resolve4, reject) => {
389
+ return new Promise((resolve5, reject) => {
371
390
  const hash = createHash("sha256");
372
391
  const stream = createReadStream(filePath);
373
392
  stream.on("data", (chunk) => hash.update(chunk));
374
- stream.on("end", () => resolve4(hash.digest("hex")));
393
+ stream.on("end", () => resolve5(hash.digest("hex")));
375
394
  stream.on("error", reject);
376
395
  });
377
396
  }
@@ -446,7 +465,7 @@ function resetCache() {
446
465
  // src/watcher.ts
447
466
  import { watch } from "chokidar";
448
467
  import { readdirSync as readdirSync2, statSync as statSync2, existsSync as existsSync3 } from "fs";
449
- import { join as join3, relative as relative2, basename as basename5, resolve as resolve2 } from "path";
468
+ import { join as join3, relative as relative2, basename as basename5, resolve as resolve3 } from "path";
450
469
 
451
470
  // src/uploader.ts
452
471
  import { stat as stat2 } from "fs/promises";
@@ -454,6 +473,40 @@ import { basename as basename3, dirname, relative } from "path";
454
473
 
455
474
  // src/mapper.ts
456
475
  import { basename as basename2 } from "path";
476
+
477
+ // src/events.ts
478
+ var pendingFiles = [];
479
+ var recentMappings = [];
480
+ var MAX_RECENT = 20;
481
+ function addRecentMapping(msg) {
482
+ recentMappings.push(msg);
483
+ if (recentMappings.length > MAX_RECENT) {
484
+ recentMappings.shift();
485
+ }
486
+ }
487
+ function addPendingFile(file) {
488
+ if (!pendingFiles.some((f) => f.filePath === file.filePath)) {
489
+ pendingFiles.push(file);
490
+ }
491
+ }
492
+ function removePendingFile(filePath) {
493
+ const idx = pendingFiles.findIndex((f) => f.filePath === filePath);
494
+ if (idx >= 0) {
495
+ return pendingFiles.splice(idx, 1)[0];
496
+ }
497
+ return void 0;
498
+ }
499
+ function removePendingByFolder(folderName) {
500
+ const removed = [];
501
+ for (let i = pendingFiles.length - 1; i >= 0; i--) {
502
+ if (pendingFiles[i].folderName === folderName) {
503
+ removed.push(pendingFiles.splice(i, 1)[0]);
504
+ }
505
+ }
506
+ return removed;
507
+ }
508
+
509
+ // src/mapper.ts
457
510
  function normalize(s) {
458
511
  return s.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
459
512
  }
@@ -506,7 +559,7 @@ function deriveCaseInfo(folderName) {
506
559
  caseName: sanitized
507
560
  };
508
561
  }
509
- async function resolveFolder(config, folderPath, cases) {
562
+ async function resolveFolder(config, folderPath, cases, options) {
510
563
  const folderName = basename2(folderPath);
511
564
  const cached = getFolderMapping(folderName);
512
565
  if (cached) {
@@ -522,6 +575,7 @@ async function resolveFolder(config, folderPath, cases) {
522
575
  if (match) {
523
576
  log.success(`Mapped "${folderName}" -> ${match.caseNumber} (${match.caseName})`);
524
577
  setFolderMapping(folderName, match.id, match.caseName, match.caseNumber);
578
+ addRecentMapping(`Mapped "${folderName}" \u2192 ${match.caseNumber} (${match.caseName})`);
525
579
  return {
526
580
  caseId: match.id,
527
581
  caseName: match.caseName,
@@ -529,12 +583,63 @@ async function resolveFolder(config, folderPath, cases) {
529
583
  created: false
530
584
  };
531
585
  }
586
+ if (options?.smartMapper && allCases.length > 0) {
587
+ try {
588
+ const smartResult = await options.smartMapper(
589
+ folderName,
590
+ options.fileName || folderName,
591
+ allCases
592
+ );
593
+ if (smartResult) {
594
+ if (smartResult.confidence === "high") {
595
+ log.success(`AI mapped "${folderName}" \u2192 ${smartResult.caseNumber} (${smartResult.reason})`);
596
+ setFolderMapping(folderName, smartResult.caseId, smartResult.caseName, smartResult.caseNumber);
597
+ addRecentMapping(
598
+ `AI mapped "${options.fileName || folderName}" \u2192 ${smartResult.caseNumber} (${smartResult.caseName})`
599
+ );
600
+ return {
601
+ caseId: smartResult.caseId,
602
+ caseName: smartResult.caseName,
603
+ caseNumber: smartResult.caseNumber,
604
+ created: false
605
+ };
606
+ } else {
607
+ log.info(
608
+ `AI unsure about "${folderName}" \u2014 suggests ${smartResult.caseNumber} (${smartResult.reason})`
609
+ );
610
+ addPendingFile({
611
+ filePath: "",
612
+ // Set by uploader
613
+ folderName,
614
+ suggestion: {
615
+ caseName: smartResult.caseName,
616
+ caseNumber: smartResult.caseNumber,
617
+ reason: smartResult.reason
618
+ },
619
+ addedAt: Date.now()
620
+ });
621
+ addRecentMapping(
622
+ `Unsure: "${options.fileName || folderName}" in "${folderName}" \u2014 AI suggests ${smartResult.caseNumber} but needs confirmation`
623
+ );
624
+ return {
625
+ caseId: "",
626
+ caseName: "",
627
+ caseNumber: "",
628
+ created: false,
629
+ needsInput: true
630
+ };
631
+ }
632
+ }
633
+ } catch {
634
+ }
635
+ }
532
636
  const { caseNumber, caseName } = deriveCaseInfo(folderName);
533
637
  log.info(`No match for "${folderName}" \u2014 creating case ${caseNumber}`);
534
638
  try {
535
639
  const newCase = await createCase(config, caseNumber, caseName);
536
640
  setFolderMapping(folderName, newCase.id, newCase.caseName, newCase.caseNumber);
537
641
  log.success(`Created case ${newCase.caseNumber} (${newCase.caseName})`);
642
+ addRecentMapping(`Created new case "${newCase.caseNumber}" for folder "${folderName}"`);
538
643
  return {
539
644
  caseId: newCase.id,
540
645
  caseName: newCase.caseName,
@@ -583,7 +688,7 @@ async function enqueue(filePath, watchFolder) {
583
688
  queue.push({ filePath, folderPath, retries: 0 });
584
689
  log.file("queued", relative(watchFolder, filePath));
585
690
  }
586
- async function processQueue(config, cases) {
691
+ async function processQueue(config, cases, options) {
587
692
  if (processing) return { uploaded: 0, failed: 0 };
588
693
  processing = true;
589
694
  let uploaded = 0;
@@ -596,11 +701,29 @@ async function processQueue(config, cases) {
596
701
  await sleep(RATE_LIMIT_INTERVAL_MS - elapsed);
597
702
  }
598
703
  try {
599
- const { caseId, caseName } = await resolveFolder(
704
+ const resolved = await resolveFolder(
600
705
  config,
601
706
  item.folderPath,
602
- cases
707
+ cases,
708
+ { smartMapper: options?.smartMapper, fileName: filename }
603
709
  );
710
+ if (resolved.needsInput) {
711
+ const pending = pendingFiles.find(
712
+ (p) => p.folderName === basename3(item.folderPath) && !p.filePath
713
+ );
714
+ if (pending) {
715
+ pending.filePath = item.filePath;
716
+ } else {
717
+ addPendingFile({
718
+ filePath: item.filePath,
719
+ folderName: basename3(item.folderPath),
720
+ addedAt: Date.now()
721
+ });
722
+ }
723
+ log.info(`Waiting for mapping: ${filename}`);
724
+ continue;
725
+ }
726
+ const { caseId, caseName } = resolved;
604
727
  const hash = await hashFile(item.filePath);
605
728
  const s = await stat2(item.filePath);
606
729
  markPending(item.filePath, hash, s.size, caseId);
@@ -649,7 +772,7 @@ function sleep(ms) {
649
772
  import { createInterface } from "readline/promises";
650
773
  import { stdin, stdout } from "process";
651
774
  import { homedir as homedir2, platform } from "os";
652
- import { resolve, join as join2, dirname as dirname2, basename as basename4 } from "path";
775
+ import { resolve as resolve2, join as join2, dirname as dirname2, basename as basename4 } from "path";
653
776
  import { existsSync as existsSync2, readdirSync, statSync, readFileSync as readFileSync2 } from "fs";
654
777
  import { fileURLToPath } from "url";
655
778
  import chalk2 from "chalk";
@@ -663,21 +786,21 @@ var PKG_VERSION = (() => {
663
786
  return "0.0.0";
664
787
  }
665
788
  })();
666
- var HOME = homedir2();
789
+ var HOME2 = homedir2();
667
790
  var IS_MAC = platform() === "darwin";
668
791
  var FOLDER_SHORTCUTS = {
669
- downloads: join2(HOME, "Downloads"),
670
- download: join2(HOME, "Downloads"),
671
- desktop: join2(HOME, "Desktop"),
672
- documents: join2(HOME, "Documents"),
673
- document: join2(HOME, "Documents"),
674
- home: HOME,
675
- "~": HOME
792
+ downloads: join2(HOME2, "Downloads"),
793
+ download: join2(HOME2, "Downloads"),
794
+ desktop: join2(HOME2, "Desktop"),
795
+ documents: join2(HOME2, "Documents"),
796
+ document: join2(HOME2, "Documents"),
797
+ home: HOME2,
798
+ "~": HOME2
676
799
  };
677
- function normalizePath(folderPath) {
800
+ function normalizePath2(folderPath) {
678
801
  const trimmed = folderPath.trim();
679
802
  if (trimmed.startsWith("~/") || trimmed === "~") {
680
- return resolve(trimmed.replace(/^~/, HOME));
803
+ return resolve2(trimmed.replace(/^~/, HOME2));
681
804
  }
682
805
  const lower = trimmed.toLowerCase();
683
806
  if (FOLDER_SHORTCUTS[lower]) {
@@ -688,9 +811,9 @@ function normalizePath(folderPath) {
688
811
  return FOLDER_SHORTCUTS[withoutSlash];
689
812
  }
690
813
  if (trimmed.startsWith("/") || /^[A-Z]:\\/i.test(trimmed)) {
691
- return resolve(trimmed);
814
+ return resolve2(trimmed);
692
815
  }
693
- return resolve(HOME, trimmed);
816
+ return resolve2(HOME2, trimmed);
694
817
  }
695
818
  var TOOLS = [
696
819
  {
@@ -845,28 +968,42 @@ var TOOLS = [
845
968
  }
846
969
  }
847
970
  ];
848
- function buildSystemPrompt(config) {
971
+ function buildSystemPrompt(config, onboarding) {
849
972
  const folders = getWatchFolders(config);
850
973
  const folderList = folders.map((f) => ` - ${f}`).join("\n");
974
+ const onboardingBlock = onboarding ? `
975
+ ONBOARDING MODE \u2014 This is the user's first time using AnrakLegal Sync. You just finished setup.
976
+ Your FIRST response must:
977
+ 1. Welcome them warmly (1 line).
978
+ 2. Ask which folders on their computer contain legal files they'd like to sync. Give examples like:
979
+ - Downloads (${join2(HOME2, "Downloads")}) \u2014 where they download court orders, filings
980
+ - Desktop (${join2(HOME2, "Desktop")}) \u2014 quick-access files
981
+ - Documents (${join2(HOME2, "Documents")}) \u2014 organized case folders
982
+ - A custom folder path
983
+ 3. Tell them they can name multiple folders at once, e.g. "downloads and desktop"
984
+ 4. After the user responds, use manage_watch_folders to add each folder they mention.
985
+ 5. Once folders are set, confirm the setup and let them know you're now watching for new files.
986
+ Keep it conversational and simple \u2014 they are lawyers, not developers.
987
+ ` : "";
851
988
  return `You are AnrakLegal Sync, a terminal assistant that helps lawyers sync local files to their case management system.
852
989
 
853
990
  Watch folders:
854
991
  ${folderList}
855
992
  Server: ${config.apiUrl}
856
- Home directory: ${HOME}
993
+ Home directory: ${HOME2}
857
994
  Platform: ${IS_MAC ? "macOS" : platform()}
858
995
 
859
996
  You can browse local folders, scan & sync files, list cases, show sync status, manage watch folders, and more.
860
-
997
+ ${onboardingBlock}
861
998
  Rules:
862
999
  - Be concise. This is a terminal \u2014 1-3 lines unless showing a list.
863
1000
  - Do NOT use markdown. Plain text only.
864
1001
  - 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".
865
1002
  - IMPORTANT: When user mentions a folder like "downloads" or "desktop", ALWAYS use the full absolute path. Examples:
866
- "downloads" or "my downloads" \u2192 ${join2(HOME, "Downloads")}
867
- "desktop" \u2192 ${join2(HOME, "Desktop")}
868
- "documents" \u2192 ${join2(HOME, "Documents")}
869
- "~/SomeFolder" \u2192 ${HOME}/SomeFolder
1003
+ "downloads" or "my downloads" \u2192 ${join2(HOME2, "Downloads")}
1004
+ "desktop" \u2192 ${join2(HOME2, "Desktop")}
1005
+ "documents" \u2192 ${join2(HOME2, "Documents")}
1006
+ "~/SomeFolder" \u2192 ${HOME2}/SomeFolder
870
1007
  - FINDING FILES: When user asks to find or locate a specific file, ALWAYS use search_files. NEVER browse a folder hoping to find it.
871
1008
  - UPLOADING: When user wants to upload a specific file to a case, use upload_to_case (NOT scan_folder). scan_folder uploads EVERYTHING.
872
1009
  - When user says "look at" or "check" a folder, use browse_folder to show what's there.
@@ -875,7 +1012,19 @@ Rules:
875
1012
  - Present file lists in a clean table/list format with names and sizes.
876
1013
  - Call tools to perform actions. Summarize results naturally after.
877
1014
  - If a tool returns an error, report it clearly to the user.
878
- - Do NOT use thinking tags or reasoning tags in your output.`;
1015
+ - 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.
1016
+ - Do NOT use thinking tags or reasoning tags in your output.
1017
+ ${pendingFiles.length > 0 ? `
1018
+ FILES WAITING FOR MAPPING (ask user which case):
1019
+ ${pendingFiles.map((f) => {
1020
+ const name = basename4(f.filePath || "unknown");
1021
+ const sug = f.suggestion ? ` \u2014 AI suggests: ${f.suggestion.caseName} (${f.suggestion.reason})` : "";
1022
+ return `- ${name} in folder "${f.folderName}"${sug}`;
1023
+ }).join("\n")}
1024
+ ` : ""}${recentMappings.length > 0 ? `
1025
+ Recent mapping activity:
1026
+ ${recentMappings.slice(-5).map((m) => `- ${m}`).join("\n")}
1027
+ ` : ""}`;
879
1028
  }
880
1029
  async function executeTool(name, args, ctx) {
881
1030
  switch (name) {
@@ -915,8 +1064,8 @@ async function executeTool(name, args, ctx) {
915
1064
  var searchDir = searchDir2;
916
1065
  const query = (args.query || "").trim();
917
1066
  if (!query) return JSON.stringify({ error: "Missing search query" });
918
- const rawSearch = args.searchPath || HOME;
919
- const searchPath = normalizePath(rawSearch);
1067
+ const rawSearch = args.searchPath || HOME2;
1068
+ const searchPath = normalizePath2(rawSearch);
920
1069
  const maxDepth = Math.min(args.maxDepth || 3, 6);
921
1070
  if (!existsSync2(searchPath) || !statSync(searchPath).isDirectory()) {
922
1071
  return JSON.stringify({ error: `Search path not found: ${searchPath}` });
@@ -937,7 +1086,7 @@ async function executeTool(name, args, ctx) {
937
1086
  const caseId = args.caseIdentifier;
938
1087
  if (!rawPath) return JSON.stringify({ error: "Missing filePath" });
939
1088
  if (!caseId) return JSON.stringify({ error: "Missing caseIdentifier" });
940
- const filePath = normalizePath(rawPath);
1089
+ const filePath = normalizePath2(rawPath);
941
1090
  if (!existsSync2(filePath)) {
942
1091
  return JSON.stringify({ error: `File not found: ${filePath}` });
943
1092
  }
@@ -974,6 +1123,28 @@ async function executeTool(name, args, ctx) {
974
1123
  const hash = await hashFile(filePath);
975
1124
  markSynced(filePath, hash, fileStat.size, matchedCase.id, result.linkedDocumentIds[0]);
976
1125
  log.success(`Uploaded ${basename4(filePath)} to ${matchedCase.caseName}`);
1126
+ removePendingFile(filePath);
1127
+ const parentFolder = dirname2(filePath);
1128
+ const folderName = basename4(parentFolder);
1129
+ if (folderName && matchedCase) {
1130
+ setFolderMapping(folderName, matchedCase.id, matchedCase.caseName, matchedCase.caseNumber);
1131
+ const sameFolderPending = removePendingByFolder(folderName);
1132
+ for (const pf of sameFolderPending) {
1133
+ if (pf.filePath && pf.filePath !== filePath) {
1134
+ try {
1135
+ const pfResult = await uploadFile(ctx.config, matchedCase.id, pf.filePath);
1136
+ if (pfResult.success && pfResult.linkedDocumentIds.length > 0) {
1137
+ const pfHash = await hashFile(pf.filePath);
1138
+ const pfStat = statSync(pf.filePath);
1139
+ markSynced(pf.filePath, pfHash, pfStat.size, matchedCase.id, pfResult.linkedDocumentIds[0]);
1140
+ log.success(`Also uploaded ${basename4(pf.filePath)} to ${matchedCase.caseName}`);
1141
+ }
1142
+ } catch {
1143
+ log.warn(`Failed to upload pending file: ${basename4(pf.filePath)}`);
1144
+ }
1145
+ }
1146
+ }
1147
+ }
977
1148
  return JSON.stringify({
978
1149
  success: true,
979
1150
  filename: basename4(filePath),
@@ -993,7 +1164,7 @@ async function executeTool(name, args, ctx) {
993
1164
  case "browse_folder": {
994
1165
  const rawPath = args.folderPath;
995
1166
  if (!rawPath) return JSON.stringify({ error: "Missing folderPath" });
996
- const folderPath = normalizePath(rawPath);
1167
+ const folderPath = normalizePath2(rawPath);
997
1168
  if (!existsSync2(folderPath)) {
998
1169
  return JSON.stringify({ error: `Folder not found: ${folderPath}` });
999
1170
  }
@@ -1043,7 +1214,7 @@ async function executeTool(name, args, ctx) {
1043
1214
  case "scan_folder": {
1044
1215
  const rawPath = args.folderPath;
1045
1216
  if (!rawPath) return JSON.stringify({ error: "Missing folderPath" });
1046
- const folderPath = normalizePath(rawPath);
1217
+ const folderPath = normalizePath2(rawPath);
1047
1218
  try {
1048
1219
  await ctx.scanFolder(folderPath);
1049
1220
  const stats = getStats();
@@ -1115,7 +1286,7 @@ async function executeTool(name, args, ctx) {
1115
1286
  }
1116
1287
  if (action === "add") {
1117
1288
  if (!rawFolder) return JSON.stringify({ error: "Missing folderPath" });
1118
- const folderPath = normalizePath(rawFolder);
1289
+ const folderPath = normalizePath2(rawFolder);
1119
1290
  if (!ctx.addWatchFolder) {
1120
1291
  return JSON.stringify({ error: "Watch folder management not available" });
1121
1292
  }
@@ -1127,7 +1298,7 @@ async function executeTool(name, args, ctx) {
1127
1298
  }
1128
1299
  if (action === "remove") {
1129
1300
  if (!rawFolder) return JSON.stringify({ error: "Missing folderPath" });
1130
- const folderPath = normalizePath(rawFolder);
1301
+ const folderPath = normalizePath2(rawFolder);
1131
1302
  if (!ctx.removeWatchFolder) {
1132
1303
  return JSON.stringify({ error: "Watch folder management not available" });
1133
1304
  }
@@ -1238,13 +1409,13 @@ async function agentTurn(messages, ctx) {
1238
1409
  }
1239
1410
  function startAIAgent(ctx) {
1240
1411
  const history = [
1241
- { role: "system", content: buildSystemPrompt(ctx.config) }
1412
+ { role: "system", content: buildSystemPrompt(ctx.config, ctx.onboarding) }
1242
1413
  ];
1243
1414
  const rl = createInterface({ input: stdin, output: stdout });
1244
1415
  const W = Math.min(process.stdout.columns || 60, 60);
1245
1416
  const allFolders = getWatchFolders(ctx.config);
1246
1417
  const folderDisplays = allFolders.map(
1247
- (f) => f.startsWith(HOME) ? "~" + f.slice(HOME.length) : f
1418
+ (f) => f.startsWith(HOME2) ? "~" + f.slice(HOME2.length) : f
1248
1419
  );
1249
1420
  const stats = ctx.bootStats;
1250
1421
  const pad = (s, len) => {
@@ -1290,7 +1461,11 @@ function startAIAgent(ctx) {
1290
1461
  console.log(row(`${chalk2.dim("Status")} ${statusLine}`));
1291
1462
  }
1292
1463
  console.log(empty);
1293
- console.log(row(chalk2.dim("Type naturally, or press / for commands.")));
1464
+ if (ctx.onboarding) {
1465
+ console.log(row(chalk2.dim("First-time setup \u2014 the AI will guide you.")));
1466
+ } else {
1467
+ console.log(row(chalk2.dim("Type naturally, or press / for commands.")));
1468
+ }
1294
1469
  console.log(empty);
1295
1470
  console.log(bot);
1296
1471
  console.log("");
@@ -1361,7 +1536,7 @@ function startAIAgent(ctx) {
1361
1536
  console.log(chalk2.dim(" " + "\u2500".repeat(40)));
1362
1537
  for (let i = 0; i < folders.length; i++) {
1363
1538
  const f = folders[i];
1364
- const display = f.startsWith(HOME) ? "~" + f.slice(HOME.length) : f;
1539
+ const display = f.startsWith(HOME2) ? "~" + f.slice(HOME2.length) : f;
1365
1540
  const tag = f === ctx.config.watchFolder ? chalk2.cyan(" (primary)") : "";
1366
1541
  console.log(` ${chalk2.dim(`${i + 1}.`)} ${display}${tag}`);
1367
1542
  }
@@ -1379,14 +1554,14 @@ function startAIAgent(ctx) {
1379
1554
  console.log(chalk2.red(" Usage: /folder add <path>"));
1380
1555
  return;
1381
1556
  }
1382
- const absPath = normalizePath(folderArg);
1557
+ const absPath = normalizePath2(folderArg);
1383
1558
  if (!ctx.addWatchFolder) {
1384
1559
  console.log(chalk2.red(" Watch folder management not available."));
1385
1560
  return;
1386
1561
  }
1387
1562
  const result = await ctx.addWatchFolder(absPath);
1388
1563
  if (result.added) {
1389
- const display = (result.path || absPath).startsWith(HOME) ? "~" + (result.path || absPath).slice(HOME.length) : result.path || absPath;
1564
+ const display = (result.path || absPath).startsWith(HOME2) ? "~" + (result.path || absPath).slice(HOME2.length) : result.path || absPath;
1390
1565
  console.log(chalk2.green(` Added: ${display}`));
1391
1566
  } else {
1392
1567
  console.log(chalk2.yellow(` ${result.error || "Could not add folder"}`));
@@ -1396,14 +1571,14 @@ function startAIAgent(ctx) {
1396
1571
  console.log(chalk2.red(" Usage: /folder remove <path>"));
1397
1572
  return;
1398
1573
  }
1399
- const absPath = normalizePath(folderArg);
1574
+ const absPath = normalizePath2(folderArg);
1400
1575
  if (!ctx.removeWatchFolder) {
1401
1576
  console.log(chalk2.red(" Watch folder management not available."));
1402
1577
  return;
1403
1578
  }
1404
1579
  const result = ctx.removeWatchFolder(absPath);
1405
1580
  if (result.removed) {
1406
- const display = absPath.startsWith(HOME) ? "~" + absPath.slice(HOME.length) : absPath;
1581
+ const display = absPath.startsWith(HOME2) ? "~" + absPath.slice(HOME2.length) : absPath;
1407
1582
  console.log(chalk2.green(` Removed: ${display}`));
1408
1583
  } else {
1409
1584
  console.log(chalk2.yellow(` ${result.error || "Could not remove folder"}`));
@@ -1413,14 +1588,14 @@ function startAIAgent(ctx) {
1413
1588
  console.log(chalk2.red(" Usage: /folder set <path>"));
1414
1589
  return;
1415
1590
  }
1416
- const absPath = normalizePath(folderArg);
1591
+ const absPath = normalizePath2(folderArg);
1417
1592
  if (!existsSync2(absPath) || !statSync(absPath).isDirectory()) {
1418
1593
  console.log(chalk2.red(` Not a valid directory: ${absPath}`));
1419
1594
  return;
1420
1595
  }
1421
1596
  ctx.config.watchFolder = absPath;
1422
1597
  saveConfig(ctx.config);
1423
- const display = absPath.startsWith(HOME) ? "~" + absPath.slice(HOME.length) : absPath;
1598
+ const display = absPath.startsWith(HOME2) ? "~" + absPath.slice(HOME2.length) : absPath;
1424
1599
  console.log(chalk2.green(` Primary folder set to: ${display}`));
1425
1600
  } else {
1426
1601
  console.log(chalk2.dim(" Usage: /folder [list|add|remove|set] <path>"));
@@ -1506,8 +1681,41 @@ function startAIAgent(ctx) {
1506
1681
  }
1507
1682
  };
1508
1683
  let currentSlashArgs = "";
1684
+ let lastPendingCount = 0;
1685
+ let onboardingDone = !ctx.onboarding;
1509
1686
  async function promptLoop() {
1687
+ if (!onboardingDone) {
1688
+ onboardingDone = true;
1689
+ history.push({ role: "user", content: "Hi, I just installed AnrakLegal Sync. Help me set up my watch folders." });
1690
+ try {
1691
+ const response = await agentTurn([...history], ctx);
1692
+ if (response) {
1693
+ history.push({ role: "assistant", content: response });
1694
+ }
1695
+ } catch (err) {
1696
+ const msg = err instanceof Error ? err.message : String(err);
1697
+ console.log(chalk2.red(` Error: ${msg}`));
1698
+ }
1699
+ console.log("");
1700
+ }
1510
1701
  while (true) {
1702
+ if (pendingFiles.length > 0 && pendingFiles.length !== lastPendingCount) {
1703
+ lastPendingCount = pendingFiles.length;
1704
+ console.log("");
1705
+ console.log(chalk2.yellow(` ${pendingFiles.length} file(s) need case mapping:`));
1706
+ for (const f of pendingFiles) {
1707
+ const name = basename4(f.filePath || "unknown");
1708
+ if (f.suggestion) {
1709
+ console.log(
1710
+ ` ${chalk2.cyan(name)} ${chalk2.dim("in")} ${f.folderName} ${chalk2.dim("\u2014 AI suggests:")} ${chalk2.green(f.suggestion.caseName)}`
1711
+ );
1712
+ } else {
1713
+ console.log(` ${chalk2.cyan(name)} ${chalk2.dim("in")} ${f.folderName} ${chalk2.dim("\u2014 no match found")}`);
1714
+ }
1715
+ }
1716
+ console.log(chalk2.dim(" Tell me which case, or say 'yes' to accept suggestions."));
1717
+ console.log("");
1718
+ }
1511
1719
  let input;
1512
1720
  try {
1513
1721
  input = await rl.question(PROMPT);
@@ -1558,6 +1766,7 @@ function startAIAgent(ctx) {
1558
1766
  while (history.length > 21) {
1559
1767
  history.splice(1, 1);
1560
1768
  }
1769
+ history[0] = { role: "system", content: buildSystemPrompt(ctx.config, false) };
1561
1770
  try {
1562
1771
  const response = await agentTurn([...history], ctx);
1563
1772
  if (response) {
@@ -1573,6 +1782,92 @@ function startAIAgent(ctx) {
1573
1782
  void promptLoop();
1574
1783
  }
1575
1784
 
1785
+ // src/smart-mapper.ts
1786
+ async function smartMapFolder(config, folderName, fileName, cases) {
1787
+ if (cases.length === 0) return null;
1788
+ let token;
1789
+ try {
1790
+ token = await getAccessToken(config);
1791
+ } catch {
1792
+ return null;
1793
+ }
1794
+ const caseList = cases.map((c, i) => `${i + 1}. ${c.caseNumber}: ${c.caseName}`).join("\n");
1795
+ const messages = [
1796
+ {
1797
+ role: "system",
1798
+ content: "You are a legal file organizer. Match files to legal cases. Respond ONLY with a JSON object. No markdown, no explanation outside JSON."
1799
+ },
1800
+ {
1801
+ role: "user",
1802
+ content: `Match this file to one of the cases below.
1803
+
1804
+ File: ${fileName}
1805
+ Folder: ${folderName}
1806
+
1807
+ Cases:
1808
+ ${caseList}
1809
+
1810
+ Respond with ONLY valid JSON:
1811
+ If a case matches: {"caseIndex": <number 1-${cases.length}>, "confidence": "high" or "low", "reason": "<10 words max>"}
1812
+ If no case matches: {"caseIndex": null, "confidence": "none", "reason": "<why>"}`
1813
+ }
1814
+ ];
1815
+ try {
1816
+ const response = await fetch(`${config.apiUrl}/api/sync/chat`, {
1817
+ method: "POST",
1818
+ headers: {
1819
+ Authorization: `Bearer ${token}`,
1820
+ "Content-Type": "application/json"
1821
+ },
1822
+ body: JSON.stringify({ messages }),
1823
+ signal: AbortSignal.timeout(15e3)
1824
+ });
1825
+ if (!response.ok) return null;
1826
+ let text = "";
1827
+ const reader = response.body.getReader();
1828
+ const decoder = new TextDecoder();
1829
+ while (true) {
1830
+ const { done, value } = await reader.read();
1831
+ if (done) break;
1832
+ const chunk = decoder.decode(value, { stream: true });
1833
+ for (const line of chunk.split("\n")) {
1834
+ const trimmed = line.trim();
1835
+ if (!trimmed.startsWith("data: ")) continue;
1836
+ const data = trimmed.slice(6);
1837
+ if (data === "[DONE]") continue;
1838
+ try {
1839
+ const parsed = JSON.parse(data);
1840
+ const content = parsed.choices?.[0]?.delta?.content;
1841
+ if (content) text += content;
1842
+ } catch {
1843
+ }
1844
+ }
1845
+ }
1846
+ text = text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
1847
+ const jsonMatch = text.match(/\{[\s\S]*?\}/);
1848
+ if (!jsonMatch) return null;
1849
+ const result = JSON.parse(jsonMatch[0]);
1850
+ if (result.caseIndex == null || result.confidence === "none") {
1851
+ return null;
1852
+ }
1853
+ const idx = result.caseIndex - 1;
1854
+ if (idx < 0 || idx >= cases.length) return null;
1855
+ const matched = cases[idx];
1856
+ return {
1857
+ caseId: matched.id,
1858
+ caseName: matched.caseName,
1859
+ caseNumber: matched.caseNumber,
1860
+ confidence: result.confidence === "high" ? "high" : "low",
1861
+ reason: result.reason || ""
1862
+ };
1863
+ } catch (err) {
1864
+ log.debug(
1865
+ `Smart mapper error: ${err instanceof Error ? err.message : String(err)}`
1866
+ );
1867
+ return null;
1868
+ }
1869
+ }
1870
+
1576
1871
  // src/watcher.ts
1577
1872
  async function scanFolder(config) {
1578
1873
  const folders = getWatchFolders(config);
@@ -1625,7 +1920,7 @@ async function pushSync(config) {
1625
1920
  );
1626
1921
  }
1627
1922
  async function scanExternalFolder(config, folderPath, cases) {
1628
- const absPath = resolve2(folderPath);
1923
+ const absPath = resolve3(folderPath);
1629
1924
  if (!existsSync3(absPath)) {
1630
1925
  log.error(`Folder not found: ${absPath}`);
1631
1926
  return;
@@ -1671,12 +1966,13 @@ async function scanExternalFolder(config, folderPath, cases) {
1671
1966
  log.success("Everything up to date");
1672
1967
  }
1673
1968
  }
1674
- async function startWatching(config) {
1969
+ async function startWatching(config, opts) {
1675
1970
  const folders = getWatchFolders(config);
1971
+ const smartMapper = async (folderName, fileName, caseList) => smartMapFolder(config, folderName, fileName, caseList);
1676
1972
  let cases = await listCases(config);
1677
1973
  const { scanned, queued } = await scanFolder(config);
1678
1974
  if (queued > 0) {
1679
- const result = await processQueue(config, cases);
1975
+ const result = await processQueue(config, cases, { smartMapper });
1680
1976
  if (result.failed > 0) {
1681
1977
  log.warn(`Initial sync: ${result.uploaded} uploaded, ${result.failed} failed`);
1682
1978
  }
@@ -1693,7 +1989,7 @@ async function startWatching(config) {
1693
1989
  if (debounceTimer) clearTimeout(debounceTimer);
1694
1990
  debounceTimer = setTimeout(async () => {
1695
1991
  if (queueSize() > 0) {
1696
- const result = await processQueue(config, cases);
1992
+ const result = await processQueue(config, cases, { smartMapper });
1697
1993
  if (result.uploaded > 0 || result.failed > 0) {
1698
1994
  log.info(
1699
1995
  `Batch: ${result.uploaded} uploaded, ${result.failed} failed`
@@ -1743,6 +2039,7 @@ async function startWatching(config) {
1743
2039
  startAIAgent({
1744
2040
  config,
1745
2041
  bootStats: { cases: cases.length, scanned, queued },
2042
+ onboarding: opts?.onboarding,
1746
2043
  getCases: () => cases,
1747
2044
  refreshCases: async () => {
1748
2045
  cases = await listCases(config);
@@ -1753,7 +2050,7 @@ async function startWatching(config) {
1753
2050
  const result = await scanFolder(config);
1754
2051
  log.info(`Scanned ${result.scanned} files, ${result.queued} need syncing`);
1755
2052
  if (result.queued > 0) {
1756
- const syncResult = await processQueue(config, cases);
2053
+ const syncResult = await processQueue(config, cases, { smartMapper });
1757
2054
  log.info(`Synced: ${syncResult.uploaded} uploaded, ${syncResult.failed} failed`);
1758
2055
  }
1759
2056
  },
@@ -1761,7 +2058,7 @@ async function startWatching(config) {
1761
2058
  await scanExternalFolder(config, folderPath, cases);
1762
2059
  },
1763
2060
  addWatchFolder: async (folderPath) => {
1764
- const absPath = resolve2(folderPath);
2061
+ const absPath = resolve3(folderPath);
1765
2062
  if (!existsSync3(absPath) || !statSync2(absPath).isDirectory()) {
1766
2063
  return { added: false, error: `Not a valid directory: ${absPath}` };
1767
2064
  }
@@ -1774,7 +2071,7 @@ async function startWatching(config) {
1774
2071
  return { added: true, path: absPath };
1775
2072
  },
1776
2073
  removeWatchFolder: (folderPath) => {
1777
- const absPath = resolve2(folderPath);
2074
+ const absPath = resolve3(folderPath);
1778
2075
  const result = removeWatchFolder(config, absPath);
1779
2076
  if (result.removed) {
1780
2077
  const w = watchers.get(absPath);
@@ -1875,13 +2172,34 @@ async function checkForUpdates(currentVersion) {
1875
2172
  }
1876
2173
 
1877
2174
  // src/cli.ts
1878
- import { readFileSync as readFileSync3 } from "fs";
1879
2175
  import { fileURLToPath as fileURLToPath2 } from "url";
1880
2176
  import { dirname as dirname3, join as join4 } from "path";
1881
2177
  var __filename3 = fileURLToPath2(import.meta.url);
1882
2178
  var __dirname3 = dirname3(__filename3);
1883
2179
  var pkg = JSON.parse(readFileSync3(join4(__dirname3, "..", "package.json"), "utf-8"));
1884
2180
  await checkForUpdates(pkg.version);
2181
+ var HOME3 = homedir3();
2182
+ var FOLDER_SHORTCUTS2 = {
2183
+ downloads: join4(HOME3, "Downloads"),
2184
+ download: join4(HOME3, "Downloads"),
2185
+ desktop: join4(HOME3, "Desktop"),
2186
+ documents: join4(HOME3, "Documents"),
2187
+ document: join4(HOME3, "Documents"),
2188
+ home: HOME3,
2189
+ "~": HOME3
2190
+ };
2191
+ function normalizeFolderPath(input) {
2192
+ const trimmed = input.trim();
2193
+ if (!trimmed) return trimmed;
2194
+ if (trimmed.startsWith("~/") || trimmed === "~") {
2195
+ return resolve4(trimmed.replace(/^~/, HOME3));
2196
+ }
2197
+ const lower = trimmed.toLowerCase();
2198
+ if (FOLDER_SHORTCUTS2[lower]) return FOLDER_SHORTCUTS2[lower];
2199
+ const withoutSlash = lower.replace(/^\//, "");
2200
+ if (FOLDER_SHORTCUTS2[withoutSlash]) return FOLDER_SHORTCUTS2[withoutSlash];
2201
+ return resolve4(trimmed);
2202
+ }
1885
2203
  var program = new Command();
1886
2204
  program.name("anrak-sync").description("AnrakLegal desktop file sync \u2014 watches local folders, syncs to case management").version(pkg.version);
1887
2205
  program.command("init").description("Set up AnrakLegal Sync (first-time configuration)").option("--password", "Use email/password login instead of browser").action(async (opts) => {
@@ -1919,37 +2237,25 @@ program.command("init").description("Set up AnrakLegal Sync (first-time configur
1919
2237
  tokens = await browserLogin(apiUrl);
1920
2238
  }
1921
2239
  log.success("Authenticated");
1922
- const rl2 = createInterface2({ input: stdin2, output: stdout2 });
1923
- const defaultFolder = process.platform === "win32" ? "C:\\Cases" : `${process.env.HOME}/Cases`;
1924
- const watchInput = await rl2.question(
1925
- ` Watch folder ${chalk4.dim(`(${defaultFolder})`)}: `
1926
- );
1927
- const watchFolder = resolve3(watchInput || defaultFolder);
1928
- rl2.close();
1929
- if (!existsSync4(watchFolder)) {
1930
- log.warn(
1931
- `Folder ${watchFolder} does not exist \u2014 it will be created when you add files`
1932
- );
1933
- } else if (!statSync3(watchFolder).isDirectory()) {
1934
- log.error(`${watchFolder} is not a directory`);
1935
- process.exit(1);
2240
+ try {
2241
+ rl.close();
2242
+ } catch {
1936
2243
  }
2244
+ const defaultFolder = normalizeFolderPath("downloads");
1937
2245
  const config = {
1938
2246
  apiUrl,
1939
2247
  supabaseUrl: serverConfig.supabaseUrl,
1940
2248
  supabaseAnonKey: serverConfig.supabaseAnonKey,
1941
2249
  accessToken: tokens.accessToken,
1942
2250
  refreshToken: tokens.refreshToken,
1943
- watchFolder
2251
+ watchFolder: defaultFolder
1944
2252
  };
1945
2253
  saveConfig(config);
1946
2254
  console.log("");
1947
2255
  log.success("Setup complete!");
1948
2256
  log.info(`Config saved to ${getConfigDir()}`);
1949
- log.info(`Watching: ${watchFolder}`);
1950
- console.log(
1951
- chalk4.dim("\n Run `anrak-sync start` to begin syncing\n")
1952
- );
2257
+ console.log("");
2258
+ await startWatching(config, { onboarding: true });
1953
2259
  } catch (err) {
1954
2260
  log.error(err instanceof Error ? err.message : String(err));
1955
2261
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anraktech/sync",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "AnrakLegal desktop file sync agent — watches local folders and syncs to case management",
5
5
  "type": "module",
6
6
  "bin": {