quilltap 4.8.0-dev.146 → 4.8.0-dev.147

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.
@@ -84,9 +84,9 @@ Write subcommands (server required for database-backed mounts):
84
84
  write [--force] [--base64] <mount> <path> [file] Write a file from <file> or stdin
85
85
  delete <mount> <path> Idempotent file delete
86
86
  mkdir <mount> <path> Idempotent folder create
87
- move <srcMount> <srcPath> <dstMount> <dstPath> Move file (hard-link when possible)
88
- copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (hard-link unless --force)
89
- link <srcMount> <srcPath> <dstMount> <dstPath> Hard-link file (server-required)
87
+ move <srcMount> <srcPath> <dstMount> <dstPath> Move file (relocates the link; no byte copy)
88
+ copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (independent; shares bytes until either side is written)
89
+ link <srcMount> <srcPath> <dstMount> <dstPath> Hard-link file — one file, two paths; edits show at both (server-required)
90
90
  rmdir <mount> <path> Delete an empty folder (server-required)
91
91
  mvdir <mount> <fromPath> <toPath> Rename/move a folder (server-required)
92
92
 
@@ -117,14 +117,17 @@ Options:
117
117
  file size, or hard-link count
118
118
  -r, --reverse Reverse sort order
119
119
  --links For 'ls' / 'dir': under each file with more than
120
- one hard link, list the other mount/path entries
120
+ one hard link, list the other mount/path entries.
121
+ Counts deliberate links made with 'docs link' — not
122
+ unrelated files that merely share identical bytes
121
123
  --depth N For 'tree': maximum nesting depth (default: 20)
122
124
  --max-nodes N For 'tree': maximum nodes to render (default: 1000)
123
125
  --long For 'tree': include text/emb columns (reserved for future)
124
126
  --force For 'read': dump binary to TTY anyway
125
127
  For 'write': overwrite existing destination
126
128
  For 'copy': overwrite + force a real byte copy
127
- (skips the default hard-link path)
129
+ (skips the default shared-content path;
130
+ the end state is the same either way)
128
131
  --base64 For 'write': send content as base64 JSON via PUT
129
132
  .../files/{path} (portable path used
130
133
  by the file browser; server-required)
@@ -624,21 +627,51 @@ function formatLsDate(iso) {
624
627
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
625
628
  }
626
629
 
627
- const LS_FILE_COLUMNS = `
630
+ // Cached per process: one PRAGMA per run, not one per prepared statement.
631
+ let linkGroupColumnPresent = null;
632
+
633
+ function hasLinkGroupColumn(db) {
634
+ if (linkGroupColumnPresent !== null) return linkGroupColumnPresent;
635
+ try {
636
+ const cols = db.prepare(`PRAGMA table_info("doc_mount_file_links")`).all();
637
+ linkGroupColumnPresent = cols.some((c) => c.name === 'linkGroupId');
638
+ } catch {
639
+ linkGroupColumnPresent = false;
640
+ }
641
+ return linkGroupColumnPresent;
642
+ }
643
+
644
+ // The "links" column counts deliberate hard links — members of this file's
645
+ // linkGroupId — NOT rows sharing a fileId. Content rows are addressed by
646
+ // sha256, so a boilerplate or empty file collects dozens of unrelated links
647
+ // that share its bytes purely by coincidence; reporting those as links told
648
+ // the operator a file was linked into 36 stores when nothing had been linked
649
+ // at all. An instance that hasn't run the linkGroupId migration yet degrades
650
+ // to 1 rather than failing the whole listing.
651
+ function lsFileColumns(db) {
652
+ const present = hasLinkGroupColumn(db);
653
+ const linkCount = present
654
+ ? `(CASE WHEN l.linkGroupId IS NULL THEN 1 ELSE
655
+ (SELECT COUNT(*) FROM doc_mount_file_links g WHERE g.linkGroupId = l.linkGroupId) END)`
656
+ : `1`;
657
+ const linkGroupId = present ? `l.linkGroupId` : `NULL`;
658
+ return `
659
+ ${linkGroupId} AS linkGroupId,
628
660
  l.id AS linkId, l.fileId, l.relativePath, l.fileName, l.lastModified,
