@anraktech/sync 0.2.1 → 0.4.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 +302 -131
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -418,9 +418,6 @@ function resetCache() {
418
418
  import { watch } from "chokidar";
419
419
  import { readdirSync, statSync, existsSync as existsSync2 } from "fs";
420
420
  import { join as join2, relative as relative2, basename as basename4, resolve } from "path";
421
- import { createInterface } from "readline/promises";
422
- import { stdin, stdout } from "process";
423
- import chalk2 from "chalk";
424
421
 
425
422
  // src/uploader.ts
426
423
  import { stat as stat2 } from "fs/promises";
@@ -619,6 +616,300 @@ function sleep(ms) {
619
616
  return new Promise((r) => setTimeout(r, ms));
620
617
  }
621
618
 
619
+ // src/agent.ts
620
+ import { createInterface } from "readline/promises";
621
+ import { stdin, stdout } from "process";
622
+ import chalk2 from "chalk";
623
+ var TOOLS = [
624
+ {
625
+ type: "function",
626
+ function: {
627
+ name: "scan_folder",
628
+ description: "Scan a local folder on the user's computer and sync all supported files (PDF, DOCX, etc.) to a matching legal case. Use when user asks to scan, sync, upload, or look at a specific folder.",
629
+ parameters: {
630
+ type: "object",
631
+ properties: {
632
+ folderPath: {
633
+ type: "string",
634
+ description: "Absolute path to the folder, e.g. /Users/name/Downloads"
635
+ }
636
+ },
637
+ required: ["folderPath"]
638
+ }
639
+ }
640
+ },
641
+ {
642
+ type: "function",
643
+ function: {
644
+ name: "list_cases",
645
+ description: "List all legal cases on the server with case numbers, names, and document counts.",
646
+ parameters: { type: "object", properties: {} }
647
+ }
648
+ },
649
+ {
650
+ type: "function",
651
+ function: {
652
+ name: "get_status",
653
+ description: "Show sync stats: files tracked, synced, pending, errors, queue size.",
654
+ parameters: { type: "object", properties: {} }
655
+ }
656
+ },
657
+ {
658
+ type: "function",
659
+ function: {
660
+ name: "get_mappings",
661
+ description: "Show which local folders are mapped to which legal cases.",
662
+ parameters: { type: "object", properties: {} }
663
+ }
664
+ },
665
+ {
666
+ type: "function",
667
+ function: {
668
+ name: "refresh_cases",
669
+ description: "Refresh the case list from the server.",
670
+ parameters: { type: "object", properties: {} }
671
+ }
672
+ },
673
+ {
674
+ type: "function",
675
+ function: {
676
+ name: "rescan_watch_folder",
677
+ description: "Re-scan the watch folder for new or changed files and sync them.",
678
+ parameters: { type: "object", properties: {} }
679
+ }
680
+ }
681
+ ];
682
+ function buildSystemPrompt(config) {
683
+ return `You are AnrakLegal Sync, a terminal assistant that helps lawyers sync local files to their case management system.
684
+
685
+ Watch folder: ${config.watchFolder}
686
+ Server: ${config.apiUrl}
687
+
688
+ You can scan folders, list cases, show sync status, and more using your tools.
689
+
690
+ Rules:
691
+ - Be concise. This is a terminal \u2014 1-3 lines unless showing a list.
692
+ - Do NOT use markdown. Plain text only.
693
+ - When user mentions a folder like "downloads" or "desktop", expand to full path (macOS: /Users/<name>/Downloads, Windows: C:\\Users\\<name>\\Downloads).
694
+ - Call tools to perform actions. Summarize results naturally after.
695
+ - Do NOT use thinking tags or reasoning tags in your output.`;
696
+ }
697
+ async function executeTool(name, args, ctx) {
698
+ switch (name) {
699
+ case "scan_folder": {
700
+ const folderPath = args.folderPath;
701
+ if (!folderPath) return JSON.stringify({ error: "Missing folderPath" });
702
+ try {
703
+ await ctx.scanFolder(folderPath);
704
+ const stats = getStats();
705
+ return JSON.stringify({
706
+ success: true,
707
+ folderPath,
708
+ totalFiles: stats.totalFiles,
709
+ synced: stats.synced,
710
+ pending: stats.pending,
711
+ errors: stats.errors
712
+ });
713
+ } catch (err) {
714
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
715
+ }
716
+ }
717
+ case "list_cases": {
718
+ await ctx.refreshCases();
719
+ const cases = ctx.getCases();
720
+ return JSON.stringify(
721
+ cases.map((c) => ({
722
+ caseNumber: c.caseNumber,
723
+ caseName: c.caseName,
724
+ status: c.status,
725
+ documents: c.documents?.length ?? 0
726
+ }))
727
+ );
728
+ }
729
+ case "get_status": {
730
+ const stats = getStats();
731
+ return JSON.stringify({ ...stats, queueSize: queueSize() });
732
+ }
733
+ case "get_mappings": {
734
+ const mappings = getAllMappings();
735
+ return JSON.stringify(
736
+ Object.entries(mappings).map(([folder, m]) => ({
737
+ folder,
738
+ caseNumber: m.caseNumber,
739
+ caseName: m.caseName
740
+ }))
741
+ );
742
+ }
743
+ case "refresh_cases": {
744
+ await ctx.refreshCases();
745
+ return JSON.stringify({ success: true, caseCount: ctx.getCases().length });
746
+ }
747
+ case "rescan_watch_folder": {
748
+ await ctx.triggerScan();
749
+ const stats = getStats();
750
+ return JSON.stringify({
751
+ success: true,
752
+ totalFiles: stats.totalFiles,
753
+ synced: stats.synced,
754
+ pending: stats.pending
755
+ });
756
+ }
757
+ default:
758
+ return JSON.stringify({ error: `Unknown tool: ${name}` });
759
+ }
760
+ }
761
+ async function* parseSSE(response) {
762
+ const reader = response.body.getReader();
763
+ const decoder = new TextDecoder();
764
+ let buffer = "";
765
+ while (true) {
766
+ const { done, value } = await reader.read();
767
+ if (done) break;
768
+ buffer += decoder.decode(value, { stream: true });
769
+ const lines = buffer.split("\n");
770
+ buffer = lines.pop();
771
+ for (const line of lines) {
772
+ const trimmed = line.trim();
773
+ if (!trimmed || !trimmed.startsWith("data: ")) continue;
774
+ const data = trimmed.slice(6);
775
+ if (data === "[DONE]") return;
776
+ try {
777
+ const parsed = JSON.parse(data);
778
+ const choice = parsed.choices?.[0];
779
+ if (choice?.delta) {
780
+ yield { delta: choice.delta, finishReason: choice.finish_reason ?? null };
781
+ }
782
+ } catch {
783
+ }
784
+ }
785
+ }
786
+ }
787
+ async function agentTurn(messages, ctx) {
788
+ const token = await getAccessToken(ctx.config);
789
+ const response = await fetch(`${ctx.config.apiUrl}/api/sync/chat`, {
790
+ method: "POST",
791
+ headers: {
792
+ Authorization: `Bearer ${token}`,
793
+ "Content-Type": "application/json"
794
+ },
795
+ body: JSON.stringify({ messages, tools: TOOLS })
796
+ });
797
+ if (!response.ok) {
798
+ const body = await response.text().catch(() => "");
799
+ throw new Error(`Server error (${response.status}): ${body.slice(0, 200)}`);
800
+ }
801
+ let textContent = "";
802
+ const toolCalls = /* @__PURE__ */ new Map();
803
+ let isFirstText = true;
804
+ for await (const { delta } of parseSSE(response)) {
805
+ if (delta.content) {
806
+ if (isFirstText) {
807
+ process.stdout.write(" ");
808
+ isFirstText = false;
809
+ }
810
+ const cleaned = delta.content.replace(/<\/?think>/g, "");
811
+ if (cleaned) {
812
+ process.stdout.write(cleaned);
813
+ textContent += cleaned;
814
+ }
815
+ }
816
+ if (delta.tool_calls) {
817
+ for (const tc of delta.tool_calls) {
818
+ const existing = toolCalls.get(tc.index);
819
+ if (existing) {
820
+ if (tc.function?.arguments) existing.args += tc.function.arguments;
821
+ } else {
822
+ toolCalls.set(tc.index, {
823
+ id: tc.id ?? `call_${tc.index}`,
824
+ name: tc.function?.name ?? "",
825
+ args: tc.function?.arguments ?? ""
826
+ });
827
+ }
828
+ }
829
+ }
830
+ }
831
+ if (textContent) {
832
+ process.stdout.write("\n");
833
+ }
834
+ if (toolCalls.size === 0) {
835
+ return textContent;
836
+ }
837
+ const assistantMsg = {
838
+ role: "assistant",
839
+ content: textContent || null,
840
+ tool_calls: Array.from(toolCalls.values()).map((tc) => ({
841
+ id: tc.id,
842
+ type: "function",
843
+ function: { name: tc.name, arguments: tc.args }
844
+ }))
845
+ };
846
+ messages.push(assistantMsg);
847
+ for (const tc of toolCalls.values()) {
848
+ process.stdout.write(chalk2.dim(` \u27F3 ${tc.name}...
849
+ `));
850
+ let args = {};
851
+ try {
852
+ args = JSON.parse(tc.args || "{}");
853
+ } catch {
854
+ }
855
+ const result = await executeTool(tc.name, args, ctx);
856
+ messages.push({ role: "tool", content: result, tool_call_id: tc.id });
857
+ }
858
+ return agentTurn(messages, ctx);
859
+ }
860
+ function startAIAgent(ctx) {
861
+ const history = [
862
+ { role: "system", content: buildSystemPrompt(ctx.config) }
863
+ ];
864
+ const rl = createInterface({ input: stdin, output: stdout });
865
+ console.log("");
866
+ console.log(chalk2.bold(" AnrakLegal Sync"));
867
+ console.log(chalk2.dim(` Watching ${ctx.config.watchFolder}`));
868
+ console.log(chalk2.dim(` Connected to ${ctx.config.apiUrl}`));
869
+ console.log("");
870
+ console.log(
871
+ chalk2.dim(" Ask me anything \u2014 scan folders, check cases, show status.")
872
+ );
873
+ console.log(chalk2.dim(' Type "quit" to exit.\n'));
874
+ const PROMPT = `${chalk2.bold.blue("\u276F")} `;
875
+ async function promptLoop() {
876
+ while (true) {
877
+ let input;
878
+ try {
879
+ input = await rl.question(PROMPT);
880
+ } catch {
881
+ break;
882
+ }
883
+ const trimmed = input.trim();
884
+ if (!trimmed) continue;
885
+ if (/^(quit|exit|q)$/i.test(trimmed)) {
886
+ process.emit("SIGINT");
887
+ return;
888
+ }
889
+ if (/^(clear|reset|new)$/i.test(trimmed)) {
890
+ history.length = 1;
891
+ console.log(chalk2.dim(" Conversation cleared.\n"));
892
+ continue;
893
+ }
894
+ history.push({ role: "user", content: trimmed });
895
+ while (history.length > 21) {
896
+ history.splice(1, 1);
897
+ }
898
+ try {
899
+ const response = await agentTurn([...history], ctx);
900
+ if (response) {
901
+ history.push({ role: "assistant", content: response });
902
+ }
903
+ } catch (err) {
904
+ const msg = err instanceof Error ? err.message : String(err);
905
+ console.log(chalk2.red(` Error: ${msg}`));
906
+ }
907
+ console.log("");
908
+ }
909
+ }
910
+ void promptLoop();
911
+ }
912
+
622
913
  // src/watcher.ts
623
914
  async function scanFolder(config) {
624
915
  const folder = config.watchFolder;
@@ -714,129 +1005,6 @@ async function scanExternalFolder(config, folderPath, cases) {
714
1005
  log.success("Everything up to date");
715
1006
  }
716
1007
  }
717
- function startInteractiveAgent(config, getCases, refreshCases, triggerScan) {
718
- const PROMPT = chalk2.blue("anrak> ");
719
- const rl = createInterface({ input: stdin, output: stdout });
720
- function showHelp() {
721
- console.log("");
722
- console.log(chalk2.bold(" Available commands:"));
723
- console.log("");
724
- console.log(` ${chalk2.cyan("scan <folder>")} Scan a folder and sync its files to a case`);
725
- console.log(` ${chalk2.cyan("rescan")} Re-scan the watch folder for new files`);
726
- console.log(` ${chalk2.cyan("cases")} List cases on the server`);
727
- console.log(` ${chalk2.cyan("status")} Show sync statistics`);
728
- console.log(` ${chalk2.cyan("mappings")} Show folder \u2192 case mappings`);
729
- console.log(` ${chalk2.cyan("refresh")} Refresh cases from server`);
730
- console.log(` ${chalk2.cyan("help")} Show this help`);
731
- console.log(` ${chalk2.cyan("quit")} Stop syncing and exit`);
732
- console.log("");
733
- }
734
- async function handleCommand(input) {
735
- const trimmed = input.trim();
736
- if (!trimmed) return;
737
- const [cmd, ...args] = trimmed.split(/\s+/);
738
- const arg = args.join(" ");
739
- switch (cmd.toLowerCase()) {
740
- case "scan": {
741
- if (!arg) {
742
- log.warn("Usage: scan <folder path>");
743
- break;
744
- }
745
- await scanExternalFolder(config, arg, getCases());
746
- break;
747
- }
748
- case "rescan": {
749
- await triggerScan();
750
- break;
751
- }
752
- case "cases": {
753
- await refreshCases();
754
- const cases = getCases();
755
- if (cases.length === 0) {
756
- log.info("No cases found on server");
757
- } else {
758
- console.log("");
759
- for (const c of cases) {
760
- const docCount = c.documents?.length ?? 0;
761
- console.log(
762
- ` ${chalk2.cyan(c.caseNumber)} ${c.caseName} ${chalk2.dim(`(${docCount} docs)`)}`
763
- );
764
- }
765
- console.log("");
766
- }
767
- break;
768
- }
769
- case "status": {
770
- const stats = getStats();
771
- console.log("");
772
- console.log(` ${chalk2.bold("Watch folder:")} ${config.watchFolder}`);
773
- console.log(` ${chalk2.bold("Files tracked:")} ${stats.totalFiles}`);
774
- console.log(` ${chalk2.bold("Synced:")} ${chalk2.green(String(stats.synced))}`);
775
- console.log(` ${chalk2.bold("Pending:")} ${chalk2.yellow(String(stats.pending))}`);
776
- console.log(` ${chalk2.bold("Errors:")} ${chalk2.red(String(stats.errors))}`);
777
- console.log(` ${chalk2.bold("Mapped folders:")} ${stats.mappedFolders}`);
778
- console.log(` ${chalk2.bold("Queue:")} ${queueSize()}`);
779
- console.log("");
780
- break;
781
- }
782
- case "mappings":
783
- case "map": {
784
- const mappings = getAllMappings();
785
- const entries = Object.entries(mappings);
786
- if (entries.length === 0) {
787
- log.info("No folder mappings yet");
788
- } else {
789
- console.log("");
790
- for (const [folder, m] of entries) {
791
- console.log(
792
- ` ${chalk2.cyan(folder)} \u2192 ${m.caseNumber} ${chalk2.dim(`(${m.caseName})`)}`
793
- );
794
- }
795
- console.log("");
796
- }
797
- break;
798
- }
799
- case "refresh": {
800
- log.info("Refreshing cases from server...");
801
- await refreshCases();
802
- log.success(`Found ${getCases().length} case(s)`);
803
- break;
804
- }
805
- case "help":
806
- case "?": {
807
- showHelp();
808
- break;
809
- }
810
- case "quit":
811
- case "exit":
812
- case "q": {
813
- process.emit("SIGINT");
814
- return;
815
- }
816
- default: {
817
- log.warn(`Unknown command: ${cmd}. Type ${chalk2.cyan("help")} for available commands.`);
818
- break;
819
- }
820
- }
821
- }
822
- showHelp();
823
- async function promptLoop() {
824
- while (true) {
825
- let input;
826
- try {
827
- input = await rl.question(PROMPT);
828
- } catch {
829
- break;
830
- }
831
- try {
832
- await handleCommand(input);
833
- } catch (err) {
834
- log.error(err instanceof Error ? err.message : String(err));
835
- }
836
- }
837
- }
838
- void promptLoop();
839
- }
840
1008
  async function startWatching(config) {
841
1009
  const folder = config.watchFolder;
842
1010
  log.info(`Scanning ${folder}...`);
@@ -904,13 +1072,13 @@ async function startWatching(config) {
904
1072
  watcher.on("error", (err) => {
905
1073
  log.error(`Watcher error: ${err instanceof Error ? err.message : String(err)}`);
906
1074
  });
907
- startInteractiveAgent(
1075
+ startAIAgent({
908
1076
  config,
909
- () => cases,
910
- async () => {
1077
+ getCases: () => cases,
1078
+ refreshCases: async () => {
911
1079
  cases = await listCases(config);
912
1080
  },
913
- async () => {
1081
+ triggerScan: async () => {
914
1082
  log.info(`Re-scanning ${folder}...`);
915
1083
  const result = await scanFolder(config);
916
1084
  log.info(`Scanned ${result.scanned} files, ${result.queued} need syncing`);
@@ -918,8 +1086,11 @@ async function startWatching(config) {
918
1086
  const syncResult = await processQueue(config, cases);
919
1087
  log.info(`Synced: ${syncResult.uploaded} uploaded, ${syncResult.failed} failed`);
920
1088
  }
1089
+ },
1090
+ scanFolder: async (folderPath) => {
1091
+ await scanExternalFolder(config, folderPath, cases);
921
1092
  }
922
- );
1093
+ });
923
1094
  const shutdown = () => {
924
1095
  log.info("Shutting down...");
925
1096
  clearInterval(refreshInterval);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anraktech/sync",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "AnrakLegal desktop file sync agent — watches local folders and syncs to case management",
5
5
  "type": "module",
6
6
  "bin": {