@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.
- package/dist/cli.js +252 -8
- 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
|
|
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
|
-
-
|
|
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
|
},
|