quilltap 4.8.0-dev.97 → 4.8.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/README.md +2 -0
- package/bin/quilltap.js +14 -0
- package/lib/__tests__/completion-coverage.test.js +35 -0
- package/lib/__tests__/docker-mounts.test.js +216 -0
- package/lib/completion/bash.template +14 -1
- package/lib/completion/fish.template +20 -0
- package/lib/completion/zsh.template +36 -0
- package/lib/db-commands.js +268 -3
- package/lib/docker-mounts.js +201 -0
- package/lib/docs-commands.js +165 -27
- package/lib/memories-commands.js +6 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -187,6 +187,7 @@ quilltap docs status # Per-mount extraction + embeddi
|
|
|
187
187
|
quilltap docs scan <mount> # Trigger a rescan
|
|
188
188
|
quilltap docs reindex <mount> [path] [--force] # Re-extract + re-chunk
|
|
189
189
|
quilltap docs embed <mount> [path] [--force] [--wait] # Enqueue embedding jobs
|
|
190
|
+
quilltap docs grep --semantic [--top N] [--threshold 0..1] <query> # Embedding search over indexed chunks
|
|
190
191
|
quilltap docs write [--force] [--base64] <mount> <path> [file] # Stdin or file → mount
|
|
191
192
|
quilltap docs read [--rendered] [--base64] <mount> <path> # File contents → stdout
|
|
192
193
|
quilltap docs delete <mount> <path> # Idempotent delete
|
|
@@ -226,6 +227,7 @@ quilltap memories grep -i --max 3 --context 1 "concrete examples" # Pattern
|
|
|
226
227
|
quilltap memories show <id|prefix> [--depth N] [--no-related] # Full record + related-memory neighbourhood
|
|
227
228
|
quilltap memories tree <id|prefix> [--depth N] [--max-nodes N] # ASCII walk of the bidirectional related-memory graph
|
|
228
229
|
quilltap memories status [--character <name|id>] # Per-holder rollup + dangling-edge check
|
|
230
|
+
quilltap memories grep --semantic --character Ariadne "the argument" # Embedding search (server required, one holder)
|
|
229
231
|
```
|
|
230
232
|
|
|
231
233
|
Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source`, `--chat` (with `none` for manual entries), `--project`, `--since`, `--until`, `--min-importance`, `--min-reinforced`, `--has-embedding` / `--no-embedding`. Sort flags (`--sort reinforced|importance|created|accessed|reinforcement-count|links`, plus `-r` to reverse) apply to `ls`, `find`, and `grep`. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. The legacy `quilltap db memories --character <name>` verb remains undisturbed.
|
package/bin/quilltap.js
CHANGED
|
@@ -717,6 +717,20 @@ Subcommands (high-level shortcuts; auto-pick the right database):
|
|
|
717
717
|
Prompts/ and Scenarios/ folder counts, and any
|
|
718
718
|
divergence between DB columns and vault content.
|
|
719
719
|
(flags: --id <id|name> --diverged --blocked --limit N)
|
|
720
|
+
characters archives List archived characters and ARCHIVE bundles on
|
|
721
|
+
the shelf, loose bundles included. Read-only.
|
|
722
|
+
characters archive <name|id> --write [--port N]
|
|
723
|
+
Archive a character via the RUNNING server (it
|
|
724
|
+
holds the export pipeline and the passphrase).
|
|
725
|
+
characters rehydrate <name|id> --write [--port N]
|
|
726
|
+
Wake an archived character via the running server.
|
|
727
|
+
characters export <name|id> [--out <path>] [--port N]
|
|
728
|
+
Write a PLAINTEXT .qtap for a character. Archived:
|
|
729
|
+
decrypts the bundle offline (prompts for the
|
|
730
|
+
passphrase on protected instances) — the only way
|
|
731
|
+
to reach packed-away mail/photos/summaries without
|
|
732
|
+
rehydrating. Live: runs the server's export
|
|
733
|
+
pipeline (server must be up). Read-only.
|
|
720
734
|
optimize [target...] Run maintenance (VACUUM + ANALYZE + PRAGMA optimize)
|
|
721
735
|
on the named databases, or all of them if no
|
|
722
736
|
target is given. Targets: main, llm-logs,
|
|
@@ -59,3 +59,38 @@ describe('CLI subcommand surface stays documented', () => {
|
|
|
59
59
|
expect(missing).toEqual([]);
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A bare substring match is too weak: a subcommand named only in a shared
|
|
65
|
+
* `case` list still tab-completes its own flags as nothing. These check that
|
|
66
|
+
* each shell actually has a per-subcommand completion arm — the gap that let
|
|
67
|
+
* `file-verify` (all three shells) and `recall-replay` (fish) ship with the
|
|
68
|
+
* verb completing but none of its flags.
|
|
69
|
+
*/
|
|
70
|
+
describe('every subcommand has its own completion arm', () => {
|
|
71
|
+
function template(shell) {
|
|
72
|
+
return fs.readFileSync(path.join(COMPLETION_DIR, `${shell}.template`), 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it('bash has a case arm per subcommand', () => {
|
|
76
|
+
const tpl = template('bash');
|
|
77
|
+
const missing = SUBCOMMANDS.filter((sub) => !new RegExp(`^\\s*${sub}\\)\\s*$`, 'm').test(tpl));
|
|
78
|
+
expect(missing).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('zsh dispatches every subcommand from _quilltap_subcommand', () => {
|
|
82
|
+
const tpl = template('zsh');
|
|
83
|
+
const body = tpl.match(/_quilltap_subcommand\(\) \{([\s\S]*?)\n\}/);
|
|
84
|
+
expect(body).toBeTruthy();
|
|
85
|
+
const missing = SUBCOMMANDS.filter((sub) => !new RegExp(`^\\s*${sub}\\)\\s*$`, 'm').test(body[1]));
|
|
86
|
+
expect(missing).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('fish offers every subcommand at top level and completes inside it', () => {
|
|
90
|
+
const tpl = template('fish');
|
|
91
|
+
const notOffered = SUBCOMMANDS.filter((sub) => !tpl.includes(`-a '${sub}'`));
|
|
92
|
+
expect(notOffered).toEqual([]);
|
|
93
|
+
const noFlags = SUBCOMMANDS.filter((sub) => !tpl.includes(`__quilltap_using_subcommand ${sub}'`));
|
|
94
|
+
expect(noFlags).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind planning for filesystem-backed document stores.
|
|
3
|
+
*
|
|
4
|
+
* The planner decides what a container is allowed to see, so its edges matter
|
|
5
|
+
* more than its happy path: a nested store that shadows its parent, a missing
|
|
6
|
+
* path that Docker would helpfully materialise as an empty directory, or a
|
|
7
|
+
* Windows host where the whole path-identical scheme cannot work.
|
|
8
|
+
*
|
|
9
|
+
* @jest-environment node
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
planStoreMounts,
|
|
16
|
+
toDockerArgs,
|
|
17
|
+
findMissingBinds,
|
|
18
|
+
normalisePath,
|
|
19
|
+
} = require('../docker-mounts');
|
|
20
|
+
|
|
21
|
+
/** Build a doc_mount_points-shaped row with sensible defaults. */
|
|
22
|
+
function store(overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
id: 'id-' + (overrides.name || 'x'),
|
|
25
|
+
name: 'Store',
|
|
26
|
+
mountType: 'filesystem',
|
|
27
|
+
storeType: 'documents',
|
|
28
|
+
basePath: '/vaults/one',
|
|
29
|
+
enabled: 1,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A path probe that treats the given list as the only existing directories. */
|
|
35
|
+
const existsIn = (paths) => (p) => paths.includes(p);
|
|
36
|
+
|
|
37
|
+
const linux = (rows, exists) => planStoreMounts(rows, { platform: 'linux', exists });
|
|
38
|
+
const macos = (rows, exists) => planStoreMounts(rows, { platform: 'darwin', exists });
|
|
39
|
+
|
|
40
|
+
describe('planStoreMounts', () => {
|
|
41
|
+
it('binds an enabled filesystem store at its own host path', () => {
|
|
42
|
+
const plan = linux([store({ name: 'Church', basePath: '/vaults/church' })], existsIn(['/vaults/church']));
|
|
43
|
+
|
|
44
|
+
expect(plan.binds).toEqual([
|
|
45
|
+
{ hostPath: '/vaults/church', containerPath: '/vaults/church', stores: ['Church'] },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('treats obsidian stores as filesystem-backed', () => {
|
|
50
|
+
const plan = linux(
|
|
51
|
+
[store({ name: 'Malory', mountType: 'obsidian', basePath: '/vaults/malory' })],
|
|
52
|
+
existsIn(['/vaults/malory'])
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(plan.binds.map((b) => b.hostPath)).toEqual(['/vaults/malory']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('ignores database-backed and disabled stores', () => {
|
|
59
|
+
const plan = linux(
|
|
60
|
+
[
|
|
61
|
+
store({ name: 'Vault', mountType: 'database', basePath: '' }),
|
|
62
|
+
store({ name: 'Off', basePath: '/vaults/off', enabled: 0 }),
|
|
63
|
+
],
|
|
64
|
+
existsIn(['/vaults/off'])
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(plan.binds).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('emits one bind for several stores sharing a path', () => {
|
|
71
|
+
const plan = linux(
|
|
72
|
+
[
|
|
73
|
+
store({ name: 'Church', basePath: '/vaults/shared' }),
|
|
74
|
+
store({ name: 'Small Group', basePath: '/vaults/shared/' }),
|
|
75
|
+
],
|
|
76
|
+
existsIn(['/vaults/shared'])
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(plan.binds).toHaveLength(1);
|
|
80
|
+
expect(plan.binds[0].stores).toEqual(['Church', 'Small Group']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('drops a store nested inside another bound store', () => {
|
|
84
|
+
// Binding both would mount the child independently and shadow the
|
|
85
|
+
// parent's view of that subdirectory.
|
|
86
|
+
const plan = linux(
|
|
87
|
+
[
|
|
88
|
+
store({ name: 'Vault', basePath: '/vaults/obsidian' }),
|
|
89
|
+
store({ name: 'Notes', basePath: '/vaults/obsidian/notes' }),
|
|
90
|
+
],
|
|
91
|
+
existsIn(['/vaults/obsidian', '/vaults/obsidian/notes'])
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(plan.binds.map((b) => b.hostPath)).toEqual(['/vaults/obsidian']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not treat a sibling with a shared prefix as nested', () => {
|
|
98
|
+
const plan = linux(
|
|
99
|
+
[
|
|
100
|
+
store({ name: 'A', basePath: '/vaults/notes' }),
|
|
101
|
+
store({ name: 'B', basePath: '/vaults/notes-archive' }),
|
|
102
|
+
],
|
|
103
|
+
existsIn(['/vaults/notes', '/vaults/notes-archive'])
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(plan.binds.map((b) => b.hostPath)).toEqual(['/vaults/notes', '/vaults/notes-archive']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('skips a missing path rather than letting Docker fabricate it', () => {
|
|
110
|
+
const plan = linux([store({ name: 'Gone', basePath: '/vaults/gone' })], existsIn([]));
|
|
111
|
+
|
|
112
|
+
expect(plan.binds).toEqual([]);
|
|
113
|
+
expect(plan.skipped).toEqual([
|
|
114
|
+
{ hostPath: '/vaults/gone', stores: ['Gone'], reason: 'missing' },
|
|
115
|
+
]);
|
|
116
|
+
expect(plan.warnings.join(' ')).toContain('does not exist');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects a relative base path', () => {
|
|
120
|
+
const plan = linux([store({ name: 'Rel', basePath: 'relative/path' })], existsIn(['relative/path']));
|
|
121
|
+
|
|
122
|
+
expect(plan.binds).toEqual([]);
|
|
123
|
+
expect(plan.warnings.join(' ')).toContain('not absolute');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('refuses path-identical binds on Windows', () => {
|
|
127
|
+
const plan = planStoreMounts([store({ basePath: 'C:\\Users\\me\\Vault' })], {
|
|
128
|
+
platform: 'win32',
|
|
129
|
+
exists: () => true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(plan.unsupported).toBe(true);
|
|
133
|
+
expect(plan.binds).toEqual([]);
|
|
134
|
+
expect(plan.warnings.join(' ')).toContain('not supported on Windows');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('warns about macOS paths outside Docker Desktop default shares', () => {
|
|
138
|
+
const plan = macos([store({ name: 'Odd', basePath: '/data/vault' })], existsIn(['/data/vault']));
|
|
139
|
+
|
|
140
|
+
expect(plan.binds).toHaveLength(1);
|
|
141
|
+
expect(plan.warnings.join(' ')).toContain('File sharing');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not warn for macOS paths under a shared prefix', () => {
|
|
145
|
+
const plan = macos([store({ name: 'Home', basePath: '/Users/me/Vault' })], existsIn(['/Users/me/Vault']));
|
|
146
|
+
|
|
147
|
+
expect(plan.warnings.join(' ')).not.toContain('File sharing');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('warns about host ownership on Linux', () => {
|
|
151
|
+
const plan = linux([store({ basePath: '/vaults/one' })], existsIn(['/vaults/one']));
|
|
152
|
+
|
|
153
|
+
expect(plan.warnings.join(' ')).toContain('--user');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('toDockerArgs', () => {
|
|
158
|
+
it('renders each bind as a -v pair', () => {
|
|
159
|
+
const plan = linux(
|
|
160
|
+
[
|
|
161
|
+
store({ name: 'A', basePath: '/vaults/a' }),
|
|
162
|
+
store({ name: 'B', basePath: '/vaults/b' }),
|
|
163
|
+
],
|
|
164
|
+
existsIn(['/vaults/a', '/vaults/b'])
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(toDockerArgs(plan)).toEqual([
|
|
168
|
+
'-v', '/vaults/a:/vaults/a',
|
|
169
|
+
'-v', '/vaults/b:/vaults/b',
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('keeps a path containing spaces in a single argument', () => {
|
|
174
|
+
// The argv is handed to execFileSync, so a space must not split the pair.
|
|
175
|
+
const plan = linux([store({ basePath: '/vaults/Local Obsidian' })], existsIn(['/vaults/Local Obsidian']));
|
|
176
|
+
|
|
177
|
+
expect(toDockerArgs(plan)).toEqual([
|
|
178
|
+
'-v', '/vaults/Local Obsidian:/vaults/Local Obsidian',
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('findMissingBinds', () => {
|
|
184
|
+
const plan = () =>
|
|
185
|
+
linux(
|
|
186
|
+
[
|
|
187
|
+
store({ name: 'A', basePath: '/vaults/a' }),
|
|
188
|
+
store({ name: 'B', basePath: '/vaults/b' }),
|
|
189
|
+
],
|
|
190
|
+
existsIn(['/vaults/a', '/vaults/b'])
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
it('reports binds a container was not created with', () => {
|
|
194
|
+
expect(findMissingBinds(plan(), ['/vaults/a']).map((b) => b.hostPath)).toEqual(['/vaults/b']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('reports nothing when every bind is present', () => {
|
|
198
|
+
expect(findMissingBinds(plan(), ['/vaults/a', '/vaults/b', '/data'])).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('ignores a trailing separator when comparing', () => {
|
|
202
|
+
expect(findMissingBinds(plan(), ['/vaults/a/', '/vaults/b/'])).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('normalisePath', () => {
|
|
207
|
+
it.each([
|
|
208
|
+
['/a/b/', '/a/b'],
|
|
209
|
+
['/a//b', '/a/b'],
|
|
210
|
+
['/a/./b', '/a/b'],
|
|
211
|
+
['/a/c/../b', '/a/b'],
|
|
212
|
+
['/', '/'],
|
|
213
|
+
])('normalises %s to %s', (input, expected) => {
|
|
214
|
+
expect(normalisePath(input)).toBe(expected);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -121,12 +121,21 @@ _quilltap_complete() {
|
|
|
121
121
|
else
|
|
122
122
|
COMPREPLY=($(compgen -W "$db_verbs" -- "$cur"))
|
|
123
123
|
fi
|
|
124
|
+
elif [[ "$subverb" == "characters" ]]; then
|
|
125
|
+
local char_verbs="status archives archive rehydrate export"
|
|
126
|
+
local char_flags="--instance --data-dir --passphrase --json --limit --diverged --blocked --id \
|
|
127
|
+
--write -p --port --out --help"
|
|
128
|
+
if [[ "$cur" == -* ]]; then
|
|
129
|
+
COMPREPLY=($(compgen -W "$char_flags" -- "$cur"))
|
|
130
|
+
else
|
|
131
|
+
COMPREPLY=($(compgen -W "$char_verbs" -- "$cur"))
|
|
132
|
+
fi
|
|
124
133
|
else
|
|
125
134
|
COMPREPLY=($(compgen -W "$db_flags" -- "$cur"))
|
|
126
135
|
fi
|
|
127
136
|
;;
|
|
128
137
|
docs)
|
|
129
|
-
local docs_verbs="list show files ls dir tree read export scan write delete mkdir move copy link rmdir mvdir status find grep reindex embed"
|
|
138
|
+
local docs_verbs="list show files ls dir tree read export scan write delete mkdir move copy link rmdir mvdir status docker-mounts find grep reindex embed"
|
|
130
139
|
local docs_flags="--mount --instance --data-dir --passphrase --port --json --help \
|
|
131
140
|
--force --rendered --links --folder --type --ext --limit --max --context --top --threshold \
|
|
132
141
|
--ignore-case -l --wait -R --recursive --sort -r --reverse --depth --max-nodes --long --semantic"
|
|
@@ -244,6 +253,10 @@ _quilltap_complete() {
|
|
|
244
253
|
COMPREPLY=($(compgen -W "$maint_flags" -- "$cur"))
|
|
245
254
|
fi
|
|
246
255
|
;;
|
|
256
|
+
file-verify)
|
|
257
|
+
local fv_flags="--all --stall-ms --json --instance --data-dir --help"
|
|
258
|
+
COMPREPLY=($(compgen -W "$fv_flags" -- "$cur"))
|
|
259
|
+
;;
|
|
247
260
|
completion)
|
|
248
261
|
if [[ "$cur" == -* ]]; then
|
|
249
262
|
COMPREPLY=($(compgen -W "--help" -- "$cur"))
|
|
@@ -55,6 +55,7 @@ complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'themes' -d 'Manage the
|
|
|
55
55
|
complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'instances' -d 'Register or inspect instances'
|
|
56
56
|
complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'memories' -d 'Search and browse memories'
|
|
57
57
|
complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'memory-diff' -d 'Memory extraction dry-run'
|
|
58
|
+
complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'recall-replay' -d 'Replay memory recall for a turn'
|
|
58
59
|
complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'logs' -d 'Tail or print log files'
|
|
59
60
|
complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'migrations' -d 'Inspect migration status'
|
|
60
61
|
complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'maintenance' -d 'Run retention/cleanup sweeps'
|
|
@@ -92,6 +93,14 @@ complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'optimize' -d 'Op
|
|
|
92
93
|
complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'backup' -d 'Backup database'
|
|
93
94
|
complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'integrity' -d 'Check integrity'
|
|
94
95
|
|
|
96
|
+
# db characters sub-subverbs
|
|
97
|
+
complete -c quilltap -n '__quilltap_using_subverb db characters' -f -a 'status' -d 'Per-character vault status report'
|
|
98
|
+
complete -c quilltap -n '__quilltap_using_subverb db characters' -f -a 'archives' -d 'List archived characters and bundles'
|
|
99
|
+
complete -c quilltap -n '__quilltap_using_subverb db characters' -f -a 'archive' -d 'Archive a character (server required)'
|
|
100
|
+
complete -c quilltap -n '__quilltap_using_subverb db characters' -f -a 'rehydrate' -d 'Wake an archived character (server required)'
|
|
101
|
+
complete -c quilltap -n '__quilltap_using_subverb db characters' -f -a 'export' -d 'Write a plaintext .qtap for a character'
|
|
102
|
+
complete -c quilltap -n '__quilltap_using_subverb db characters' -l 'port' -s 'p' -d 'Server port' -x
|
|
103
|
+
|
|
95
104
|
# db flags
|
|
96
105
|
complete -c quilltap -n '__quilltap_using_subcommand db' -l 'limit' -d 'Result limit' -x
|
|
97
106
|
complete -c quilltap -n '__quilltap_using_subcommand db' -l 'grep' -d 'Substring search' -x
|
|
@@ -135,6 +144,7 @@ complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'scan' -d 'Trig
|
|
|
135
144
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'find' -d 'Substring search'
|
|
136
145
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'grep' -d 'Pattern search in text'
|
|
137
146
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'status' -d 'Per-mount status'
|
|
147
|
+
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'docker-mounts' -d 'Binds needed under Docker'
|
|
138
148
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'reindex' -d 'Re-extract and chunk'
|
|
139
149
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'embed' -d 'Enqueue embeddings'
|
|
140
150
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'write' -d 'Write a file'
|
|
@@ -261,6 +271,16 @@ complete -c quilltap -n '__quilltap_using_subcommand memory-diff' -l 'port' -s '
|
|
|
261
271
|
complete -c quilltap -n '__quilltap_using_subcommand memory-diff' -l 'concurrency' -d 'Concurrency limit' -x
|
|
262
272
|
complete -c quilltap -n '__quilltap_using_subcommand memory-diff' -l 'out' -d 'Output file' -r -F
|
|
263
273
|
|
|
274
|
+
# ---------- recall-replay flags ----------
|
|
275
|
+
complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'turn' -d 'Interchange to replay (1-based)' -x
|
|
276
|
+
complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'char' -d 'Character whose memories are searched' -x
|
|
277
|
+
complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'limit' -d 'Candidate rows per path' -x
|
|
278
|
+
complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'port' -d 'Server port' -x
|
|
279
|
+
|
|
280
|
+
# ---------- file-verify flags ----------
|
|
281
|
+
complete -c quilltap -n '__quilltap_using_subcommand file-verify' -l 'all' -d 'Read every top-level file, not just dataless ones'
|
|
282
|
+
complete -c quilltap -n '__quilltap_using_subcommand file-verify' -l 'stall-ms' -d 'Per-chunk stall threshold (ms)' -x
|
|
283
|
+
|
|
264
284
|
# ---------- logs flags ----------
|
|
265
285
|
complete -c quilltap -n '__quilltap_using_subcommand logs' -l 'stream' -d 'Log stream' -x -a 'combined error stdout stderr startup'
|
|
266
286
|
complete -c quilltap -n '__quilltap_using_subcommand logs' -l 'tail' -d 'Last N lines (0=full)' -x
|
|
@@ -85,6 +85,9 @@ _quilltap_subcommand() {
|
|
|
85
85
|
maintenance)
|
|
86
86
|
_quilltap_maintenance
|
|
87
87
|
;;
|
|
88
|
+
file-verify)
|
|
89
|
+
_arguments '--all[read every top-level file, not just dataless ones]' '--stall-ms[per-chunk stall threshold in ms]:ms:' '--json[machine-readable output]' '--help[show help]'
|
|
90
|
+
;;
|
|
88
91
|
completion)
|
|
89
92
|
_quilltap_completion
|
|
90
93
|
;;
|
|
@@ -146,7 +149,39 @@ _quilltap_db() {
|
|
|
146
149
|
|
|
147
150
|
if (( CURRENT == 2 )); then
|
|
148
151
|
_describe 'db subcommand' subverbs
|
|
152
|
+
return
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
if [[ "$words[2]" == "characters" ]]; then
|
|
156
|
+
local -a char_verbs char_opts
|
|
157
|
+
char_verbs=(
|
|
158
|
+
'status:Per-character vault status report'
|
|
159
|
+
'archives:List archived characters and ARCHIVE bundles'
|
|
160
|
+
'archive:Archive a character (runs through the server)'
|
|
161
|
+
'rehydrate:Wake an archived character (runs through the server)'
|
|
162
|
+
'export:Write a plaintext .qtap for a character'
|
|
163
|
+
)
|
|
164
|
+
char_opts=(
|
|
165
|
+
'(-i --instance)'{-i,--instance}'[Registered instance name]:instance:_quilltap_instance_names'
|
|
166
|
+
'(-d --data-dir)'{-d,--data-dir}'[Data directory]:directory:_directories'
|
|
167
|
+
'--passphrase[Database passphrase]:passphrase:'
|
|
168
|
+
'--json[JSON output]'
|
|
169
|
+
'--limit[Result limit]:limit:'
|
|
170
|
+
'--diverged[Only characters whose DB and vault differ]'
|
|
171
|
+
'--blocked[Only characters with vault issues]'
|
|
172
|
+
'--id[Single character by name or id]:character:'
|
|
173
|
+
'--write[Perform the archive/rehydrate write]'
|
|
174
|
+
'(-p --port)'{-p,--port}'[Server port]:port:'
|
|
175
|
+
'--out[Output .qtap path]:path:_files'
|
|
176
|
+
'(-h --help)'{-h,--help}'[Show help]'
|
|
177
|
+
)
|
|
178
|
+
if (( CURRENT == 3 )); then
|
|
179
|
+
_describe 'characters subcommand' char_verbs
|
|
180
|
+
fi
|
|
181
|
+
_arguments $char_opts
|
|
182
|
+
return
|
|
149
183
|
fi
|
|
184
|
+
|
|
150
185
|
_arguments $db_opts
|
|
151
186
|
}
|
|
152
187
|
|
|
@@ -165,6 +200,7 @@ _quilltap_docs() {
|
|
|
165
200
|
'find:Substring search'
|
|
166
201
|
'grep:Pattern search inside text'
|
|
167
202
|
'status:Per-mount status'
|
|
203
|
+
'docker-mounts:Bind mounts needed under Docker'
|
|
168
204
|
'reindex:Re-extract and re-chunk'
|
|
169
205
|
'embed:Enqueue embedding jobs'
|
|
170
206
|
'write:Write a file'
|
package/lib/db-commands.js
CHANGED
|
@@ -922,13 +922,26 @@ function inspectCharacterVault(row, mounts) {
|
|
|
922
922
|
return status;
|
|
923
923
|
}
|
|
924
924
|
|
|
925
|
-
function cmdCharacters(args, ctx) {
|
|
925
|
+
async function cmdCharacters(args, ctx) {
|
|
926
926
|
const { flags, positional } = parseSubArgs(args);
|
|
927
927
|
const sub = positional[0] || 'status';
|
|
928
|
-
|
|
929
|
-
|
|
928
|
+
switch (sub) {
|
|
929
|
+
case 'status':
|
|
930
|
+
return cmdCharactersStatus(flags, ctx);
|
|
931
|
+
case 'archives':
|
|
932
|
+
return cmdCharactersArchives(flags, ctx);
|
|
933
|
+
case 'archive':
|
|
934
|
+
return cmdCharactersArchiveVerb('archive', positional[1], flags, ctx);
|
|
935
|
+
case 'rehydrate':
|
|
936
|
+
return cmdCharactersArchiveVerb('rehydrate', positional[1], flags, ctx);
|
|
937
|
+
case 'export':
|
|
938
|
+
return cmdCharactersExport(positional[1], flags, ctx);
|
|
939
|
+
default:
|
|
940
|
+
throw new Error(`Unknown characters subcommand: ${sub}. Try: status, archives, archive, rehydrate, export`);
|
|
930
941
|
}
|
|
942
|
+
}
|
|
931
943
|
|
|
944
|
+
function cmdCharactersStatus(flags, ctx) {
|
|
932
945
|
const json = asBool(flags.json);
|
|
933
946
|
const limit = asInt(flags.limit, 0);
|
|
934
947
|
const onlyDiverged = asBool(flags.diverged);
|
|
@@ -1042,6 +1055,258 @@ function summarizeCharacterStatuses(all) {
|
|
|
1042
1055
|
return { ok, diverged, missingFiles, noVault, empty, physIncomplete };
|
|
1043
1056
|
}
|
|
1044
1057
|
|
|
1058
|
+
// ---------- characters: archive shelf ----------
|
|
1059
|
+
|
|
1060
|
+
const ARCHIVE_MAGIC = Buffer.from('QTAPARC1', 'ascii');
|
|
1061
|
+
const ARCHIVE_INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* List archived characters and the ARCHIVE bundle files on the shelf,
|
|
1065
|
+
* including loose bundles (files rows no character points at — the survivors
|
|
1066
|
+
* of a "keep archived bundles" wipe). Read-only.
|
|
1067
|
+
*/
|
|
1068
|
+
function cmdCharactersArchives(flags, ctx) {
|
|
1069
|
+
const json = asBool(flags.json);
|
|
1070
|
+
const main = ctx.openMain();
|
|
1071
|
+
try {
|
|
1072
|
+
const cols = new Set(main.prepare('PRAGMA table_info(characters)').all().map(r => r.name));
|
|
1073
|
+
if (!cols.has('archivedAt')) {
|
|
1074
|
+
console.log('This database predates character archiving (no archivedAt column).');
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const archived = main.prepare(
|
|
1078
|
+
'SELECT id, name, archivedAt, archiveFileId FROM characters WHERE archivedAt IS NOT NULL ORDER BY archivedAt DESC'
|
|
1079
|
+
).all();
|
|
1080
|
+
const bundles = main.prepare(
|
|
1081
|
+
"SELECT id, originalFilename, storageKey, size, createdAt FROM files WHERE category = 'ARCHIVE' ORDER BY createdAt DESC"
|
|
1082
|
+
).all();
|
|
1083
|
+
|
|
1084
|
+
const referenced = new Set(archived.map(c => c.archiveFileId).filter(Boolean));
|
|
1085
|
+
const looseBundles = bundles.filter(b => !referenced.has(b.id));
|
|
1086
|
+
|
|
1087
|
+
if (json) {
|
|
1088
|
+
return printJson({ archivedCharacters: archived, bundles, looseBundles });
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (archived.length === 0 && bundles.length === 0) {
|
|
1092
|
+
console.log('The archive shelf stands empty — no archived characters, no bundles.');
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (archived.length > 0) {
|
|
1097
|
+
console.log(`Archived characters (${archived.length}):`);
|
|
1098
|
+
printTable(archived.map(c => ({
|
|
1099
|
+
id: c.id.slice(0, 8),
|
|
1100
|
+
name: truncate(c.name, 28),
|
|
1101
|
+
archivedAt: c.archivedAt,
|
|
1102
|
+
bundle: c.archiveFileId ? c.archiveFileId.slice(0, 8) : '(none — pre-bundle tombstone)',
|
|
1103
|
+
})));
|
|
1104
|
+
}
|
|
1105
|
+
if (bundles.length > 0) {
|
|
1106
|
+
console.log('');
|
|
1107
|
+
console.log(`Archive bundles (${bundles.length}${looseBundles.length > 0 ? `, ${looseBundles.length} loose` : ''}):`);
|
|
1108
|
+
printTable(bundles.map(b => ({
|
|
1109
|
+
id: b.id.slice(0, 8),
|
|
1110
|
+
file: truncate(b.originalFilename, 44),
|
|
1111
|
+
bytes: b.size,
|
|
1112
|
+
createdAt: b.createdAt,
|
|
1113
|
+
state: referenced.has(b.id) ? 'held by character' : 'loose (importable only)',
|
|
1114
|
+
})));
|
|
1115
|
+
}
|
|
1116
|
+
} finally {
|
|
1117
|
+
try { main.close(); } catch {}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Archive or rehydrate a character through the RUNNING server's API. The
|
|
1123
|
+
* archive pipeline (export, encryption, prune) and the passphrase cache live
|
|
1124
|
+
* in the server process — the CLI cannot run them against the raw database —
|
|
1125
|
+
* so the server must be up, and it is the server that holds the instance
|
|
1126
|
+
* lock. `--write` is still required as the explicit opt-in to a write.
|
|
1127
|
+
*/
|
|
1128
|
+
async function cmdCharactersArchiveVerb(verb, query, flags, ctx) {
|
|
1129
|
+
if (!query) {
|
|
1130
|
+
throw new Error(`Usage: characters ${verb} <name|id> --write [--port N]`);
|
|
1131
|
+
}
|
|
1132
|
+
if (!asBool(flags.write)) {
|
|
1133
|
+
throw new Error(`characters ${verb} changes data; add --write to proceed.`);
|
|
1134
|
+
}
|
|
1135
|
+
const port = asInt(flags.port, 3000);
|
|
1136
|
+
|
|
1137
|
+
const main = ctx.openMain();
|
|
1138
|
+
let character;
|
|
1139
|
+
try {
|
|
1140
|
+
character = resolveCharacter(main, String(query), ctx.openMounts);
|
|
1141
|
+
} finally {
|
|
1142
|
+
try { main.close(); } catch {}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const url = `http://localhost:${port}/api/v1/characters/${encodeURIComponent(character.id)}?action=${verb}`;
|
|
1146
|
+
let res;
|
|
1147
|
+
try {
|
|
1148
|
+
res = await fetch(url, { method: 'POST' });
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
`Could not reach the Quilltap server at http://localhost:${port}: ${err.message}\n` +
|
|
1152
|
+
`The ${verb} operation runs inside the server (it needs the export pipeline and the ` +
|
|
1153
|
+
'unlocked passphrase), so start the server first.'
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
const body = await res.json().catch(() => ({}));
|
|
1157
|
+
if (!res.ok) {
|
|
1158
|
+
throw new Error(body.error || `${verb} failed with HTTP ${res.status}`);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (asBool(flags.json)) return printJson(body);
|
|
1162
|
+
if (verb === 'archive') {
|
|
1163
|
+
console.log(
|
|
1164
|
+
body.pruneComplete === false
|
|
1165
|
+
? `${character.name} is archived, but the prune did not finish — run the same command again to complete it.`
|
|
1166
|
+
: `${character.name} rests in the archive. Bundle file: ${body.archiveFileId || '(none)'}.`
|
|
1167
|
+
);
|
|
1168
|
+
} else {
|
|
1169
|
+
const r = body.restored;
|
|
1170
|
+
console.log(
|
|
1171
|
+
r
|
|
1172
|
+
? `${character.name} is awake again — ${r.memories} memories, ${r.documents} documents, ${r.blobs} blobs restored.`
|
|
1173
|
+
: `${character.name} is awake again.`
|
|
1174
|
+
);
|
|
1175
|
+
if (body.archiveBundleFileId) {
|
|
1176
|
+
console.log(`The archive bundle stays in the file library (file ${body.archiveBundleFileId}); delete it there if you no longer want the spare copy.`);
|
|
1177
|
+
}
|
|
1178
|
+
for (const w of body.warnings || []) console.log(`warning: ${w}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** Parse + decrypt a QTAPARC1 bundle. Returns null on a wrong passphrase. */
|
|
1183
|
+
function tryDecryptArchiveBundle(data, passphrase) {
|
|
1184
|
+
const crypto = require('crypto');
|
|
1185
|
+
const headerLength = data.readUInt32BE(ARCHIVE_MAGIC.length);
|
|
1186
|
+
const bodyStart = ARCHIVE_MAGIC.length + 4 + headerLength;
|
|
1187
|
+
if (headerLength <= 0 || data.length < bodyStart + 16) {
|
|
1188
|
+
throw new Error('Archive bundle is truncated (bad header or missing auth tag).');
|
|
1189
|
+
}
|
|
1190
|
+
const header = JSON.parse(data.subarray(ARCHIVE_MAGIC.length + 4, bodyStart).toString('utf8'));
|
|
1191
|
+
const salt = Buffer.from(header.salt, 'hex');
|
|
1192
|
+
const iv = Buffer.from(header.iv, 'hex');
|
|
1193
|
+
const key = crypto.pbkdf2Sync(passphrase, new Uint8Array(salt), header.kdfIterations, 32, header.kdfDigest);
|
|
1194
|
+
const keyHash = crypto.createHash('sha256').update(new Uint8Array(key)).digest('hex');
|
|
1195
|
+
if (keyHash !== header.keyHash) return null;
|
|
1196
|
+
|
|
1197
|
+
const ciphertext = data.subarray(bodyStart, data.length - 16);
|
|
1198
|
+
const authTag = data.subarray(data.length - 16);
|
|
1199
|
+
const decipher = crypto.createDecipheriv(header.algorithm, new Uint8Array(key), new Uint8Array(iv));
|
|
1200
|
+
decipher.setAuthTag(new Uint8Array(authTag));
|
|
1201
|
+
return Buffer.concat([decipher.update(new Uint8Array(ciphertext)), decipher.final()]);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Export a character as a plaintext `.qtap` — the interchange escape hatch.
|
|
1206
|
+
*
|
|
1207
|
+
* Archived characters: decrypt their bundle straight off the disk (offline;
|
|
1208
|
+
* prompts for the passphrase on protected instances). This is the only way to
|
|
1209
|
+
* reach an archived character's packed-away material — mail, photographs,
|
|
1210
|
+
* summaries — without rehydrating. Live characters: proxy to the running
|
|
1211
|
+
* server's export pipeline. Read-only either way.
|
|
1212
|
+
*/
|
|
1213
|
+
async function cmdCharactersExport(query, flags, ctx) {
|
|
1214
|
+
if (!query) {
|
|
1215
|
+
throw new Error('Usage: characters export <name|id> [--out <path>] [--port N]');
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const main = ctx.openMain();
|
|
1219
|
+
let character;
|
|
1220
|
+
let archiveFile = null;
|
|
1221
|
+
try {
|
|
1222
|
+
character = resolveCharacter(main, String(query), ctx.openMounts);
|
|
1223
|
+
const cols = new Set(main.prepare('PRAGMA table_info(characters)').all().map(r => r.name));
|
|
1224
|
+
if (cols.has('archivedAt')) {
|
|
1225
|
+
const row = main.prepare('SELECT archivedAt, archiveFileId FROM characters WHERE id = ?').get(character.id);
|
|
1226
|
+
if (row && row.archivedAt && row.archiveFileId) {
|
|
1227
|
+
archiveFile = main.prepare('SELECT id, storageKey, sha256 FROM files WHERE id = ?').get(row.archiveFileId);
|
|
1228
|
+
if (!archiveFile) {
|
|
1229
|
+
throw new Error(`${character.name} is archived but their bundle file row (${row.archiveFileId}) is missing.`);
|
|
1230
|
+
}
|
|
1231
|
+
} else if (row && row.archivedAt) {
|
|
1232
|
+
throw new Error(`${character.name} is a pre-bundle tombstone (no archive bundle exists to export).`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
} finally {
|
|
1236
|
+
try { main.close(); } catch {}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const safeName = String(character.name || character.id).replace(/[\\/:*?"<>|]/g, '_');
|
|
1240
|
+
const outPath = path.resolve(flags.out ? String(flags.out) : `${safeName}.qtap`);
|
|
1241
|
+
|
|
1242
|
+
let plaintext;
|
|
1243
|
+
if (archiveFile) {
|
|
1244
|
+
if (!archiveFile.storageKey) {
|
|
1245
|
+
throw new Error('The bundle row has no storage key; the file was never written.');
|
|
1246
|
+
}
|
|
1247
|
+
const bundlePath = path.join(ctx.dataDir, '..', 'files', archiveFile.storageKey);
|
|
1248
|
+
if (!fs.existsSync(bundlePath)) {
|
|
1249
|
+
throw new Error(`Bundle bytes not found on disk: ${bundlePath}`);
|
|
1250
|
+
}
|
|
1251
|
+
const data = fs.readFileSync(bundlePath);
|
|
1252
|
+
|
|
1253
|
+
if (!data.subarray(0, ARCHIVE_MAGIC.length).equals(ARCHIVE_MAGIC)) {
|
|
1254
|
+
// Pre-encryption plaintext bundle — pass it through untouched.
|
|
1255
|
+
plaintext = data;
|
|
1256
|
+
} else {
|
|
1257
|
+
plaintext = tryDecryptArchiveBundle(data, ARCHIVE_INTERNAL_PASSPHRASE);
|
|
1258
|
+
if (plaintext === null && process.env.QUILLTAP_DB_PASSPHRASE) {
|
|
1259
|
+
plaintext = tryDecryptArchiveBundle(data, process.env.QUILLTAP_DB_PASSPHRASE);
|
|
1260
|
+
}
|
|
1261
|
+
if (plaintext === null) {
|
|
1262
|
+
const { promptPassphrase } = require('./db-helpers');
|
|
1263
|
+
const pass = await promptPassphrase('Archive passphrase: ');
|
|
1264
|
+
if (pass) plaintext = tryDecryptArchiveBundle(data, pass);
|
|
1265
|
+
}
|
|
1266
|
+
if (plaintext === null) {
|
|
1267
|
+
throw new Error(
|
|
1268
|
+
'That passphrase does not open this archive. If you changed your passphrase and this ' +
|
|
1269
|
+
'bundle was reported left behind, it still wants the old one.'
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
} else {
|
|
1274
|
+
// Live character: the export pipeline lives in the server.
|
|
1275
|
+
const port = asInt(flags.port, 3000);
|
|
1276
|
+
const url = `http://localhost:${port}/api/v1/system/tools?action=export`;
|
|
1277
|
+
let res;
|
|
1278
|
+
try {
|
|
1279
|
+
res = await fetch(url, {
|
|
1280
|
+
method: 'POST',
|
|
1281
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1282
|
+
body: JSON.stringify({
|
|
1283
|
+
type: 'characters',
|
|
1284
|
+
scope: 'selected',
|
|
1285
|
+
selectedIds: [character.id],
|
|
1286
|
+
includeMemories: true,
|
|
1287
|
+
}),
|
|
1288
|
+
});
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
`Could not reach the Quilltap server at http://localhost:${port}: ${err.message}\n` +
|
|
1292
|
+
'Exporting a live character runs the server\'s export pipeline, so start the server first. ' +
|
|
1293
|
+
'(Archived characters export offline from their bundle.)'
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
if (!res.ok) {
|
|
1297
|
+
const body = await res.json().catch(() => ({}));
|
|
1298
|
+
throw new Error(body.error || `Export failed with HTTP ${res.status}`);
|
|
1299
|
+
}
|
|
1300
|
+
plaintext = Buffer.from(await res.arrayBuffer());
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
fs.writeFileSync(outPath, plaintext);
|
|
1304
|
+
console.log(`Wrote ${plaintext.length} bytes to ${outPath}`);
|
|
1305
|
+
if (archiveFile) {
|
|
1306
|
+
console.log('This is the decrypted archive bundle — a plaintext .qtap. Guard it accordingly.');
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1045
1310
|
// ---------- verb: optimize ----------
|
|
1046
1311
|
|
|
1047
1312
|
const OPTIMIZE_TARGETS = {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Docker Bind Planning for Filesystem Document Stores
|
|
5
|
+
*
|
|
6
|
+
* A container sees only the host paths handed to it at creation time. Database
|
|
7
|
+
* -backed stores live inside the data directory and ride along on the single
|
|
8
|
+
* bind every Quilltap container already has; filesystem and Obsidian stores
|
|
9
|
+
* point anywhere on the host and are, by default, simply absent inside the
|
|
10
|
+
* container — the store lists happily from the cached mount index while every
|
|
11
|
+
* read and write against the real bytes fails.
|
|
12
|
+
*
|
|
13
|
+
* This module turns "what stores does this instance have" into "what -v flags
|
|
14
|
+
* does the container need". It is deliberately pure: the caller supplies the
|
|
15
|
+
* store rows, the platform, and a path probe, so the planner can be tested
|
|
16
|
+
* without a database, a filesystem, or Docker.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the binds are path-identical
|
|
19
|
+
*
|
|
20
|
+
* Each store is bound at its own host path (`-v /host/vault:/host/vault`) so
|
|
21
|
+
* the `basePath` recorded in the database resolves unchanged whether Quilltap
|
|
22
|
+
* runs natively or in a container. The alternative — mounting under some
|
|
23
|
+
* container-local prefix — would require a translation layer on every path in
|
|
24
|
+
* and out of the database, and would make the same instance directory
|
|
25
|
+
* unusable outside the container.
|
|
26
|
+
*
|
|
27
|
+
* Docker creates missing destination ancestors itself, as root and mode 0755,
|
|
28
|
+
* during mount setup — so binding `/Users/you/Vault` into an image that has no
|
|
29
|
+
* `/Users` works, and the unprivileged app user can still traverse in. Those
|
|
30
|
+
* ancestors are *not* writable by the app user, which is a feature: a store
|
|
31
|
+
* that was never bound stays structurally unwritable rather than quietly
|
|
32
|
+
* accumulating a fabricated directory tree.
|
|
33
|
+
*
|
|
34
|
+
* @module docker-mounts
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Prefixes Docker Desktop for macOS shares with the VM out of the box. A bind
|
|
42
|
+
* whose source falls outside these is accepted by `docker run` but arrives in
|
|
43
|
+
* the container as an empty directory, which is a uniquely confusing failure —
|
|
44
|
+
* so it earns a warning rather than silence.
|
|
45
|
+
*/
|
|
46
|
+
const MACOS_DEFAULT_SHARED_PREFIXES = ['/Users', '/Volumes', '/private', '/tmp', '/var/folders'];
|
|
47
|
+
|
|
48
|
+
/** Store types whose bytes live on the host filesystem rather than in the database. */
|
|
49
|
+
const FILESYSTEM_MOUNT_TYPES = new Set(['filesystem', 'obsidian']);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalise a base path for comparison: resolve `.`/`..`, collapse separators,
|
|
53
|
+
* and drop any trailing separator so `/a/b` and `/a/b/` are one path.
|
|
54
|
+
*/
|
|
55
|
+
function normalisePath(basePath) {
|
|
56
|
+
const resolved = path.posix.normalize(String(basePath).trim());
|
|
57
|
+
if (resolved.length > 1 && resolved.endsWith('/')) {
|
|
58
|
+
return resolved.slice(0, -1);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when `candidate` sits inside `ancestor` (and is not `ancestor` itself). */
|
|
64
|
+
function isDescendantOf(candidate, ancestor) {
|
|
65
|
+
return candidate.startsWith(ancestor.endsWith('/') ? ancestor : ancestor + '/');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Plan the bind mounts an instance's filesystem-backed stores require.
|
|
70
|
+
*
|
|
71
|
+
* @param {Array<object>} rows - doc_mount_points rows (id, name, mountType, basePath, enabled)
|
|
72
|
+
* @param {object} [options]
|
|
73
|
+
* @param {string} [options.platform] - process.platform value; defaults to the current host
|
|
74
|
+
* @param {(p: string) => boolean} [options.exists] - path probe, injectable for tests
|
|
75
|
+
* @returns {{binds: Array<object>, skipped: Array<object>, warnings: Array<string>, unsupported: boolean}}
|
|
76
|
+
*/
|
|
77
|
+
function planStoreMounts(rows, options = {}) {
|
|
78
|
+
const platform = options.platform || process.platform;
|
|
79
|
+
const exists =
|
|
80
|
+
options.exists ||
|
|
81
|
+
((p) => {
|
|
82
|
+
try {
|
|
83
|
+
return fs.statSync(p).isDirectory();
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const warnings = [];
|
|
90
|
+
|
|
91
|
+
// Windows host paths (C:\Users\…) have no in-container equivalent, so the
|
|
92
|
+
// path-identical scheme this module depends on cannot work there. Say so
|
|
93
|
+
// plainly rather than emitting binds that would silently misbehave.
|
|
94
|
+
if (platform === 'win32') {
|
|
95
|
+
return {
|
|
96
|
+
binds: [],
|
|
97
|
+
skipped: [],
|
|
98
|
+
warnings: [
|
|
99
|
+
'Automatic store binds are not supported on Windows: container paths cannot mirror ' +
|
|
100
|
+
'Windows host paths. Filesystem document stores must be bound manually.',
|
|
101
|
+
],
|
|
102
|
+
unsupported: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const candidates = rows.filter(
|
|
107
|
+
(r) => FILESYSTEM_MOUNT_TYPES.has(r.mountType) && r.enabled && String(r.basePath || '').trim()
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Group stores by normalised path first — several stores commonly share one
|
|
111
|
+
// vault root, and they need exactly one bind between them.
|
|
112
|
+
const byPath = new Map();
|
|
113
|
+
for (const row of candidates) {
|
|
114
|
+
const normalised = normalisePath(row.basePath);
|
|
115
|
+
if (!path.posix.isAbsolute(normalised)) {
|
|
116
|
+
warnings.push(`Skipping store '${row.name}': base path '${row.basePath}' is not absolute.`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!byPath.has(normalised)) {
|
|
120
|
+
byPath.set(normalised, []);
|
|
121
|
+
}
|
|
122
|
+
byPath.get(normalised).push(row.name);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const allPaths = [...byPath.keys()].sort();
|
|
126
|
+
|
|
127
|
+
const binds = [];
|
|
128
|
+
const skipped = [];
|
|
129
|
+
|
|
130
|
+
for (const hostPath of allPaths) {
|
|
131
|
+
const stores = byPath.get(hostPath);
|
|
132
|
+
|
|
133
|
+
// A path nested inside another selected path is already covered by that
|
|
134
|
+
// bind. Binding both is redundant, and Docker mounts them independently —
|
|
135
|
+
// which would shadow the parent's view of the child directory.
|
|
136
|
+
const ancestor = allPaths.find((other) => other !== hostPath && isDescendantOf(hostPath, other));
|
|
137
|
+
if (ancestor) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!exists(hostPath)) {
|
|
142
|
+
// Never create the source. Docker would happily materialise a missing
|
|
143
|
+
// bind source as a root-owned empty directory, which presents an empty
|
|
144
|
+
// store as a healthy one — the exact failure this feature exists to end.
|
|
145
|
+
skipped.push({ hostPath, stores, reason: 'missing' });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (platform === 'darwin' && !MACOS_DEFAULT_SHARED_PREFIXES.some((p) => hostPath === p || isDescendantOf(hostPath, p))) {
|
|
150
|
+
warnings.push(
|
|
151
|
+
`'${hostPath}' is outside Docker Desktop's default shared paths. Add it under ` +
|
|
152
|
+
'Settings → Resources → File sharing, or the store will appear empty in the container.'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
binds.push({ hostPath, containerPath: hostPath, stores });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (platform === 'linux' && binds.length > 0) {
|
|
160
|
+
warnings.push(
|
|
161
|
+
'On Linux, bind mounts preserve host ownership. If the container user cannot write to ' +
|
|
162
|
+
'these paths, start the container with --user "$(id -u):$(id -g)".'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const entry of skipped) {
|
|
167
|
+
warnings.push(
|
|
168
|
+
`Skipping '${entry.hostPath}' (${entry.stores.join(', ')}): the path does not exist on this host.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { binds, skipped, warnings, unsupported: false };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Render a plan as `docker run` arguments. */
|
|
176
|
+
function toDockerArgs(plan) {
|
|
177
|
+
const args = [];
|
|
178
|
+
for (const bind of plan.binds) {
|
|
179
|
+
args.push('-v', `${bind.hostPath}:${bind.containerPath}`);
|
|
180
|
+
}
|
|
181
|
+
return args;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Compare a plan against the binds a container was actually created with.
|
|
186
|
+
* Returns the host paths a running container is missing, which is what tells
|
|
187
|
+
* an operator that a restart is owed.
|
|
188
|
+
*/
|
|
189
|
+
function findMissingBinds(plan, existingSources) {
|
|
190
|
+
const existing = new Set(existingSources.map(normalisePath));
|
|
191
|
+
return plan.binds.filter((b) => !existing.has(b.hostPath));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
planStoreMounts,
|
|
196
|
+
toDockerArgs,
|
|
197
|
+
findMissingBinds,
|
|
198
|
+
normalisePath,
|
|
199
|
+
FILESYSTEM_MOUNT_TYPES,
|
|
200
|
+
MACOS_DEFAULT_SHARED_PREFIXES,
|
|
201
|
+
};
|
package/lib/docs-commands.js
CHANGED
|
@@ -71,8 +71,13 @@ Read subcommands:
|
|
|
71
71
|
grep [--mount <name|id|all>] [--ignore-case] [-l] [--max N] [--context N] <pattern>
|
|
72
72
|
Substring search inside extracted text
|
|
73
73
|
status [--mount <name|id>] [--top N] Per-mount extraction + embedding rollup
|
|
74
|
+
docker-mounts [--format args|json] Bind mounts this instance's filesystem
|
|
75
|
+
stores need to be visible in Docker
|
|
74
76
|
|
|
75
77
|
Server-required subcommands (background-job queue lives in the running server):
|
|
78
|
+
grep --semantic [--mount <name|id|all>] [--top N] [--threshold 0..1] <query>
|
|
79
|
+
Embedding search over indexed chunks
|
|
80
|
+
(default --top 20, --threshold 0.5)
|
|
76
81
|
reindex <mount> [path] [--force] Re-extract text + re-chunk affected files
|
|
77
82
|
embed <mount> [path] [--force] [--wait]
|
|
78
83
|
Enqueue embedding jobs for un-embedded chunks
|
|
@@ -81,9 +86,9 @@ Write subcommands (server required for database-backed mounts):
|
|
|
81
86
|
write [--force] [--base64] <mount> <path> [file] Write a file from <file> or stdin
|
|
82
87
|
delete <mount> <path> Idempotent file delete
|
|
83
88
|
mkdir <mount> <path> Idempotent folder create
|
|
84
|
-
move <srcMount> <srcPath> <dstMount> <dstPath> Move file (
|
|
85
|
-
copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (
|
|
86
|
-
link <srcMount> <srcPath> <dstMount> <dstPath> Hard-link file (server-required)
|
|
89
|
+
move <srcMount> <srcPath> <dstMount> <dstPath> Move file (relocates the link; no byte copy)
|
|
90
|
+
copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (independent; shares bytes until either side is written)
|
|
91
|
+
link <srcMount> <srcPath> <dstMount> <dstPath> Hard-link file — one file, two paths; edits show at both (server-required)
|
|
87
92
|
rmdir <mount> <path> Delete an empty folder (server-required)
|
|
88
93
|
mvdir <mount> <fromPath> <toPath> Rename/move a folder (server-required)
|
|
89
94
|
|
|
@@ -114,14 +119,17 @@ Options:
|
|
|
114
119
|
file size, or hard-link count
|
|
115
120
|
-r, --reverse Reverse sort order
|
|
116
121
|
--links For 'ls' / 'dir': under each file with more than
|
|
117
|
-
one hard link, list the other mount/path entries
|
|
122
|
+
one hard link, list the other mount/path entries.
|
|
123
|
+
Counts deliberate links made with 'docs link' — not
|
|
124
|
+
unrelated files that merely share identical bytes
|
|
118
125
|
--depth N For 'tree': maximum nesting depth (default: 20)
|
|
119
126
|
--max-nodes N For 'tree': maximum nodes to render (default: 1000)
|
|
120
127
|
--long For 'tree': include text/emb columns (reserved for future)
|
|
121
128
|
--force For 'read': dump binary to TTY anyway
|
|
122
129
|
For 'write': overwrite existing destination
|
|
123
130
|
For 'copy': overwrite + force a real byte copy
|
|
124
|
-
(skips the default
|
|
131
|
+
(skips the default shared-content path;
|
|
132
|
+
the end state is the same either way)
|
|
125
133
|
--base64 For 'write': send content as base64 JSON via PUT
|
|
126
134
|
.../files/{path} (portable path used
|
|
127
135
|
by the file browser; server-required)
|
|
@@ -158,6 +166,7 @@ Examples:
|
|
|
158
166
|
quilltap docs find --mount notes --ext md Knowledge
|
|
159
167
|
quilltap docs grep --mount notes --ignore-case "five-point Calvinist"
|
|
160
168
|
quilltap docs grep --mount notes -l "TODO"
|
|
169
|
+
quilltap docs grep --semantic --mount notes --top 10 "what did we decide about pricing"
|
|
161
170
|
quilltap docs read qtap://notes/today.md
|
|
162
171
|
quilltap docs find --uri Manifesto
|
|
163
172
|
quilltap docs status
|
|
@@ -203,6 +212,8 @@ function parseFlags(args) {
|
|
|
203
212
|
threshold: -1,
|
|
204
213
|
// base64 read/write flag
|
|
205
214
|
base64: false,
|
|
215
|
+
// docker-mounts output shape: table (human), args (docker run flags), json
|
|
216
|
+
format: '',
|
|
206
217
|
};
|
|
207
218
|
const positional = [];
|
|
208
219
|
let i = 0;
|
|
@@ -222,6 +233,7 @@ function parseFlags(args) {
|
|
|
222
233
|
break;
|
|
223
234
|
}
|
|
224
235
|
case '--json': flags.json = true; break;
|
|
236
|
+
case '--format': flags.format = args[++i]; break;
|
|
225
237
|
case '--uri': flags.uri = true; break;
|
|
226
238
|
case '--rendered': flags.rendered = true; break;
|
|
227
239
|
case '--folder': flags.folder = args[++i]; break;
|
|
@@ -480,6 +492,95 @@ async function handleList(flags) {
|
|
|
480
492
|
}
|
|
481
493
|
}
|
|
482
494
|
|
|
495
|
+
// ----------------------------------------------------------------------------
|
|
496
|
+
// docker-mounts
|
|
497
|
+
// ----------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Report the bind mounts this instance's filesystem-backed stores need in
|
|
501
|
+
* order to be reachable from inside a container.
|
|
502
|
+
*
|
|
503
|
+
* `--format args` prints only the flags, one per line, so a start script can
|
|
504
|
+
* splice them into a `docker run` argv without parsing prose. Everything
|
|
505
|
+
* advisory goes to stderr for exactly that reason: stdout stays machine-clean
|
|
506
|
+
* even when there are warnings worth a human's attention.
|
|
507
|
+
*/
|
|
508
|
+
async function handleDockerMounts(flags) {
|
|
509
|
+
const { planStoreMounts, toDockerArgs } = require('./docker-mounts');
|
|
510
|
+
const { db } = await openDb(flags);
|
|
511
|
+
|
|
512
|
+
let rows;
|
|
513
|
+
try {
|
|
514
|
+
rows = db.prepare(`
|
|
515
|
+
SELECT id, name, mountType, storeType, basePath, enabled
|
|
516
|
+
FROM doc_mount_points
|
|
517
|
+
WHERE mountType != 'database'
|
|
518
|
+
ORDER BY name COLLATE NOCASE
|
|
519
|
+
`).all();
|
|
520
|
+
} finally {
|
|
521
|
+
db.close();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const plan = planStoreMounts(rows);
|
|
525
|
+
const format = flags.format || (flags.json ? 'json' : 'table');
|
|
526
|
+
|
|
527
|
+
if (format === 'json') {
|
|
528
|
+
process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (format === 'args') {
|
|
533
|
+
for (const arg of toDockerArgs(plan)) {
|
|
534
|
+
process.stdout.write(arg + '\n');
|
|
535
|
+
}
|
|
536
|
+
for (const warning of plan.warnings) {
|
|
537
|
+
process.stderr.write(`warning: ${warning}\n`);
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (plan.unsupported) {
|
|
543
|
+
for (const warning of plan.warnings) {
|
|
544
|
+
console.log(`${YELLOW}${warning}${RESET}`);
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (plan.binds.length === 0 && plan.skipped.length === 0) {
|
|
550
|
+
console.log('(no filesystem-backed document stores — nothing to bind)');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (plan.binds.length > 0) {
|
|
555
|
+
console.log(`${BOLD}Bind mounts required:${RESET}`);
|
|
556
|
+
console.table(
|
|
557
|
+
plan.binds.map((b) => ({
|
|
558
|
+
'host path': b.hostPath,
|
|
559
|
+
stores: b.stores.join(', '),
|
|
560
|
+
}))
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (plan.skipped.length > 0) {
|
|
565
|
+
console.log(`${BOLD}Skipped:${RESET}`);
|
|
566
|
+
console.table(
|
|
567
|
+
plan.skipped.map((s) => ({
|
|
568
|
+
'host path': s.hostPath,
|
|
569
|
+
stores: s.stores.join(', '),
|
|
570
|
+
reason: s.reason,
|
|
571
|
+
}))
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const warning of plan.warnings) {
|
|
576
|
+
console.log(`${YELLOW}warning:${RESET} ${warning}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
console.log('');
|
|
580
|
+
console.log(`${DIM}Binds are applied when a container is created. Re-run the start script`);
|
|
581
|
+
console.log(`with --recreate to rebuild the container with these stores included.${RESET}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
483
584
|
// ----------------------------------------------------------------------------
|
|
484
585
|
// show
|
|
485
586
|
// ----------------------------------------------------------------------------
|
|
@@ -620,21 +721,51 @@ function formatLsDate(iso) {
|
|
|
620
721
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
621
722
|
}
|
|
622
723
|
|
|
623
|
-
|
|
724
|
+
// Cached per process: one PRAGMA per run, not one per prepared statement.
|
|
725
|
+
let linkGroupColumnPresent = null;
|
|
726
|
+
|
|
727
|
+
function hasLinkGroupColumn(db) {
|
|
728
|
+
if (linkGroupColumnPresent !== null) return linkGroupColumnPresent;
|
|
729
|
+
try {
|
|
730
|
+
const cols = db.prepare(`PRAGMA table_info("doc_mount_file_links")`).all();
|
|
731
|
+
linkGroupColumnPresent = cols.some((c) => c.name === 'linkGroupId');
|
|
732
|
+
} catch {
|
|
733
|
+
linkGroupColumnPresent = false;
|
|
734
|
+
}
|
|
735
|
+
return linkGroupColumnPresent;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// The "links" column counts deliberate hard links — members of this file's
|
|
739
|
+
// linkGroupId — NOT rows sharing a fileId. Content rows are addressed by
|
|
740
|
+
// sha256, so a boilerplate or empty file collects dozens of unrelated links
|
|
741
|
+
// that share its bytes purely by coincidence; reporting those as links told
|
|
742
|
+
// the operator a file was linked into 36 stores when nothing had been linked
|
|
743
|
+
// at all. An instance that hasn't run the linkGroupId migration yet degrades
|
|
744
|
+
// to 1 rather than failing the whole listing.
|
|
745
|
+
function lsFileColumns(db) {
|
|
746
|
+
const present = hasLinkGroupColumn(db);
|
|
747
|
+
const linkCount = present
|
|
748
|
+
? `(CASE WHEN l.linkGroupId IS NULL THEN 1 ELSE
|
|
749
|
+
(SELECT COUNT(*) FROM doc_mount_file_links g WHERE g.linkGroupId = l.linkGroupId) END)`
|
|
750
|
+
: `1`;
|
|
751
|
+
const linkGroupId = present ? `l.linkGroupId` : `NULL`;
|
|
752
|
+
return `
|
|
753
|
+
${linkGroupId} AS linkGroupId,
|
|
624
754
|
l.id AS linkId, l.fileId, l.relativePath, l.fileName, l.lastModified,
|
|
625
755
|
l.extractionStatus, l.extractedTextSha256, l.chunkCount,
|
|
626
756
|
f.fileType, f.fileSizeBytes, f.source, f.sha256,
|
|
627
|
-
|
|
757
|
+
${linkCount} AS linkCount,
|
|
628
758
|
(SELECT COUNT(*) FROM doc_mount_chunks
|
|
629
759
|
WHERE linkId = l.id AND embedding IS NOT NULL) AS embeddedChunkCount
|
|
630
760
|
`;
|
|
761
|
+
}
|
|
631
762
|
|
|
632
763
|
function resolveLsTarget(db, mountId, normalizedPath) {
|
|
633
764
|
if (!normalizedPath) return { kind: 'root', path: '' };
|
|
634
765
|
|
|
635
766
|
// Exact file match wins — handles the single-file display mode.
|
|
636
767
|
const file = db.prepare(`
|
|
637
|
-
SELECT ${
|
|
768
|
+
SELECT ${lsFileColumns(db)}
|
|
638
769
|
FROM doc_mount_file_links l
|
|
639
770
|
JOIN doc_mount_files f ON f.id = l.fileId
|
|
640
771
|
WHERE l.mountPointId = ? AND l.relativePath = ?
|
|
@@ -688,7 +819,7 @@ function fetchLsRows(db, mountId, parentPath) {
|
|
|
688
819
|
|
|
689
820
|
const files = parentPath === ''
|
|
690
821
|
? db.prepare(`
|
|
691
|
-
SELECT ${
|
|
822
|
+
SELECT ${lsFileColumns(db)}
|
|
692
823
|
FROM doc_mount_file_links l
|
|
693
824
|
JOIN doc_mount_files f ON f.id = l.fileId
|
|
694
825
|
WHERE l.mountPointId = ?
|
|
@@ -696,7 +827,7 @@ function fetchLsRows(db, mountId, parentPath) {
|
|
|
696
827
|
ORDER BY l.fileName COLLATE NOCASE
|
|
697
828
|
`).all(mountId)
|
|
698
829
|
: db.prepare(`
|
|
699
|
-
SELECT ${
|
|
830
|
+
SELECT ${lsFileColumns(db)}
|
|
700
831
|
FROM doc_mount_file_links l
|
|
701
832
|
JOIN doc_mount_files f ON f.id = l.fileId
|
|
702
833
|
WHERE l.mountPointId = ?
|
|
@@ -708,26 +839,29 @@ function fetchLsRows(db, mountId, parentPath) {
|
|
|
708
839
|
return { folders, files };
|
|
709
840
|
}
|
|
710
841
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
842
|
+
// Members of each named hard-link group, keyed by linkGroupId. Grouping is by
|
|
843
|
+
// linkGroupId rather than fileId on purpose — see lsFileColumns: a shared
|
|
844
|
+
// fileId only means "identical bytes", which is not a link.
|
|
845
|
+
function fetchLinkGroupMembers(db, groupIds) {
|
|
846
|
+
if (groupIds.length === 0) return new Map();
|
|
847
|
+
const placeholders = groupIds.map(() => '?').join(',');
|
|
714
848
|
const rows = db.prepare(`
|
|
715
|
-
SELECT l.
|
|
849
|
+
SELECT l.linkGroupId, l.relativePath, l.mountPointId, m.name AS mountName
|
|
716
850
|
FROM doc_mount_file_links l
|
|
717
851
|
JOIN doc_mount_points m ON m.id = l.mountPointId
|
|
718
|
-
WHERE l.
|
|
852
|
+
WHERE l.linkGroupId IN (${placeholders})
|
|
719
853
|
ORDER BY m.name COLLATE NOCASE, l.relativePath COLLATE NOCASE
|
|
720
|
-
`).all(...
|
|
721
|
-
const
|
|
854
|
+
`).all(...groupIds);
|
|
855
|
+
const byGroup = new Map();
|
|
722
856
|
for (const r of rows) {
|
|
723
|
-
if (!
|
|
724
|
-
|
|
857
|
+
if (!byGroup.has(r.linkGroupId)) byGroup.set(r.linkGroupId, []);
|
|
858
|
+
byGroup.get(r.linkGroupId).push({
|
|
725
859
|
mountPointId: r.mountPointId,
|
|
726
860
|
mountName: r.mountName,
|
|
727
861
|
relativePath: r.relativePath,
|
|
728
862
|
});
|
|
729
863
|
}
|
|
730
|
-
return
|
|
864
|
+
return byGroup;
|
|
731
865
|
}
|
|
732
866
|
|
|
733
867
|
function sortLsFiles(files, sortType, reverse) {
|
|
@@ -776,14 +910,14 @@ async function handleLs(flags, mountSpec, rawPath) {
|
|
|
776
910
|
const prefix = normalizedPath ? normalizedPath.replace(/\/+$/, '') + '/' : '';
|
|
777
911
|
const allFiles = normalizedPath
|
|
778
912
|
? db.prepare(`
|
|
779
|
-
SELECT ${
|
|
913
|
+
SELECT ${lsFileColumns(db)}
|
|
780
914
|
FROM doc_mount_file_links l
|
|
781
915
|
JOIN doc_mount_files f ON f.id = l.fileId
|
|
782
916
|
WHERE l.mountPointId = ? AND l.relativePath LIKE ?
|
|
783
917
|
ORDER BY l.relativePath
|
|
784
918
|
`).all(mount.id, prefix + '%')
|
|
785
919
|
: db.prepare(`
|
|
786
|
-
SELECT ${
|
|
920
|
+
SELECT ${lsFileColumns(db)}
|
|
787
921
|
FROM doc_mount_file_links l
|
|
788
922
|
JOIN doc_mount_files f ON f.id = l.fileId
|
|
789
923
|
WHERE l.mountPointId = ?
|
|
@@ -820,10 +954,10 @@ async function handleLs(flags, mountSpec, rawPath) {
|
|
|
820
954
|
|
|
821
955
|
// Fetch links for JSON or --links flag
|
|
822
956
|
const wantLinks = flags.json || flags.links;
|
|
823
|
-
const
|
|
824
|
-
? files.filter((f) => f.linkCount > 1).map((f) => f.
|
|
957
|
+
const linkedGroupIds = wantLinks
|
|
958
|
+
? files.filter((f) => f.linkCount > 1 && f.linkGroupId).map((f) => f.linkGroupId)
|
|
825
959
|
: [];
|
|
826
|
-
const
|
|
960
|
+
const linksByGroup = fetchLinkGroupMembers(db, linkedGroupIds);
|
|
827
961
|
|
|
828
962
|
// JSON output
|
|
829
963
|
if (flags.json) {
|
|
@@ -842,7 +976,7 @@ async function handleLs(flags, mountSpec, rawPath) {
|
|
|
842
976
|
}
|
|
843
977
|
}
|
|
844
978
|
for (const file of files) {
|
|
845
|
-
const others =
|
|
979
|
+
const others = file.linkGroupId ? linksByGroup.get(file.linkGroupId) : undefined;
|
|
846
980
|
const links = others && others.length > 0
|
|
847
981
|
? others
|
|
848
982
|
: [{
|
|
@@ -947,6 +1081,7 @@ async function handleLs(flags, mountSpec, rawPath) {
|
|
|
947
1081
|
emb: embedColumnMarker(file.chunkCount, file.embeddedChunkCount),
|
|
948
1082
|
name: singleFile ? file.relativePath : file.fileName,
|
|
949
1083
|
fileId: file.fileId,
|
|
1084
|
+
linkGroupId: file.linkGroupId,
|
|
950
1085
|
relativePath: file.relativePath,
|
|
951
1086
|
});
|
|
952
1087
|
}
|
|
@@ -978,7 +1113,7 @@ async function handleLs(flags, mountSpec, rawPath) {
|
|
|
978
1113
|
for (const r of dataRows) {
|
|
979
1114
|
console.log(renderLine(r, false));
|
|
980
1115
|
if (flags.links && r.type === '-') {
|
|
981
|
-
const others = (
|
|
1116
|
+
const others = ((r.linkGroupId && linksByGroup.get(r.linkGroupId)) || []).filter(
|
|
982
1117
|
(l) => !(l.mountPointId === mount.id && l.relativePath === r.relativePath)
|
|
983
1118
|
);
|
|
984
1119
|
if (others.length > 0) {
|
|
@@ -2992,6 +3127,9 @@ async function docsCommand(args) {
|
|
|
2992
3127
|
case 'status':
|
|
2993
3128
|
await handleStatus(flags);
|
|
2994
3129
|
break;
|
|
3130
|
+
case 'docker-mounts':
|
|
3131
|
+
await handleDockerMounts(flags);
|
|
3132
|
+
break;
|
|
2995
3133
|
case 'find':
|
|
2996
3134
|
await handleFind(flags, positional);
|
|
2997
3135
|
break;
|
package/lib/memories-commands.js
CHANGED
|
@@ -1276,6 +1276,11 @@ Subcommands:
|
|
|
1276
1276
|
grep [filters] [-i] [-l] [--max N] [--context N] <pattern>
|
|
1277
1277
|
Pattern search inside content
|
|
1278
1278
|
with snippets.
|
|
1279
|
+
grep --semantic --character <name|id> [--top N] [--threshold 0..1] <query>
|
|
1280
|
+
Embedding search via the running
|
|
1281
|
+
server (default --top 20,
|
|
1282
|
+
--threshold 0.5). One holder at a
|
|
1283
|
+
time; --port sets the server port.
|
|
1279
1284
|
show <id|prefix> [--depth N] [--no-related] [--json]
|
|
1280
1285
|
Full record + related-memory
|
|
1281
1286
|
neighbourhood.
|
|
@@ -1326,6 +1331,7 @@ Examples:
|
|
|
1326
1331
|
quilltap memories ls --character Ariadne --sort created --limit 10
|
|
1327
1332
|
quilltap memories find "concrete examples"
|
|
1328
1333
|
quilltap memories grep -i --max 3 --context 1 "concrete examples"
|
|
1334
|
+
quilltap memories grep --semantic --character Ariadne --top 10 "the argument about Calvin"
|
|
1329
1335
|
quilltap memories show abc12345 --depth 2
|
|
1330
1336
|
quilltap memories tree abc12345 --depth 3
|
|
1331
1337
|
quilltap memories status --character Ariadne
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quilltap",
|
|
3
|
-
"version": "4.8.0
|
|
3
|
+
"version": "4.8.0",
|
|
4
4
|
"description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Charles Sebold",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"@napi-rs/canvas": "^0.1.100",
|
|
40
40
|
"better-sqlite3-multiple-ciphers": "^12.11.1",
|
|
41
41
|
"node-pty": "^1.1.0",
|
|
42
|
-
"sharp": "^0.
|
|
43
|
-
"tar": "^7.5.
|
|
42
|
+
"sharp": "^0.35.3",
|
|
43
|
+
"tar": "^7.5.22",
|
|
44
44
|
"yauzl": "^3.4.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|