jishushell 0.4.30 → 0.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/apps/anythingllm-container.yaml +287 -0
  2. package/apps/browserless-chromium-container.yaml +18 -6
  3. package/apps/filebrowser-container.yaml +163 -0
  4. package/apps/openclaw-binary.yaml +8 -0
  5. package/apps/openclaw-container.yaml +9 -1
  6. package/apps/openclaw-with-searxng-container.yaml +4 -0
  7. package/apps/searxng-container.yaml +5 -4
  8. package/apps/weknora-container.yaml +471 -0
  9. package/dist/cli/panel.js.map +1 -1
  10. package/dist/config.d.ts +19 -0
  11. package/dist/config.js +99 -1
  12. package/dist/config.js.map +1 -1
  13. package/dist/install.js +3 -3
  14. package/dist/install.js.map +1 -1
  15. package/dist/routes/auth.js +2 -2
  16. package/dist/routes/auth.js.map +1 -1
  17. package/dist/routes/backup.js +64 -11
  18. package/dist/routes/backup.js.map +1 -1
  19. package/dist/routes/external-mounts.d.ts +17 -0
  20. package/dist/routes/external-mounts.js +73 -0
  21. package/dist/routes/external-mounts.js.map +1 -0
  22. package/dist/routes/file-mounts.d.ts +13 -0
  23. package/dist/routes/file-mounts.js +90 -0
  24. package/dist/routes/file-mounts.js.map +1 -0
  25. package/dist/routes/files-organize.d.ts +28 -0
  26. package/dist/routes/files-organize.js +167 -0
  27. package/dist/routes/files-organize.js.map +1 -0
  28. package/dist/routes/files.d.ts +31 -0
  29. package/dist/routes/files.js +321 -0
  30. package/dist/routes/files.js.map +1 -0
  31. package/dist/routes/instances.js +45 -7
  32. package/dist/routes/instances.js.map +1 -1
  33. package/dist/routes/internal.d.ts +2 -0
  34. package/dist/routes/internal.js +59 -0
  35. package/dist/routes/internal.js.map +1 -0
  36. package/dist/routes/setup.js +9 -9
  37. package/dist/routes/setup.js.map +1 -1
  38. package/dist/routes/system.js +1 -1
  39. package/dist/routes/system.js.map +1 -1
  40. package/dist/routes/webdav.d.ts +17 -0
  41. package/dist/routes/webdav.js +114 -0
  42. package/dist/routes/webdav.js.map +1 -0
  43. package/dist/server.js +341 -3
  44. package/dist/server.js.map +1 -1
  45. package/dist/services/app/app-compiler.d.ts +1 -1
  46. package/dist/services/app/app-compiler.js +5 -5
  47. package/dist/services/app/app-compiler.js.map +1 -1
  48. package/dist/services/app/app-manager.d.ts +1 -0
  49. package/dist/services/app/app-manager.js +172 -41
  50. package/dist/services/app/app-manager.js.map +1 -1
  51. package/dist/services/app/custom-manager.js.map +1 -1
  52. package/dist/services/app/hermes-agent-manager.js +1 -0
  53. package/dist/services/app/hermes-agent-manager.js.map +1 -1
  54. package/dist/services/app/ollama-manager.js +1 -1
  55. package/dist/services/app/ollama-manager.js.map +1 -1
  56. package/dist/services/app/openclaw-manager.js +20 -3
  57. package/dist/services/app/openclaw-manager.js.map +1 -1
  58. package/dist/services/app/platform-transform.d.ts +32 -0
  59. package/dist/services/app/platform-transform.js +65 -0
  60. package/dist/services/app/platform-transform.js.map +1 -0
  61. package/dist/services/app-passwords.d.ts +61 -0
  62. package/dist/services/app-passwords.js +173 -0
  63. package/dist/services/app-passwords.js.map +1 -0
  64. package/dist/services/backup-manager.d.ts +11 -0
  65. package/dist/services/backup-manager.js +177 -4
  66. package/dist/services/backup-manager.js.map +1 -1
  67. package/dist/services/connection-apply.d.ts +2 -0
  68. package/dist/services/connection-apply.js +55 -1
  69. package/dist/services/connection-apply.js.map +1 -1
  70. package/dist/services/connection-resolver.js +1 -1
  71. package/dist/services/connection-resolver.js.map +1 -1
  72. package/dist/services/connection-transactor.d.ts +2 -0
  73. package/dist/services/connection-transactor.js +12 -2
  74. package/dist/services/connection-transactor.js.map +1 -1
  75. package/dist/services/external-mounts.d.ts +40 -0
  76. package/dist/services/external-mounts.js +187 -0
  77. package/dist/services/external-mounts.js.map +1 -0
  78. package/dist/services/files-manager.d.ts +252 -0
  79. package/dist/services/files-manager.js +1075 -0
  80. package/dist/services/files-manager.js.map +1 -0
  81. package/dist/services/files-mounts.d.ts +42 -0
  82. package/dist/services/files-mounts.js +207 -0
  83. package/dist/services/files-mounts.js.map +1 -0
  84. package/dist/services/instance-manager.js +1 -23
  85. package/dist/services/instance-manager.js.map +1 -1
  86. package/dist/services/llm-proxy/index.js.map +1 -1
  87. package/dist/services/llm-proxy/ssrf.js +6 -2
  88. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  89. package/dist/services/nomad-manager.d.ts +4 -0
  90. package/dist/services/nomad-manager.js +53 -19
  91. package/dist/services/nomad-manager.js.map +1 -1
  92. package/dist/services/organize/applier.d.ts +46 -0
  93. package/dist/services/organize/applier.js +218 -0
  94. package/dist/services/organize/applier.js.map +1 -0
  95. package/dist/services/organize/rules.d.ts +57 -0
  96. package/dist/services/organize/rules.js +286 -0
  97. package/dist/services/organize/rules.js.map +1 -0
  98. package/dist/services/organize/scanner.d.ts +50 -0
  99. package/dist/services/organize/scanner.js +366 -0
  100. package/dist/services/organize/scanner.js.map +1 -0
  101. package/dist/services/organize/store.d.ts +14 -0
  102. package/dist/services/organize/store.js +82 -0
  103. package/dist/services/organize/store.js.map +1 -0
  104. package/dist/services/panel-manager.js +20 -1
  105. package/dist/services/panel-manager.js.map +1 -1
  106. package/dist/services/process-manager.js +3 -2
  107. package/dist/services/process-manager.js.map +1 -1
  108. package/dist/services/runtime/adapters/hermes.js +1 -1
  109. package/dist/services/runtime/adapters/hermes.js.map +1 -1
  110. package/dist/services/runtime/adapters/openclaw-routes.d.ts +8 -2
  111. package/dist/services/runtime/adapters/openclaw-routes.js +68 -0
  112. package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -1
  113. package/dist/services/runtime/adapters/openclaw.d.ts +90 -0
  114. package/dist/services/runtime/adapters/openclaw.js +957 -45
  115. package/dist/services/runtime/adapters/openclaw.js.map +1 -1
  116. package/dist/services/runtime/instance.d.ts +1 -1
  117. package/dist/services/runtime/instance.js +1 -1
  118. package/dist/services/runtime/instance.js.map +1 -1
  119. package/dist/services/runtime/mcp-shims/anythingllm-shim.d.ts +46 -0
  120. package/dist/services/runtime/mcp-shims/anythingllm-shim.js +281 -0
  121. package/dist/services/runtime/mcp-shims/anythingllm-shim.js.map +1 -0
  122. package/dist/services/runtime/mcp-shims/drive-shim.d.ts +54 -0
  123. package/dist/services/runtime/mcp-shims/drive-shim.js +489 -0
  124. package/dist/services/runtime/mcp-shims/drive-shim.js.map +1 -0
  125. package/dist/services/runtime/types.d.ts +31 -0
  126. package/dist/services/setup-manager.js +93 -18
  127. package/dist/services/setup-manager.js.map +1 -1
  128. package/dist/services/suggestions.js.map +1 -1
  129. package/dist/services/webdav/server.d.ts +24 -0
  130. package/dist/services/webdav/server.js +420 -0
  131. package/dist/services/webdav/server.js.map +1 -0
  132. package/dist/services/webdav/xml-builder.d.ts +73 -0
  133. package/dist/services/webdav/xml-builder.js +156 -0
  134. package/dist/services/webdav/xml-builder.js.map +1 -0
  135. package/dist/services/workspace-builder.d.ts +29 -0
  136. package/dist/services/workspace-builder.js +188 -0
  137. package/dist/services/workspace-builder.js.map +1 -0
  138. package/dist/types.d.ts +60 -0
  139. package/dist/utils/path-locks.d.ts +30 -0
  140. package/dist/utils/path-locks.js +63 -0
  141. package/dist/utils/path-locks.js.map +1 -0
  142. package/dist/utils/path-safety.d.ts +41 -0
  143. package/dist/utils/path-safety.js +119 -0
  144. package/dist/utils/path-safety.js.map +1 -0
  145. package/dist/utils/safe-write.d.ts +24 -0
  146. package/dist/utils/safe-write.js +82 -0
  147. package/dist/utils/safe-write.js.map +1 -0
  148. package/package.json +16 -1
  149. package/public/assets/Dashboard-BdWPtroF.js +1 -0
  150. package/public/assets/{HermesChatPanel-_GHoklgo.js → HermesChatPanel-B_2HlVBQ.js} +1 -1
  151. package/public/assets/{HermesConfigForm-anDnwUp_.js → HermesConfigForm-DVlhg3WV.js} +2 -2
  152. package/public/assets/{InitPassword-ZU9_-hDr.js → InitPassword-D7glTExX.js} +1 -1
  153. package/public/assets/InstanceDetail-CxSy2cpe.js +92 -0
  154. package/public/assets/{Login-BItXqYAJ.js → Login-Cfr5c2sv.js} +1 -1
  155. package/public/assets/NewInstance-BIYDmJis.js +1 -0
  156. package/public/assets/{ProviderRecommendations-DFYj7Fb6.js → ProviderRecommendations-BuRnvRcI.js} +1 -1
  157. package/public/assets/{Settings-Bttc6QmM.js → Settings-Cc-tYBil.js} +1 -1
  158. package/public/assets/{Setup-Bsxx1zgj.js → Setup-lGZEk5jq.js} +1 -1
  159. package/public/assets/{WeixinLoginPanel-DPZpAKgO.js → WeixinLoginPanel-CoGqzxeV.js} +2 -2
  160. package/public/assets/index-87IJXG-w.css +1 -0
  161. package/public/assets/index-BZc5zH7u.js +19 -0
  162. package/public/assets/{registry-5s2UB6is.js → registry-BWnkJgZ1.js} +2 -2
  163. package/public/assets/{usePolling-Do5Erqm_.js → usePolling-CwwT9KrC.js} +1 -1
  164. package/public/assets/{vendor-i18n-ucpM0OR0.js → vendor-i18n-y9V7Sfuu.js} +1 -1
  165. package/public/assets/{vendor-react-Bk1hRGiY.js → vendor-react-BWrEVJVb.js} +6 -6
  166. package/public/index.html +4 -4
  167. package/scripts/check-app-spec.mjs +18 -4
  168. package/scripts/check-new-file-tests.mjs +230 -0
  169. package/scripts/check-quarantine-expiry.mjs +105 -0
  170. package/scripts/perf/README.md +49 -0
  171. package/scripts/perf/auth.js +99 -0
  172. package/scripts/perf/config.js +63 -0
  173. package/scripts/perf/instances.js +143 -0
  174. package/scripts/perf/proxy.js +96 -0
  175. package/scripts/smoke/files-w1.sh +142 -0
  176. package/scripts/smoke-backend.mjs +122 -0
  177. package/scripts/smoke-post-publish.mjs +346 -0
  178. package/public/assets/Dashboard-rkWp-CXd.js +0 -1
  179. package/public/assets/InstanceDetail-CN0FH1aw.js +0 -92
  180. package/public/assets/NewInstance-BousE6kY.js +0 -1
  181. package/public/assets/index-8xZy1z5k.css +0 -1
  182. package/public/assets/index-Dw3HhUYE.js +0 -19