629
661
  l.extractionStatus, l.extractedTextSha256, l.chunkCount,
630
662
  f.fileType, f.fileSizeBytes, f.source, f.sha256,
631
- (SELECT COUNT(*) FROM doc_mount_file_links WHERE fileId = l.fileId) AS linkCount,
663
+ ${linkCount} AS linkCount,
632
664
  (SELECT COUNT(*) FROM doc_mount_chunks
633
665
  WHERE linkId = l.id AND embedding IS NOT NULL) AS embeddedChunkCount
634
666
  `;
667
+ }
635
668
 
636
669
  function resolveLsTarget(db, mountId, normalizedPath) {
637
670
  if (!normalizedPath) return { kind: 'root', path: '' };
638
671
 
639
672
  // Exact file match wins — handles the single-file display mode.
640
673
  const file = db.prepare(`
641
- SELECT ${LS_FILE_COLUMNS}
674
+ SELECT ${lsFileColumns(db)}
642
675
  FROM doc_mount_file_links l
643
676
  JOIN doc_mount_files f ON f.id = l.fileId
644
677
  WHERE l.mountPointId = ? AND l.relativePath = ?
@@ -692,7 +725,7 @@ function fetchLsRows(db, mountId, parentPath) {
692
725
 
693
726
  const files = parentPath === ''
694
727
  ? db.prepare(`
695
- SELECT ${LS_FILE_COLUMNS}
728
+ SELECT ${lsFileColumns(db)}
696
729
  FROM doc_mount_file_links l
697
730
  JOIN doc_mount_files f ON f.id = l.fileId
698
731
  WHERE l.mountPointId = ?
@@ -700,7 +733,7 @@ function fetchLsRows(db, mountId, parentPath) {
700
733
  ORDER BY l.fileName COLLATE NOCASE
701
734
  `).all(mountId)
702
735
  : db.prepare(`
703
- SELECT ${LS_FILE_COLUMNS}
736
+ SELECT ${lsFileColumns(db)}
704
737
  FROM doc_mount_file_links l
705
738
  JOIN doc_mount_files f ON f.id = l.fileId
706
739
  WHERE l.mountPointId = ?
@@ -712,26 +745,29 @@ function fetchLsRows(db, mountId, parentPath) {
712
745
  return { folders, files };
713
746
  }
714
747
 
715
- function fetchLinksForFiles(db, fileIds) {
716
- if (fileIds.length === 0) return new Map();
717
- const placeholders = fileIds.map(() => '?').join(',');
748
+ // Members of each named hard-link group, keyed by linkGroupId. Grouping is by
749
+ // linkGroupId rather than fileId on purpose — see lsFileColumns: a shared
750
+ // fileId only means "identical bytes", which is not a link.
751
+ function fetchLinkGroupMembers(db, groupIds) {
752
+ if (groupIds.length === 0) return new Map();
753
+ const placeholders = groupIds.map(() => '?').join(',');
718
754
  const rows = db.prepare(`
719
- SELECT l.fileId, l.relativePath, l.mountPointId, m.name AS mountName
755
+ SELECT l.linkGroupId, l.relativePath, l.mountPointId, m.name AS mountName
720
756
  FROM doc_mount_file_links l
721
757
  JOIN doc_mount_points m ON m.id = l.mountPointId
722
- WHERE l.fileId IN (${placeholders})
758
+ WHERE l.linkGroupId IN (${placeholders})
723
759
  ORDER BY m.name COLLATE NOCASE, l.relativePath COLLATE NOCASE
724
- `).all(...fileIds);
725
- const byFile = new Map();
760
+ `).all(...groupIds);
761
+ const byGroup = new Map();
726
762
  for (const r of rows) {
727
- if (!byFile.has(r.fileId)) byFile.set(r.fileId, []);
728
- byFile.get(r.fileId).push({
763
+ if (!byGroup.has(r.linkGroupId)) byGroup.set(r.linkGroupId, []);
764
+ byGroup.get(r.linkGroupId).push({
729
765
  mountPointId: r.mountPointId,
730
766
  mountName: r.mountName,
731
767
  relativePath: r.relativePath,
732
768
  });
733
769
  }
734
- return byFile;
770
+ return byGroup;
735
771
  }
736
772
 
737
773
  function sortLsFiles(files, sortType, reverse) {
@@ -780,14 +816,14 @@ async function handleLs(flags, mountSpec, rawPath) {
780
816
  const prefix = normalizedPath ? normalizedPath.replace(/\/+$/, '') + '/' : '';
781
817
  const allFiles = normalizedPath
782
818
  ? db.prepare(`
783
- SELECT ${LS_FILE_COLUMNS}
819
+ SELECT ${lsFileColumns(db)}
784
820
  FROM doc_mount_file_links l
785
821
  JOIN doc_mount_files f ON f.id = l.fileId
786
822
  WHERE l.mountPointId = ? AND l.relativePath LIKE ?
787
823
  ORDER BY l.relativePath
788
824
  `).all(mount.id, prefix + '%')
789
825
  : db.prepare(`
790
- SELECT ${LS_FILE_COLUMNS}
826
+ SELECT ${lsFileColumns(db)}
791
827
  FROM doc_mount_file_links l
792
828
  JOIN doc_mount_files f ON f.id = l.fileId
793
829
  WHERE l.mountPointId = ?
@@ -824,10 +860,10 @@ async function handleLs(flags, mountSpec, rawPath) {
824
860
 
825
861
  // Fetch links for JSON or --links flag
826
862
  const wantLinks = flags.json || flags.links;
827
- const multiLinkFileIds = wantLinks
828
- ? files.filter((f) => f.linkCount > 1).map((f) => f.fileId)
863
+ const linkedGroupIds = wantLinks
864
+ ? files.filter((f) => f.linkCount > 1 && f.linkGroupId).map((f) => f.linkGroupId)
829
865
  : [];
830
- const linksByFile = fetchLinksForFiles(db, multiLinkFileIds);
866
+ const linksByGroup = fetchLinkGroupMembers(db, linkedGroupIds);
831
867
 
832
868
  // JSON output
833
869
  if (flags.json) {
@@ -846,7 +882,7 @@ async function handleLs(flags, mountSpec, rawPath) {
846
882
  }
847
883
  }
848
884
  for (const file of files) {
849
- const others = linksByFile.get(file.fileId);
885
+ const others = file.linkGroupId ? linksByGroup.get(file.linkGroupId) : undefined;
850
886
  const links = others && others.length > 0
851
887
  ? others
852
888
  : [{
@@ -951,6 +987,7 @@ async function handleLs(flags, mountSpec, rawPath) {
951
987
  emb: embedColumnMarker(file.chunkCount, file.embeddedChunkCount),
952
988
  name: singleFile ? file.relativePath : file.fileName,
953
989
  fileId: file.fileId,
990
+ linkGroupId: file.linkGroupId,
954
991
  relativePath: file.relativePath,
955
992
  });
956
993
  }
@@ -982,7 +1019,7 @@ async function handleLs(flags, mountSpec, rawPath) {
982
1019
  for (const r of dataRows) {
983
1020
  console.log(renderLine(r, false));
984
1021
  if (flags.links && r.type === '-') {
985
- const others = (linksByFile.get(r.fileId) || []).filter(
1022
+ const others = ((r.linkGroupId && linksByGroup.get(r.linkGroupId)) || []).filter(
986
1023
  (l) => !(l.mountPointId === mount.id && l.relativePath === r.relativePath)
987
1024
  );
988
1025
  if (others.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.8.0-dev.146",
3
+ "version": "4.8.0-dev.147",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",