@@ -0,0 +1,1075 @@
1
+ /**
2
+ * FilesManager — POSIX file CRUD for `~/.jishushell/files/` (M1 W1 PR-3).
3
+ *
4
+ * Source-of-truth: real filesystem (POSIX). sqlite indexing comes in W5
5
+ * (NAS module) but does not change the truth model.
6
+ *
7
+ * Streaming-first:
8
+ * - readStream() → fs.createReadStream
9
+ * - writeStream() → atomicWriteStream (tmp + sha256 transform + fsync + rename)
10
+ *
11
+ * ETag policy (W1):
12
+ * - Weak ETag W/"<size>-<mtime_ms>" — no sha256 scan on GET
13
+ * - If-Match uses weak comparison
14
+ * - Strong ETag (sha256 from index) added in W5
15
+ *
16
+ * Soft delete:
17
+ * - DELETE moves to ~/.jishushell/files/.trash/{YYYY-MM-DD}/{name}.{epoch}
18
+ * - 30-day retention cleaner is W7+; W1 just creates the directory
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { createHash } from "node:crypto";
23
+ import { DatabaseSync } from "node:sqlite";
24
+ import { resolveSafe, isHidden, PathSafetyError, } from "../utils/path-safety.js";
25
+ import { atomicWriteStream } from "../utils/safe-write.js";
26
+ import { withPathLock } from "../utils/path-locks.js";
27
+ import { safeWriteJson, safeReadJson } from "../utils/safe-json.js";
28
+ import { FILES_ROOT, FILES_TRASH_DIR, FILES_AUDIT_LOG, } from "../config.js";
29
+ import { resolveAcrossMounts, } from "./external-mounts.js";
30
+ import { homedir } from "node:os";
31
+ export class FilesError extends Error {
32
+ code;
33
+ httpStatus;
34
+ constructor(message, code, httpStatus) {
35
+ super(message);
36
+ this.code = code;
37
+ this.httpStatus = httpStatus;
38
+ this.name = "FilesError";
39
+ }
40
+ }
41
+ // ── Defaults ─────────────────────────────────────────
42
+ const DEFAULT_BLOCKED_EXTENSIONS = new Set([
43
+ ".exe",
44
+ ".bat",
45
+ ".cmd",
46
+ ".ps1",
47
+ ".sh",
48
+ ".bash",
49
+ ".zsh",
50
+ ".com",
51
+ ".scr",
52
+ ".msi",
53
+ ".app",
54
+ ]);
55
+ const DEFAULT_QUOTA_MB = 10240; // 10 GB
56
+ const DEFAULT_MAX_UPLOAD_MB = 50;
57
+ const DEFAULT_TEXT_PREVIEW_KB = 256;
58
+ // Extensions treated as indexable text even when MIME is not text/*
59
+ const INDEXABLE_EXTS = new Set([
60
+ ".md", ".txt", ".log", ".csv", ".tsv", ".json", ".yaml", ".yml",
61
+ ".toml", ".ini", ".cfg", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs",
62
+ ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
63
+ ".sh", ".html", ".css", ".xml", ".sql",
64
+ ]);
65
+ // Minimal MIME map covering the formats Files Tab actually previews +
66
+ // common archive/binary types. Avoid pulling a 100KB dep for W1.
67
+ const MIME_MAP = {
68
+ ".txt": "text/plain; charset=utf-8",
69
+ ".md": "text/markdown; charset=utf-8",
70
+ ".json": "application/json",
71
+ ".yaml": "application/yaml",
72
+ ".yml": "application/yaml",
73
+ ".csv": "text/csv; charset=utf-8",
74
+ ".log": "text/plain; charset=utf-8",
75
+ ".html": "text/html; charset=utf-8",
76
+ ".css": "text/css; charset=utf-8",
77
+ ".js": "application/javascript",
78
+ ".ts": "application/typescript",
79
+ ".xml": "application/xml",
80
+ ".jpg": "image/jpeg",
81
+ ".jpeg": "image/jpeg",
82
+ ".png": "image/png",
83
+ ".webp": "image/webp",
84
+ ".gif": "image/gif",
85
+ ".svg": "image/svg+xml",
86
+ ".bmp": "image/bmp",
87
+ ".pdf": "application/pdf",
88
+ ".mp3": "audio/mpeg",
89
+ ".wav": "audio/wav",
90
+ ".ogg": "audio/ogg",
91
+ ".mp4": "video/mp4",
92
+ ".webm": "video/webm",
93
+ ".mov": "video/quicktime",
94
+ ".zip": "application/zip",
95
+ ".tar": "application/x-tar",
96
+ ".gz": "application/gzip",
97
+ };
98
+ function lookupMime(p) {
99
+ const ext = path.extname(p).toLowerCase();
100
+ return MIME_MAP[ext] ?? "application/octet-stream";
101
+ }
102
+ // ── AI-FS W4: Knowledge-dir README ───────────────────
103
+ const KNOWLEDGE_README_TEMPLATE = `# \`.knowledge/\` — Panel-private AI metadata
104
+
105
+ This directory is **maintained by the JishuShell panel**, not by you.
106
+
107
+ It stores two kinds of artifacts that let the AI agent answer questions about
108
+ your files without re-reading them from scratch each time:
109
+
110
+ - **\`index.sqlite\`** — FTS5 full-text search index over text / markdown / code /
111
+ json / yaml content under \`~/.jishushell/files/\`. Rebuilt on demand when the
112
+ agent calls \`drive_reindex\`. Never auto-rebuilt on file changes.
113
+ - **\`<sha256>.json\`** — Per-file metadata sidecars. Each file is keyed by its
114
+ content hash, not its path, so renaming a file does not lose its tags or
115
+ notes. Schema: \`{ schema_version, sha256, size, mtime, indexed_at, tags?,
116
+ summary?, links?, agent_notes?, ... }\`.
117
+
118
+ ## Why it's here, not somewhere hidden
119
+
120
+ You own this filesystem. We surface \`.knowledge/\` instead of stashing state
121
+ inside a database the panel keeps to itself, so backups, snapshots, and
122
+ disaster recovery work without coordinating with the panel. If you copy
123
+ \`~/.jishushell/files/\` to another machine, the AI knowledge moves with it.
124
+
125
+ ## Safe to do
126
+
127
+ - **Browse it** — to satisfy curiosity.
128
+ - **Back it up** — it's part of your data, treat it like the rest of the tree.
129
+ - **Delete the whole directory** — the panel will recreate it next time the
130
+ agent uses \`drive_*\` tools; you just lose the cached index and any
131
+ agent-authored notes/tags.
132
+
133
+ ## Don't do
134
+
135
+ - **Don't hand-edit** \`<sha256>.json\` files. Use the agent (or a future panel
136
+ UI) to write metadata. Hand-edits work but are not validated — easy to
137
+ corrupt schemas.
138
+ - **Don't rename or move this directory** — the panel hard-codes the path.
139
+
140
+ ## Want to ignore it?
141
+
142
+ Filebrowser shows hidden dotfiles by default. If \`.knowledge/\` clutter bothers
143
+ you, toggle "Hide dotfiles" in your Filebrowser user settings; the panel doesn't
144
+ care whether you see it or not.
145
+
146
+ — jishushell @ AI-FS v1
147
+ `;
148
+ // ── Class ────────────────────────────────────────────
149
+ export class FilesManager {
150
+ filesRoot;
151
+ trashRoot;
152
+ auditLog;
153
+ quotaMb;
154
+ maxUploadMb;
155
+ textPreviewMaxKb;
156
+ blockedExtensions;
157
+ externalMounts;
158
+ _indexDb = null;
159
+ _indexDbRegistered = false;
160
+ constructor(cfg = {}) {
161
+ this.filesRoot = cfg.filesRoot ?? FILES_ROOT;
162
+ this.trashRoot = cfg.trashRoot ?? FILES_TRASH_DIR;
163
+ this.auditLog = cfg.auditLog ?? FILES_AUDIT_LOG;
164
+ this.quotaMb = cfg.quotaMb ?? DEFAULT_QUOTA_MB;
165
+ this.maxUploadMb = cfg.maxUploadMb ?? DEFAULT_MAX_UPLOAD_MB;
166
+ this.textPreviewMaxKb = cfg.textPreviewMaxKb ?? DEFAULT_TEXT_PREVIEW_KB;
167
+ this.blockedExtensions =
168
+ cfg.blockedExtensions ?? DEFAULT_BLOCKED_EXTENSIONS;
169
+ this.externalMounts = cfg.externalMounts ?? [];
170
+ fs.mkdirSync(this.filesRoot, { recursive: true });
171
+ }
172
+ /**
173
+ * Replace the current external-mount set; used when panel.json is
174
+ * mutated through routes/external-mounts.ts so a new mount becomes
175
+ * visible without restarting the server.
176
+ */
177
+ setExternalMounts(mounts) {
178
+ this.externalMounts = [...mounts];
179
+ }
180
+ // ── Listing ─────────────────────────────────────
181
+ async list(relPath, opts = {}) {
182
+ const abs = this.resolve(relPath);
183
+ const stat = this.statOrThrow(abs);
184
+ if (!stat.isDirectory()) {
185
+ throw new FilesError("not a directory", "not-dir", 400);
186
+ }
187
+ const showHidden = opts.showHidden === true;
188
+ const names = fs.readdirSync(abs);
189
+ const entries = [];
190
+ for (const name of names) {
191
+ if (!showHidden && name.startsWith("."))
192
+ continue;
193
+ const childAbs = path.join(abs, name);
194
+ let childStat;
195
+ try {
196
+ // lstat: do NOT follow symlinks during listing (defense layer 3)
197
+ childStat = fs.lstatSync(childAbs);
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ // Skip symlinks entirely from listings — present as a separate concept
203
+ // if/when we want them. W1 hides them.
204
+ if (childStat.isSymbolicLink())
205
+ continue;
206
+ const entry = {
207
+ name,
208
+ is_dir: childStat.isDirectory(),
209
+ size: childStat.isDirectory() ? 0 : childStat.size,
210
+ mtime: Math.floor(childStat.mtimeMs / 1000),
211
+ etag: makeWeakETag(childStat),
212
+ };
213
+ if (entry.is_dir) {
214
+ try {
215
+ entry.child_count = fs.readdirSync(childAbs).length;
216
+ }
217
+ catch {
218
+ /* ignore */
219
+ }
220
+ }
221
+ else {
222
+ entry.mime = lookupMime(name);
223
+ }
224
+ entries.push(entry);
225
+ }
226
+ // Merge external mount aliases as virtual entries when listing the
227
+ // root. Mounts inside subdirectories are not (intentionally) — keep
228
+ // mount surface flat at the top of files/.
229
+ if (relPath === "" && this.externalMounts.length > 0) {
230
+ const seen = new Set(entries.map((e) => e.name));
231
+ for (const m of this.externalMounts) {
232
+ if (seen.has(m.alias))
233
+ continue; // real entry shadows the mount
234
+ const mountRoot = m.host_path.startsWith("~")
235
+ ? path.join(homedir(), m.host_path.slice(1))
236
+ : path.resolve(m.host_path);
237
+ let mtime = 0;
238
+ let childCount;
239
+ try {
240
+ const s = fs.statSync(mountRoot);
241
+ mtime = Math.floor(s.mtimeMs / 1000);
242
+ if (s.isDirectory()) {
243
+ try {
244
+ childCount = fs.readdirSync(mountRoot).filter((n) => !n.startsWith(".")).length;
245
+ }
246
+ catch {
247
+ /* permission denied — fine, leave undefined */
248
+ }
249
+ }
250
+ }
251
+ catch {
252
+ /* host path missing — still surface the alias so user sees the
253
+ configuration even if the underlying directory is unavailable */
254
+ }
255
+ entries.push({
256
+ name: m.alias,
257
+ is_dir: true,
258
+ size: 0,
259
+ mtime,
260
+ etag: `W/"mount-${m.alias}-${mtime}"`,
261
+ child_count: childCount,
262
+ });
263
+ }
264
+ }
265
+ entries.sort(sortDirsFirst);
266
+ const quota = await this.quota();
267
+ return {
268
+ path: relPath,
269
+ entries,
270
+ quota_mb: quota.quota_mb,
271
+ used_mb: quota.used_mb,
272
+ available_mb: quota.available_mb,
273
+ };
274
+ }
275
+ // ── Reading ─────────────────────────────────────
276
+ async readStream(relPath) {
277
+ const abs = this.resolve(relPath);
278
+ const stat = this.statOrThrow(abs);
279
+ if (stat.isDirectory()) {
280
+ throw new FilesError("path is a directory", "is-dir", 400);
281
+ }
282
+ return {
283
+ openStream: () => fs.createReadStream(abs),
284
+ etag: makeWeakETag(stat),
285
+ mime: lookupMime(relPath),
286
+ size: stat.size,
287
+ mtime: Math.floor(stat.mtimeMs / 1000),
288
+ };
289
+ }
290
+ async readSmall(relPath, maxBytes) {
291
+ const abs = this.resolve(relPath);
292
+ const stat = this.statOrThrow(abs);
293
+ if (stat.isDirectory()) {
294
+ throw new FilesError("path is a directory", "is-dir", 400);
295
+ }
296
+ const target = Math.min(stat.size, maxBytes);
297
+ const fd = fs.openSync(abs, "r");
298
+ let buf = Buffer.alloc(target);
299
+ try {
300
+ let offset = 0;
301
+ while (offset < target) {
302
+ const got = fs.readSync(fd, buf, offset, target - offset, offset);
303
+ if (got === 0)
304
+ break;
305
+ offset += got;
306
+ }
307
+ buf = buf.subarray(0, offset);
308
+ }
309
+ finally {
310
+ fs.closeSync(fd);
311
+ }
312
+ return {
313
+ buf,
314
+ etag: makeWeakETag(stat),
315
+ mime: lookupMime(relPath),
316
+ truncated: stat.size > maxBytes,
317
+ size: stat.size,
318
+ };
319
+ }
320
+ // ── Writing ─────────────────────────────────────
321
+ async writeStream(relPath, source, opts = {}) {
322
+ if (relPath === "") {
323
+ throw new FilesError("cannot write to root", "invalid-path", 400);
324
+ }
325
+ this.checkExtension(relPath);
326
+ this.assertWritable(relPath);
327
+ const abs = this.resolve(relPath);
328
+ return withPathLock(abs, async () => {
329
+ // Defense layer 3 — ancestor symlinks are always rejected;
330
+ // a symlink AT the target is also rejected (we never write through
331
+ // a symlink, even with overwrite=true).
332
+ this.assertNoSymlinkInAncestors(abs);
333
+ let existedBefore = false;
334
+ let beforeSize = 0;
335
+ try {
336
+ const ls = fs.lstatSync(abs);
337
+ if (ls.isSymbolicLink()) {
338
+ throw new FilesError("target is a symlink", "symlink", 400);
339
+ }
340
+ if (ls.isDirectory()) {
341
+ throw new FilesError("target is a directory", "is-dir", 400);
342
+ }
343
+ existedBefore = true;
344
+ beforeSize = ls.size;
345
+ }
346
+ catch (e) {
347
+ if (e instanceof FilesError)
348
+ throw e;
349
+ if (e.code !== "ENOENT")
350
+ throw e;
351
+ }
352
+ if (existedBefore && opts.overwrite !== true) {
353
+ throw new FilesError("target exists (use overwrite=true)", "exists", 409);
354
+ }
355
+ if (existedBefore && opts.ifMatch !== undefined) {
356
+ const currentETag = makeWeakETag(fs.lstatSync(abs));
357
+ if (!weakETagMatch(opts.ifMatch, currentETag)) {
358
+ throw new FilesError("If-Match did not match current ETag", "etag-mismatch", 412);
359
+ }
360
+ }
361
+ // Pre-check quota using expectedSize when available
362
+ if (opts.expectedSize !== undefined) {
363
+ const delta = opts.expectedSize - beforeSize;
364
+ if (delta > 0)
365
+ await this.assertQuotaAvailable(delta);
366
+ if (opts.expectedSize >
367
+ this.maxUploadMb * 1024 * 1024) {
368
+ throw new FilesError(`file size exceeds max_upload_mb=${this.maxUploadMb}`, "too-large", 413);
369
+ }
370
+ }
371
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
372
+ const result = await atomicWriteStream(abs, source, {
373
+ expectedSize: opts.expectedSize,
374
+ });
375
+ // Defensive: re-check size against max_upload_mb in case expectedSize
376
+ // was not provided (e.g. chunked client). If exceeded, soft-delete.
377
+ const finalStat = fs.lstatSync(abs);
378
+ if (finalStat.size > this.maxUploadMb * 1024 * 1024) {
379
+ // Move to trash so we don't lose user data, then throw
380
+ try {
381
+ await this.removeInternal(abs, relPath);
382
+ }
383
+ catch {
384
+ /* best-effort */
385
+ }
386
+ throw new FilesError(`file size exceeds max_upload_mb=${this.maxUploadMb}`, "too-large", 413);
387
+ }
388
+ const etag = makeWeakETag(finalStat);
389
+ this.audit("write", relPath, { size: result.size });
390
+ return { etag, size: result.size, mime: lookupMime(relPath) };
391
+ });
392
+ }
393
+ // ── mkdir / move / remove ───────────────────────
394
+ async mkdir(relPath) {
395
+ if (relPath === "") {
396
+ throw new FilesError("cannot mkdir root", "invalid-path", 400);
397
+ }
398
+ this.assertWritable(relPath);
399
+ const abs = this.resolve(relPath);
400
+ this.assertNoSymlinkInAncestors(abs);
401
+ try {
402
+ const ls = fs.lstatSync(abs);
403
+ if (ls.isSymbolicLink()) {
404
+ throw new FilesError("path is a symlink", "symlink", 400);
405
+ }
406
+ throw new FilesError("path already exists", "exists", 409);
407
+ }
408
+ catch (e) {
409
+ if (e instanceof FilesError)
410
+ throw e;
411
+ if (e.code !== "ENOENT")
412
+ throw e;
413
+ }
414
+ fs.mkdirSync(abs, { recursive: true });
415
+ this.audit("mkdir", relPath);
416
+ }
417
+ async move(fromRel, toRel, overwrite = false) {
418
+ if (fromRel === "" || toRel === "") {
419
+ throw new FilesError("cannot move root", "invalid-path", 400);
420
+ }
421
+ this.assertWritable(fromRel); // moving FROM a ro mount = removing
422
+ this.assertWritable(toRel); // moving TO a ro mount = creating
423
+ const fromAbs = this.resolve(fromRel);
424
+ const toAbs = this.resolve(toRel);
425
+ this.checkExtension(toRel);
426
+ // Defense layer 3 on both endpoints
427
+ this.assertNoSymlinkInAncestors(fromAbs);
428
+ this.assertNoSymlinkInAncestors(toAbs);
429
+ let fromLstat;
430
+ try {
431
+ fromLstat = fs.lstatSync(fromAbs);
432
+ }
433
+ catch (e) {
434
+ if (e.code === "ENOENT") {
435
+ throw new FilesError("source not found", "not-found", 404);
436
+ }
437
+ throw e;
438
+ }
439
+ if (fromLstat.isSymbolicLink()) {
440
+ throw new FilesError("source is a symlink", "symlink", 400);
441
+ }
442
+ // Lock both paths in deterministic order to avoid deadlock
443
+ const sorted = [fromAbs, toAbs].sort();
444
+ const [first, second] = [sorted[0], sorted[1]];
445
+ return withPathLock(first, () => withPathLock(second, async () => {
446
+ try {
447
+ const toLstat = fs.lstatSync(toAbs);
448
+ if (toLstat.isSymbolicLink()) {
449
+ throw new FilesError("target is a symlink", "symlink", 400);
450
+ }
451
+ if (!overwrite) {
452
+ throw new FilesError("target exists", "exists", 409);
453
+ }
454
+ }
455
+ catch (e) {
456
+ if (e instanceof FilesError)
457
+ throw e;
458
+ if (e.code !== "ENOENT")
459
+ throw e;
460
+ }
461
+ fs.mkdirSync(path.dirname(toAbs), { recursive: true });
462
+ fs.renameSync(fromAbs, toAbs);
463
+ this.audit("move", fromRel, { to: toRel });
464
+ }));
465
+ }
466
+ async remove(relPath) {
467
+ if (relPath === "") {
468
+ throw new FilesError("cannot remove root", "invalid-path", 400);
469
+ }
470
+ this.assertWritable(relPath);
471
+ const abs = this.resolve(relPath);
472
+ this.assertNoSymlinkInAncestors(abs);
473
+ let ls;
474
+ try {
475
+ ls = fs.lstatSync(abs);
476
+ }
477
+ catch (e) {
478
+ if (e.code === "ENOENT") {
479
+ throw new FilesError("path not found", "not-found", 404);
480
+ }
481
+ throw e;
482
+ }
483
+ if (ls.isSymbolicLink()) {
484
+ throw new FilesError("cannot remove symlink", "symlink", 400);
485
+ }
486
+ return withPathLock(abs, () => this.removeInternal(abs, relPath));
487
+ }
488
+ async removeInternal(abs, relPath) {
489
+ const trashDay = path.join(this.trashRoot, ymd());
490
+ fs.mkdirSync(trashDay, { recursive: true });
491
+ const baseName = path.basename(abs);
492
+ const ts = Date.now();
493
+ const trashTarget = path.join(trashDay, `${baseName}.${ts}`);
494
+ fs.renameSync(abs, trashTarget);
495
+ this.audit("remove", relPath, { trash: trashTarget });
496
+ }
497
+ // ── Path resolution ─────────────────────────────
498
+ /**
499
+ * Resolve a files/-relative path to its absolute host filesystem path.
500
+ *
501
+ * Why a public API: the drive-shim (and the agents that call it via MCP)
502
+ * needs to hand the resulting absolute path to IM channel plugins
503
+ * (Feishu / WeChat / Telegram / ...) that send files. Those plugins read
504
+ * from the local filesystem, not via HTTP. The OpenClaw container runs
505
+ * with host==container bind mounts (see buildVolumes in
506
+ * src/services/runtime/adapters/openclaw.ts), so the same absolute
507
+ * string is valid on both sides; raw_exec / process service managers
508
+ * run the agent natively on the host, where the path is also directly
509
+ * usable.
510
+ *
511
+ * External mounts: a path whose first segment is an external mount
512
+ * alias resolves into the mount's host_path. The returned abs_path
513
+ * points at the real host location; external_mount is set so the
514
+ * caller can warn the user that this is outside files/. mode is
515
+ * propagated so writers know if it's read-only.
516
+ *
517
+ * Symlink policy: if the path resolves to a symlink we still surface
518
+ * the abs_path (callers may legitimately want to know where it points
519
+ * conceptually), but exists is reported as false to discourage handing
520
+ * symlinks to channel plugins.
521
+ */
522
+ async resolveLocalPath(relPath) {
523
+ const r = this.resolveOp(relPath);
524
+ const out = {
525
+ abs_path: r.abs,
526
+ exists: false,
527
+ };
528
+ if (r.ext) {
529
+ out.external_mount = r.ext.alias;
530
+ out.external_mount_mode = r.ext.mode;
531
+ }
532
+ try {
533
+ const ls = fs.lstatSync(r.abs);
534
+ if (ls.isSymbolicLink()) {
535
+ return out; // exists stays false; symlinks are not handed out
536
+ }
537
+ out.exists = true;
538
+ out.is_dir = ls.isDirectory();
539
+ out.size = ls.size;
540
+ out.mtime = Math.floor(ls.mtimeMs / 1000);
541
+ }
542
+ catch (e) {
543
+ if (e?.code !== "ENOENT" && e?.code !== "ENOTDIR") {
544
+ throw new FilesError(`stat failed: ${e.message}`, "stat", 500);
545
+ }
546
+ }
547
+ return out;
548
+ }
549
+ // ── Quota ───────────────────────────────────────
550
+ async quota() {
551
+ const used = dirSize(this.filesRoot, this.trashRoot);
552
+ const trash = fs.existsSync(this.trashRoot) ? dirSize(this.trashRoot) : 0;
553
+ const usedMb = used / (1024 * 1024);
554
+ const trashMb = trash / (1024 * 1024);
555
+ return {
556
+ quota_mb: this.quotaMb,
557
+ used_mb: round(usedMb),
558
+ available_mb: round(Math.max(0, this.quotaMb - usedMb)),
559
+ trash_mb: round(trashMb),
560
+ };
561
+ }
562
+ async assertQuotaAvailable(deltaBytes) {
563
+ if (deltaBytes <= 0)
564
+ return;
565
+ const q = await this.quota();
566
+ const availBytes = q.available_mb * 1024 * 1024;
567
+ if (deltaBytes > availBytes) {
568
+ throw new FilesError("quota exceeded", "quota", 507);
569
+ }
570
+ }
571
+ // ── Audit ───────────────────────────────────────
572
+ audit(op, relPath, meta = {}) {
573
+ try {
574
+ const line = JSON.stringify({
575
+ ts: Math.floor(Date.now() / 1000),
576
+ op,
577
+ path: relPath,
578
+ ...meta,
579
+ }) + "\n";
580
+ fs.mkdirSync(path.dirname(this.auditLog), { recursive: true });
581
+ fs.appendFileSync(this.auditLog, line);
582
+ }
583
+ catch (e) {
584
+ console.warn(`[files-audit] failed: ${e.message}`);
585
+ }
586
+ }
587
+ // ── Internals ───────────────────────────────────
588
+ /**
589
+ * Resolve a files/-relative path. If the first segment matches a
590
+ * registered external mount alias the path is virtualized into the
591
+ * mount's host_path; otherwise the path resolves under FILES_ROOT.
592
+ *
593
+ * Symlink defense (assertNoSymlinkInAncestors) and lstat checks in
594
+ * the calling op still run on the absolute path returned here, so
595
+ * symlinks INSIDE an external mount are still rejected.
596
+ */
597
+ resolve(relPath) {
598
+ return this.resolveOp(relPath).abs;
599
+ }
600
+ resolveOp(relPath) {
601
+ if (this.externalMounts.length > 0) {
602
+ const m = resolveAcrossMounts(relPath, this.externalMounts);
603
+ if (m) {
604
+ const mountRoot = m.mount.host_path.startsWith("~")
605
+ ? path.join(homedir(), m.mount.host_path.slice(1))
606
+ : path.resolve(m.mount.host_path);
607
+ try {
608
+ const abs = resolveSafe(mountRoot, m.remainder);
609
+ return { abs, ext: m.mount };
610
+ }
611
+ catch (e) {
612
+ if (e instanceof PathSafetyError) {
613
+ throw new FilesError(`invalid path: ${e.message}`, "invalid-path", 400);
614
+ }
615
+ throw e;
616
+ }
617
+ }
618
+ }
619
+ try {
620
+ return { abs: resolveSafe(this.filesRoot, relPath), ext: null };
621
+ }
622
+ catch (e) {
623
+ if (e instanceof PathSafetyError) {
624
+ throw new FilesError(`invalid path: ${e.message}`, "invalid-path", 400);
625
+ }
626
+ throw e;
627
+ }
628
+ }
629
+ /**
630
+ * Refuse mutations that target a read-only external mount. Called by
631
+ * write / move / delete / mkdir before any fs work.
632
+ */
633
+ assertWritable(rel) {
634
+ const r = this.resolveOp(rel);
635
+ if (r.ext && r.ext.mode === "ro") {
636
+ throw new FilesError(`mount "${r.ext.alias}" is read-only`, "read-only", 403);
637
+ }
638
+ }
639
+ /**
640
+ * Defense layer 3 (per path-safety.ts header): refuse symlinks at the
641
+ * target path AND refuse paths that traverse a symlink in their parent
642
+ * chain inside the trust root.
643
+ *
644
+ * For external mounts the trust root is the mount's host_path (the
645
+ * user explicitly chose that directory; symlinks inside it are their
646
+ * own filesystem and not an attack we need to defend against). For
647
+ * regular files/ paths the trust root is FILES_ROOT.
648
+ */
649
+ statOrThrow(abs) {
650
+ try {
651
+ const ls = fs.lstatSync(abs);
652
+ if (ls.isSymbolicLink()) {
653
+ throw new FilesError("symlink target is not allowed", "symlink", 400);
654
+ }
655
+ this.assertNoSymlinkInAncestors(abs);
656
+ return fs.statSync(abs);
657
+ }
658
+ catch (e) {
659
+ if (e instanceof FilesError)
660
+ throw e;
661
+ if (e.code === "ENOENT") {
662
+ throw new FilesError("path not found", "not-found", 404);
663
+ }
664
+ throw e;
665
+ }
666
+ }
667
+ /**
668
+ * Walk up from `abs` toward the relevant trust root and refuse if any
669
+ * intermediate component is a symlink. Trust root is FILES_ROOT for
670
+ * regular paths and the matching external-mount host_path for paths
671
+ * that fall inside an external mount.
672
+ */
673
+ assertNoSymlinkInAncestors(abs) {
674
+ const trustRoot = this.trustRootFor(abs);
675
+ let cur = path.dirname(abs);
676
+ while (cur.length >= trustRoot.length && cur !== path.dirname(cur)) {
677
+ if (cur === trustRoot)
678
+ return;
679
+ try {
680
+ const s = fs.lstatSync(cur);
681
+ if (s.isSymbolicLink()) {
682
+ throw new FilesError("path traverses a symlink ancestor", "symlink", 400);
683
+ }
684
+ }
685
+ catch (e) {
686
+ if (e instanceof FilesError)
687
+ throw e;
688
+ if (e.code !== "ENOENT")
689
+ throw e;
690
+ // missing intermediate is OK — write side may create it
691
+ }
692
+ cur = path.dirname(cur);
693
+ }
694
+ }
695
+ /**
696
+ * Find the trust root for an absolute path: either an external mount's
697
+ * host_path (if abs is inside one) or FILES_ROOT.
698
+ */
699
+ trustRootFor(abs) {
700
+ for (const m of this.externalMounts) {
701
+ const root = m.host_path.startsWith("~")
702
+ ? path.join(homedir(), m.host_path.slice(1))
703
+ : path.resolve(m.host_path);
704
+ if (abs === root || abs.startsWith(root + path.sep))
705
+ return root;
706
+ }
707
+ return this.filesRoot;
708
+ }
709
+ checkExtension(relPath) {
710
+ const ext = path.extname(relPath).toLowerCase();
711
+ if (this.blockedExtensions.has(ext)) {
712
+ throw new FilesError(`extension ${ext} is blocked`, "blocked-ext", 400);
713
+ }
714
+ }
715
+ // ── Knowledge index (AI-FS W3) ───────────────────
716
+ /**
717
+ * Returns the knowledge directory path (does NOT create it).
718
+ */
719
+ get knowledgeDir() {
720
+ return path.join(this.filesRoot, ".knowledge");
721
+ }
722
+ /**
723
+ * Returns the SQLite DB path.
724
+ */
725
+ get indexDbPath() {
726
+ return path.join(this.knowledgeDir, "index.sqlite");
727
+ }
728
+ /**
729
+ * Returns the sidecar path for a given sha256.
730
+ */
731
+ sidecarPath(sha256) {
732
+ return path.join(this.knowledgeDir, `${sha256}.json`);
733
+ }
734
+ /**
735
+ * Creates ~/.jishushell/files/.knowledge/ (mode 0700) and, on first
736
+ * creation only, writes a README.md explaining the directory is
737
+ * panel-private metadata. README is idempotent: only written when the
738
+ * file doesn't already exist, never overwritten.
739
+ */
740
+ ensureKnowledgeDir() {
741
+ fs.mkdirSync(this.knowledgeDir, { recursive: true, mode: 0o700 });
742
+ const readmePath = path.join(this.knowledgeDir, "README.md");
743
+ if (!fs.existsSync(readmePath)) {
744
+ try {
745
+ fs.writeFileSync(readmePath, KNOWLEDGE_README_TEMPLATE, { encoding: "utf8", mode: 0o644 });
746
+ }
747
+ catch (e) {
748
+ try {
749
+ this.audit("knowledge-readme-warn", readmePath, { err: e.message });
750
+ }
751
+ catch { /* ignore */ }
752
+ }
753
+ }
754
+ }
755
+ /**
756
+ * Lazy-initialize the SQLite FTS5 handle. Creates the .knowledge dir
757
+ * and the schema on first call.
758
+ */
759
+ getIndexDb() {
760
+ if (this._indexDb !== null)
761
+ return this._indexDb;
762
+ this.ensureKnowledgeDir();
763
+ const db = new DatabaseSync(this.indexDbPath);
764
+ db.exec(`
765
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(
766
+ sha256 UNINDEXED,
767
+ path,
768
+ content,
769
+ tags
770
+ )
771
+ `);
772
+ this._indexDb = db;
773
+ // Register process.exit close handler once per instance
774
+ if (!this._indexDbRegistered) {
775
+ this._indexDbRegistered = true;
776
+ process.once("beforeExit", () => {
777
+ try {
778
+ this._indexDb?.close();
779
+ }
780
+ catch { /* ignore */ }
781
+ });
782
+ }
783
+ return db;
784
+ }
785
+ /**
786
+ * Search the FTS5 index.
787
+ */
788
+ async searchIndex(query, opts) {
789
+ const q = query.trim();
790
+ if (!q) {
791
+ throw new FilesError("query must not be empty", "invalid-query", 400);
792
+ }
793
+ // Return empty if DB doesn't exist yet (no reindex run)
794
+ if (!fs.existsSync(this.indexDbPath)) {
795
+ this.audit("search_index", "", { query: q, hits: 0 });
796
+ return [];
797
+ }
798
+ const db = this.getIndexDb();
799
+ const limit = Math.min(opts?.limit ?? 20, 100);
800
+ let hits;
801
+ if (opts?.pathPrefix) {
802
+ // Escape LIKE special chars in prefix
803
+ const escaped = opts.pathPrefix.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
804
+ const likePattern = `${escaped}%`;
805
+ const rows = db.prepare(`SELECT path, sha256,
806
+ snippet(docs, 2, '<b>', '</b>', '…', 16) AS snippet,
807
+ bm25(docs) AS score
808
+ FROM docs
809
+ WHERE docs MATCH ?
810
+ AND path LIKE ? ESCAPE '\\'
811
+ ORDER BY score
812
+ LIMIT ?`).all(q, likePattern, limit);
813
+ hits = rows;
814
+ }
815
+ else {
816
+ const rows = db.prepare(`SELECT path, sha256,
817
+ snippet(docs, 2, '<b>', '</b>', '…', 16) AS snippet,
818
+ bm25(docs) AS score
819
+ FROM docs
820
+ WHERE docs MATCH ?
821
+ ORDER BY score
822
+ LIMIT ?`).all(q, limit);
823
+ hits = rows;
824
+ }
825
+ this.audit("search_index", "", { query: q, hits: hits.length });
826
+ return hits;
827
+ }
828
+ /**
829
+ * Read a sidecar JSON for a file identified by sha256 or path.
830
+ */
831
+ async getMeta(args) {
832
+ let sha256;
833
+ if (args.sha256) {
834
+ sha256 = args.sha256.toLowerCase();
835
+ if (!SHA256_RE.test(sha256)) {
836
+ throw new FilesError("invalid sha256 format", "invalid-sha256", 400);
837
+ }
838
+ }
839
+ else if (args.path) {
840
+ // Resolve path and stream-hash the file
841
+ const abs = this.resolve(args.path);
842
+ if (!fs.existsSync(abs)) {
843
+ throw new FilesError("path not found", "not-found", 404);
844
+ }
845
+ sha256 = await streamSha256(abs, 64 * 1024 * 1024);
846
+ }
847
+ else {
848
+ throw new FilesError("sha256 or path required", "invalid-args", 400);
849
+ }
850
+ this.audit("get_meta", sha256);
851
+ const sp = this.sidecarPath(sha256);
852
+ if (!fs.existsSync(sp))
853
+ return null;
854
+ const raw = safeReadJson(sp, "get_meta");
855
+ if (raw === null) {
856
+ this.audit("get_meta_warn", sha256, { warn: "sidecar malformed, treating as null" });
857
+ return null;
858
+ }
859
+ return raw;
860
+ }
861
+ /**
862
+ * Write (merge or replace) a sidecar for a given sha256.
863
+ */
864
+ async setMeta(sha256, payload, opts) {
865
+ const normalizedSha = sha256.toLowerCase();
866
+ if (!SHA256_RE.test(normalizedSha)) {
867
+ throw new FilesError("invalid sha256 format", "invalid-sha256", 400);
868
+ }
869
+ // Validate payload is JSON-serializable and within size limit
870
+ let serialized;
871
+ try {
872
+ serialized = JSON.stringify(payload);
873
+ }
874
+ catch {
875
+ throw new FilesError("payload is not JSON-serializable", "invalid-payload", 400);
876
+ }
877
+ if (Buffer.byteLength(serialized, "utf8") > 64 * 1024) {
878
+ throw new FilesError("payload exceeds 64KB limit", "payload-too-large", 413);
879
+ }
880
+ const merge = opts?.merge !== false; // default true
881
+ this.ensureKnowledgeDir();
882
+ let base = {};
883
+ if (merge) {
884
+ const existing = safeReadJson(this.sidecarPath(normalizedSha), "set_meta");
885
+ if (existing !== null)
886
+ base = existing;
887
+ }
888
+ const now = Math.floor(Date.now() / 1000);
889
+ const merged = {
890
+ ...base,
891
+ ...payload,
892
+ schema_version: 1,
893
+ sha256: normalizedSha,
894
+ indexed_at: now,
895
+ size: base.size ?? 0,
896
+ mtime: base.mtime ?? 0,
897
+ };
898
+ safeWriteJson(this.sidecarPath(normalizedSha), merged);
899
+ this.audit("set_meta", normalizedSha, { merge });
900
+ return merged;
901
+ }
902
+ /**
903
+ * Walk the file tree and upsert text-indexable files into the FTS5 index.
904
+ */
905
+ async reindex(opts) {
906
+ const walkRoot = opts?.pathPrefix
907
+ ? path.join(this.filesRoot, opts.pathPrefix)
908
+ : this.filesRoot;
909
+ const result = { indexed: 0, skipped: 0, errors: 0 };
910
+ if (!fs.existsSync(walkRoot)) {
911
+ this.audit("reindex", opts?.pathPrefix ?? "/", result);
912
+ return result;
913
+ }
914
+ const db = this.getIndexDb();
915
+ const upsertDelete = db.prepare("DELETE FROM docs WHERE sha256 = ?");
916
+ const upsertInsert = db.prepare("INSERT INTO docs(sha256, path, content, tags) VALUES (?, ?, ?, ?)");
917
+ const stack = [walkRoot];
918
+ while (stack.length > 0) {
919
+ const cur = stack.pop();
920
+ let entries;
921
+ try {
922
+ entries = fs.readdirSync(cur, { withFileTypes: true });
923
+ }
924
+ catch (e) {
925
+ result.errors++;
926
+ this.audit("reindex-error", cur, { err: e?.message });
927
+ continue;
928
+ }
929
+ for (const entry of entries) {
930
+ const absEntry = path.join(cur, entry.name);
931
+ // Compute rel path relative to filesRoot
932
+ const relEntry = path.relative(this.filesRoot, absEntry);
933
+ if (entry.isDirectory()) {
934
+ // Skip panel-private dirs
935
+ if (relEntry === ".knowledge" || relEntry.startsWith(".knowledge" + path.sep))
936
+ continue;
937
+ if (relEntry === ".trash" || relEntry.startsWith(".trash" + path.sep))
938
+ continue;
939
+ stack.push(absEntry);
940
+ continue;
941
+ }
942
+ if (!entry.isFile())
943
+ continue;
944
+ // Skip non-indexable files
945
+ const mime = lookupMime(entry.name);
946
+ const ext = path.extname(entry.name).toLowerCase();
947
+ if (!mime.startsWith("text/") && !INDEXABLE_EXTS.has(ext)) {
948
+ result.skipped++;
949
+ continue;
950
+ }
951
+ try {
952
+ // Compute sha256 via streaming (full file) and read up to 512KB for content
953
+ const sha = await streamSha256(absEntry);
954
+ const stat = fs.statSync(absEntry);
955
+ const readBytes = Math.min(stat.size, 512 * 1024);
956
+ let content = "";
957
+ if (readBytes > 0) {
958
+ const fd = fs.openSync(absEntry, "r");
959
+ try {
960
+ const buf = Buffer.alloc(readBytes);
961
+ let off = 0;
962
+ while (off < readBytes) {
963
+ const got = fs.readSync(fd, buf, off, readBytes - off, off);
964
+ if (got === 0)
965
+ break;
966
+ off += got;
967
+ }
968
+ content = buf.subarray(0, off).toString("utf8");
969
+ }
970
+ finally {
971
+ fs.closeSync(fd);
972
+ }
973
+ }
974
+ // Load tags from existing sidecar if present
975
+ const sp = this.sidecarPath(sha);
976
+ let tags = "";
977
+ if (fs.existsSync(sp)) {
978
+ const sc = safeReadJson(sp, "reindex");
979
+ if (sc?.tags && Array.isArray(sc.tags)) {
980
+ tags = sc.tags.join(" ");
981
+ }
982
+ }
983
+ upsertDelete.run(sha);
984
+ upsertInsert.run(sha, relEntry, content, tags);
985
+ result.indexed++;
986
+ }
987
+ catch (e) {
988
+ result.errors++;
989
+ this.audit("reindex-error", relEntry, { err: e?.message });
990
+ }
991
+ }
992
+ }
993
+ this.audit("reindex", opts?.pathPrefix ?? "/", result);
994
+ return result;
995
+ }
996
+ }
997
+ // ── Helpers ───────────────────────────────────────
998
+ function makeWeakETag(stat) {
999
+ return `W/"${stat.size}-${Math.floor(stat.mtimeMs)}"`;
1000
+ }
1001
+ function weakETagMatch(provided, current) {
1002
+ // RFC 7232 weak comparison: strip W/ from both, compare values
1003
+ const strip = (s) => s.replace(/^W\//, "");
1004
+ return strip(provided) === strip(current);
1005
+ }
1006
+ function ymd() {
1007
+ const d = new Date();
1008
+ const yyyy = d.getFullYear();
1009
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
1010
+ const dd = String(d.getDate()).padStart(2, "0");
1011
+ return `${yyyy}-${mm}-${dd}`;
1012
+ }
1013
+ function round(n) {
1014
+ return Math.round(n * 10) / 10;
1015
+ }
1016
+ function sortDirsFirst(a, b) {
1017
+ if (a.is_dir !== b.is_dir)
1018
+ return a.is_dir ? -1 : 1;
1019
+ return a.name.localeCompare(b.name, "zh-CN", { sensitivity: "base" });
1020
+ }
1021
+ function dirSize(rootAbs, exclude) {
1022
+ if (!fs.existsSync(rootAbs))
1023
+ return 0;
1024
+ let total = 0;
1025
+ const stack = [rootAbs];
1026
+ while (stack.length > 0) {
1027
+ const cur = stack.pop();
1028
+ if (exclude && (cur === exclude || cur.startsWith(exclude + path.sep))) {
1029
+ continue;
1030
+ }
1031
+ let entries;
1032
+ try {
1033
+ entries = fs.readdirSync(cur, { withFileTypes: true });
1034
+ }
1035
+ catch {
1036
+ continue;
1037
+ }
1038
+ for (const e of entries) {
1039
+ const full = path.join(cur, e.name);
1040
+ if (e.isSymbolicLink())
1041
+ continue;
1042
+ if (e.isDirectory()) {
1043
+ stack.push(full);
1044
+ }
1045
+ else if (e.isFile()) {
1046
+ try {
1047
+ total += fs.statSync(full).size;
1048
+ }
1049
+ catch {
1050
+ /* ignore */
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+ return total;
1056
+ }
1057
+ // Re-export isHidden so callers don't need a second import for this common check
1058
+ export { isHidden };
1059
+ // ── Knowledge-index helpers ───────────────────────
1060
+ const SHA256_RE = /^[a-f0-9]{64}$/i;
1061
+ /**
1062
+ * Stream-hash a file with SHA-256 and return the hex digest.
1063
+ * Reads the entire file (or up to `maxBytes` for large files) without
1064
+ * loading it fully into memory.
1065
+ */
1066
+ async function streamSha256(absPath, maxBytes) {
1067
+ return new Promise((resolve, reject) => {
1068
+ const hasher = createHash("sha256");
1069
+ const stream = fs.createReadStream(absPath, maxBytes !== undefined ? { end: maxBytes - 1 } : undefined);
1070
+ stream.on("data", (chunk) => hasher.update(chunk));
1071
+ stream.on("end", () => resolve(hasher.digest("hex")));
1072
+ stream.on("error", reject);
1073
+ });
1074
+ }
1075
+ //# sourceMappingURL=files-manager.js.map