pinokiod 8.0.36 → 8.0.37

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.
@@ -0,0 +1,169 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+
4
+ // Persisted record of blobs and their known names.
5
+ // The registry is a cache of the filesystem, never the source of truth
6
+ // (spec/requirements/shared-model-store.md "Registry").
7
+ class Registry {
8
+ constructor(root) {
9
+ this.root = root
10
+ this.snapshotPath = path.resolve(root, "registry.json")
11
+ this.eventsPath = path.resolve(root, "events.ndjson")
12
+ this.reset()
13
+ this.persistTimer = null
14
+ this.persistDelay = 500
15
+ this.flushPromise = Promise.resolve()
16
+ }
17
+ reset() {
18
+ this.blobs = new Map() // hash -> { size, first_seen, source_urls, verified_at, orphan }
19
+ this.links = new Map() // path -> { hash, app, source_id, dev, ino, created, mode }
20
+ this.scanIndex = new Map() // path -> { size, mtime, dev, ino, hash, source_id }
21
+ this.duplicates = new Map() // path -> { hash, size, app, source_id, discovered } pending user conversion
22
+ this.excluded = new Map() // path -> { ts, source_id } user chose "keep independent"
23
+ this.lastScan = null // { ts, dirs, files, bytes_total, home_bytes_total, source_bytes }
24
+ this.totals = { lifetime_bytes_saved: 0 }
25
+ this.byIno = new Map() // "dev:ino" -> hash
26
+ }
27
+ setLastScan(meta) {
28
+ this.lastScan = meta
29
+ this.schedulePersist()
30
+ }
31
+ inoKey(dev, ino) {
32
+ return `${dev}:${ino}`
33
+ }
34
+ addBlob(hash, meta) {
35
+ if (!this.blobs.has(hash)) {
36
+ this.blobs.set(hash, Object.assign({
37
+ size: 0,
38
+ first_seen: Date.now(),
39
+ source_urls: [],
40
+ verified_at: null
41
+ }, meta))
42
+ } else if (meta && meta.source_urls && meta.source_urls.length) {
43
+ const blob = this.blobs.get(hash)
44
+ for (const url of meta.source_urls) {
45
+ if (!blob.source_urls.includes(url)) blob.source_urls.push(url)
46
+ }
47
+ }
48
+ this.schedulePersist()
49
+ }
50
+ addLink(filePath, entry) {
51
+ this.links.set(filePath, Object.assign({ created: Date.now(), mode: "link" }, entry))
52
+ if (entry.dev !== undefined && entry.ino !== undefined) {
53
+ this.byIno.set(this.inoKey(entry.dev, entry.ino), entry.hash)
54
+ }
55
+ this.schedulePersist()
56
+ }
57
+ removeLink(filePath) {
58
+ const entry = this.links.get(filePath)
59
+ if (entry) {
60
+ this.links.delete(filePath)
61
+ const key = this.inoKey(entry.dev, entry.ino)
62
+ let stillUsed = false
63
+ for (const other of this.links.values()) {
64
+ if (this.inoKey(other.dev, other.ino) === key) { stillUsed = true; break }
65
+ }
66
+ if (!stillUsed) this.byIno.delete(key)
67
+ this.schedulePersist()
68
+ }
69
+ }
70
+ removeBlob(hash) {
71
+ this.blobs.delete(hash)
72
+ for (const [p, entry] of [...this.links]) {
73
+ if (entry.hash === hash) this.removeLink(p)
74
+ }
75
+ this.schedulePersist()
76
+ }
77
+ addSaved(bytes) {
78
+ this.totals.lifetime_bytes_saved += bytes
79
+ this.schedulePersist()
80
+ }
81
+ // Returns { corrupt: true } when the snapshot exists but cannot be parsed,
82
+ // so the caller can rebuild from disk instead of blocking startup.
83
+ async load() {
84
+ this.reset()
85
+ let raw
86
+ try {
87
+ raw = await fs.promises.readFile(this.snapshotPath, "utf8")
88
+ } catch (e) {
89
+ return { corrupt: false, existed: false }
90
+ }
91
+ let json
92
+ try {
93
+ json = JSON.parse(raw)
94
+ } catch (e) {
95
+ return { corrupt: true, existed: true }
96
+ }
97
+ for (const [k, v] of Object.entries(json.blobs || {})) this.blobs.set(k, v)
98
+ for (const [k, v] of Object.entries(json.links || {})) {
99
+ this.links.set(k, v)
100
+ if (v.dev !== undefined && v.ino !== undefined) {
101
+ this.byIno.set(this.inoKey(v.dev, v.ino), v.hash)
102
+ }
103
+ }
104
+ for (const [k, v] of Object.entries(json.scan_index || {})) this.scanIndex.set(k, v)
105
+ for (const [k, v] of Object.entries(json.duplicates || {})) this.duplicates.set(k, v)
106
+ for (const [k, v] of Object.entries(json.excluded || {})) this.excluded.set(k, v)
107
+ if (json.last_scan) this.lastScan = json.last_scan
108
+ if (json.totals && typeof json.totals.lifetime_bytes_saved === "number") {
109
+ this.totals = json.totals
110
+ }
111
+ return { corrupt: false, existed: true }
112
+ }
113
+ schedulePersist() {
114
+ if (this.persistTimer) return
115
+ this.persistTimer = setTimeout(() => {
116
+ this.persistTimer = null
117
+ this.flush().catch(() => {})
118
+ }, this.persistDelay)
119
+ if (this.persistTimer.unref) this.persistTimer.unref()
120
+ }
121
+ async flush() {
122
+ if (this.persistTimer) {
123
+ clearTimeout(this.persistTimer)
124
+ this.persistTimer = null
125
+ }
126
+ const json = {
127
+ version: 1,
128
+ blobs: Object.fromEntries(this.blobs),
129
+ links: Object.fromEntries(this.links),
130
+ scan_index: Object.fromEntries(this.scanIndex),
131
+ duplicates: Object.fromEntries(this.duplicates),
132
+ excluded: Object.fromEntries(this.excluded),
133
+ last_scan: this.lastScan,
134
+ totals: this.totals
135
+ }
136
+ const write = async () => {
137
+ const tmp = this.snapshotPath + ".tmp"
138
+ await fs.promises.mkdir(this.root, { recursive: true })
139
+ await fs.promises.writeFile(tmp, JSON.stringify(json))
140
+ await fs.promises.rename(tmp, this.snapshotPath)
141
+ }
142
+ const pending = this.flushPromise.then(write, write)
143
+ this.flushPromise = pending.catch(() => {})
144
+ return pending
145
+ }
146
+ async appendEvent(event) {
147
+ const line = JSON.stringify(Object.assign({ ts: Date.now() }, event)) + "\n"
148
+ await fs.promises.appendFile(this.eventsPath, line).catch(() => {})
149
+ }
150
+ // Torn final line (crash mid-append) is discarded, per persistence rules.
151
+ async readEvents() {
152
+ let raw
153
+ try {
154
+ raw = await fs.promises.readFile(this.eventsPath, "utf8")
155
+ } catch (e) {
156
+ return []
157
+ }
158
+ const events = []
159
+ for (const line of raw.split("\n")) {
160
+ if (!line.trim()) continue
161
+ try {
162
+ events.push(JSON.parse(line))
163
+ } catch (e) {}
164
+ }
165
+ return events
166
+ }
167
+ }
168
+
169
+ module.exports = Registry
@@ -0,0 +1,309 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const fastq = require('fastq')
4
+
5
+ // Manual scan engine (spec/requirements/shared-model-store.md).
6
+ // Scans run ONLY when the user asks — there are no automatic triggers.
7
+ // The walk is generic: every regular file counts toward folder totals, and
8
+ // every file >= the size threshold is a candidate. No name heuristics.
9
+ // A scan never converts anything: it adopts new content (adds a vault name,
10
+ // which mutates nothing) and lists byte-identical copies as pending, for the
11
+ // user to deduplicate explicitly.
12
+
13
+ const SHA256_RE = /^[0-9a-f]{64}$/
14
+ const TMP_SUFFIX = ".pinokio-dedup-tmp"
15
+
16
+ class Sweeper {
17
+ constructor(vault) {
18
+ this.vault = vault
19
+ this.kernel = vault.kernel
20
+ this.state = this.idleState()
21
+ this.currentHash = null
22
+ this.hashQueue = fastq.promise(this, this._hashJob, 1)
23
+ this.stats = { hashes: 0, ingested: 0 }
24
+ }
25
+ idleState() {
26
+ return {
27
+ active: false, dirs: 0, files: 0, bytes_total: 0,
28
+ home_bytes_total: 0, source_bytes: {},
29
+ candidates: 0, hashed: 0, queued: 0, started: null
30
+ }
31
+ }
32
+ // The one entry point: the Pinokio home plus explicit linked imports.
33
+ async scan() {
34
+ if (this.state.active) return { already_running: true }
35
+ this.state = Object.assign(this.idleState(), { active: true, started: Date.now() })
36
+ try {
37
+ await this.vault.refreshSources()
38
+ for (const source of this.vault.scanRoots()) {
39
+ await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
40
+ }
41
+ await this.settle()
42
+ this.vault.registry.setLastScan({
43
+ ts: Date.now(),
44
+ dirs: this.state.dirs,
45
+ files: this.state.files,
46
+ bytes_total: this.state.bytes_total,
47
+ home_bytes_total: this.state.home_bytes_total,
48
+ source_bytes: Object.assign({}, this.state.source_bytes)
49
+ })
50
+ } finally {
51
+ this.state.active = false
52
+ }
53
+ return {
54
+ dirs: this.state.dirs, files: this.state.files,
55
+ bytes_total: this.state.bytes_total, candidates: this.state.candidates
56
+ }
57
+ }
58
+ async settle() {
59
+ await this.hashQueue.drained()
60
+ }
61
+ async walk(root, preferredSourceId = null) {
62
+ const vaultRoot = this.vault.root
63
+ const stack = [root]
64
+ while (stack.length) {
65
+ const dir = stack.pop()
66
+ this.state.dirs += 1
67
+ let entries
68
+ try {
69
+ entries = await fs.promises.readdir(dir, { withFileTypes: true })
70
+ } catch (e) {
71
+ continue
72
+ }
73
+ // HF hub blobs dir: filenames ARE sha256 for LFS files — no hashing.
74
+ if (path.basename(dir) === "blobs" && /^(models|datasets|spaces)--/.test(path.basename(path.dirname(dir)))) {
75
+ await this.ingestHfBlobs(dir, entries, preferredSourceId)
76
+ continue
77
+ }
78
+ for (const entry of entries) {
79
+ if (entry.isSymbolicLink()) continue
80
+ const full = path.resolve(dir, entry.name)
81
+ if (entry.isDirectory()) {
82
+ if (full === vaultRoot) continue
83
+ stack.push(full)
84
+ } else if (entry.isFile()) {
85
+ await this.considerFile(full, preferredSourceId)
86
+ }
87
+ }
88
+ }
89
+ }
90
+ async considerFile(filePath, preferredSourceId = null) {
91
+ if (filePath.endsWith(TMP_SUFFIX)) return
92
+ const registry = this.vault.registry
93
+ let st
94
+ try {
95
+ st = await fs.promises.stat(filePath)
96
+ } catch (e) {
97
+ return
98
+ }
99
+ this.state.files += 1
100
+ this.state.bytes_total += st.size
101
+ const sourceKey = preferredSourceId || "pinokio"
102
+ this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
103
+ if (!preferredSourceId) this.state.home_bytes_total += st.size
104
+ if (st.size < this.vault.sizeThreshold) return
105
+ if (registry.excluded.has(filePath)) return
106
+ this.state.candidates += 1
107
+ const source = this.vault.sourceForPath(filePath, preferredSourceId)
108
+ const sourceId = source ? source.id : null
109
+ const appName = source && source.kind === "app" ? source.app : null
110
+ // Known and unchanged: nothing to do.
111
+ const inoHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
112
+ if (inoHash && st.nlink > 1) {
113
+ const cached = registry.scanIndex.get(filePath)
114
+ const unchanged = cached && cached.size === st.size && cached.mtime === st.mtimeMs && cached.ino === st.ino
115
+ if (unchanged || !cached) {
116
+ if (!registry.links.has(filePath)) {
117
+ registry.addLink(filePath, { hash: inoHash, app: appName, source_id: sourceId, dev: st.dev, ino: st.ino, mode: "link" })
118
+ }
119
+ if (!cached) this.updateScanIndex(filePath, st, inoHash, sourceId)
120
+ return
121
+ }
122
+ }
123
+ const cached = registry.scanIndex.get(filePath)
124
+ if (cached && cached.size === st.size && cached.mtime === st.mtimeMs && cached.ino === st.ino && cached.hash) {
125
+ await this.classify(filePath, st, cached.hash, sourceId)
126
+ return
127
+ }
128
+ const harvested = await this.harvestLocalDirEtag(filePath, st)
129
+ if (harvested) {
130
+ await this.classify(filePath, st, harvested, sourceId)
131
+ return
132
+ }
133
+ this.state.queued += 1
134
+ this.hashQueue.push({ filePath, source_id: sourceId }).catch(() => {})
135
+ }
136
+ async _hashJob({ filePath, source_id }) {
137
+ this.currentHash = { path: filePath }
138
+ try {
139
+ const { hash } = await this.vault.hashFile(filePath)
140
+ this.stats.hashes += 1
141
+ this.state.hashed += 1
142
+ const st = await fs.promises.stat(filePath)
143
+ await this.classify(filePath, st, hash, source_id)
144
+ } catch (e) {
145
+ } finally {
146
+ this.currentHash = null
147
+ this.state.queued = Math.max(0, this.state.queued - 1)
148
+ }
149
+ }
150
+ // hash known → adopt if new content; otherwise a byte-identical copy:
151
+ // ALWAYS pending, never converted by a scan.
152
+ async classify(filePath, st, hash, preferredSourceId = null) {
153
+ const registry = this.vault.registry
154
+ const source = this.vault.sourceForPath(filePath, preferredSourceId)
155
+ const sourceId = source ? source.id : null
156
+ const appName = source && source.kind === "app" ? source.app : null
157
+ // Divergence: this inode was registered under a different hash — the
158
+ // shared content was written in place. Evict the stale blob.
159
+ const priorHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
160
+ if (priorHash && priorHash !== hash) {
161
+ const stalePath = this.vault.storePathFor(priorHash)
162
+ try {
163
+ const staleStat = await fs.promises.stat(stalePath)
164
+ if (staleStat.dev === st.dev && staleStat.ino === st.ino) {
165
+ await fs.promises.unlink(stalePath)
166
+ }
167
+ } catch (e) {}
168
+ registry.removeBlob(priorHash)
169
+ registry.appendEvent({ kind: "diverged", hash: priorHash, path: filePath, app: appName, source_id: sourceId, size: st.size })
170
+ }
171
+ const storePath = this.vault.storePathFor(hash)
172
+ let storeStat = null
173
+ try {
174
+ storeStat = await fs.promises.stat(storePath)
175
+ } catch (e) {}
176
+ if (!storeStat && !registry.blobs.has(hash)) {
177
+ await this.vault.adopt(filePath, hash, { app: appName, source_id: sourceId })
178
+ this.updateScanIndex(filePath, st, hash, sourceId)
179
+ return
180
+ }
181
+ if (storeStat && storeStat.dev === st.dev && storeStat.ino === st.ino) {
182
+ if (!registry.links.has(filePath)) {
183
+ registry.addLink(filePath, { hash, app: appName, source_id: sourceId, dev: st.dev, ino: st.ino, mode: "link" })
184
+ }
185
+ this.updateScanIndex(filePath, st, hash, sourceId)
186
+ return
187
+ }
188
+ if (!registry.duplicates.has(filePath)) {
189
+ registry.duplicates.set(filePath, { hash, size: st.size, app: appName, source_id: sourceId, discovered: Date.now() })
190
+ registry.schedulePersist()
191
+ registry.appendEvent({ kind: "found", hash, path: filePath, app: appName, source_id: sourceId, size: st.size })
192
+ }
193
+ this.updateScanIndex(filePath, st, hash, sourceId)
194
+ }
195
+ updateScanIndex(filePath, st, hash, sourceId = null) {
196
+ this.vault.registry.scanIndex.set(filePath, {
197
+ size: st.size, mtime: st.mtimeMs, dev: st.dev, ino: st.ino, hash: hash || null, source_id: sourceId
198
+ })
199
+ this.vault.registry.schedulePersist()
200
+ }
201
+ async ingestHfBlobs(dir, entries, preferredSourceId = null) {
202
+ for (const entry of entries) {
203
+ if (!entry.isFile() || entry.isSymbolicLink()) continue
204
+ const name = entry.name
205
+ const full = path.resolve(dir, name)
206
+ let st
207
+ try {
208
+ st = await fs.promises.stat(full)
209
+ } catch (e) { continue }
210
+ this.state.files += 1
211
+ this.state.bytes_total += st.size
212
+ const sourceKey = preferredSourceId || "pinokio"
213
+ this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
214
+ if (!preferredSourceId) this.state.home_bytes_total += st.size
215
+ if (!SHA256_RE.test(name)) continue
216
+ if (st.size < this.vault.sizeThreshold) continue
217
+ if (this.vault.registry.excluded.has(full)) continue
218
+ this.state.candidates += 1
219
+ this.stats.ingested += 1
220
+ await this.classify(full, st, name, preferredSourceId)
221
+ }
222
+ }
223
+ // hf CLI --local-dir metadata: etag is the sha256 for LFS files; freshness
224
+ // via the recorded timestamp against mtime (huggingface_hub's own rule).
225
+ async harvestLocalDirEtag(filePath, st) {
226
+ try {
227
+ let dir = path.dirname(filePath)
228
+ const stop = path.resolve(this.kernel.homedir)
229
+ const rel = []
230
+ for (let depth = 0; depth < 12; depth++) {
231
+ const metaRoot = path.resolve(dir, ".cache", "huggingface")
232
+ let exists = false
233
+ try {
234
+ exists = (await fs.promises.stat(metaRoot)).isDirectory()
235
+ } catch (e) {}
236
+ if (exists) {
237
+ const metaPath = path.resolve(metaRoot, ...rel, path.basename(filePath) + ".metadata")
238
+ let raw
239
+ try {
240
+ raw = await fs.promises.readFile(metaPath, "utf8")
241
+ } catch (e) {
242
+ return null
243
+ }
244
+ const lines = raw.split("\n")
245
+ const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
246
+ const ts = parseFloat(lines[2] || "0") * 1000
247
+ if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) {
248
+ return etag
249
+ }
250
+ return null
251
+ }
252
+ if (dir === stop || path.dirname(dir) === dir) return null
253
+ rel.unshift(path.basename(dir))
254
+ dir = path.dirname(dir)
255
+ }
256
+ } catch (e) {}
257
+ return null
258
+ }
259
+ appNameFor(filePath) {
260
+ return this.vault.appNameFor(filePath)
261
+ }
262
+ // User-triggered batch conversion (the Deduplicate buttons). Locked files
263
+ // stay pending; the user can retry after stopping the app.
264
+ async convertPending(appName, opts = {}) {
265
+ const registry = this.vault.registry
266
+ const batch = opts.batch_id || `batch-${Date.now()}`
267
+ const scopeId = opts.scope_id || null
268
+ const summary = { converted: 0, bytes_saved: 0, locked: 0, unavailable: 0, failed: 0 }
269
+ for (const [filePath, entry] of [...registry.duplicates]) {
270
+ const location = this.vault.locationForPath(filePath, entry.source_id)
271
+ if (scopeId && location.source_id !== scopeId) continue
272
+ if (!scopeId && appName !== undefined && (entry.app || null) !== appName) continue
273
+ try {
274
+ if (scopeId) {
275
+ const source = this.vault.sourceForPath(filePath, location.source_id)
276
+ let targetStat = null
277
+ let storeStat = null
278
+ try {
279
+ targetStat = await fs.promises.stat(filePath)
280
+ storeStat = await fs.promises.stat(this.vault.storePathFor(entry.hash))
281
+ } catch (e) {}
282
+ if (!source || !source.shareable || !targetStat || !storeStat || targetStat.dev !== storeStat.dev) {
283
+ summary.unavailable += 1
284
+ continue
285
+ }
286
+ }
287
+ const result = await this.vault.convert(filePath, entry.hash, {
288
+ app: entry.app, source_id: location.source_id, batch_id: batch
289
+ })
290
+ if (result.status === "converted" || result.status === "already") {
291
+ registry.duplicates.delete(filePath)
292
+ summary.converted += 1
293
+ summary.bytes_saved += result.bytes_saved || 0
294
+ } else if (result.status === "locked") {
295
+ summary.locked += 1
296
+ } else {
297
+ registry.duplicates.delete(filePath)
298
+ summary.failed += 1
299
+ }
300
+ } catch (e) {
301
+ summary.failed += 1
302
+ }
303
+ }
304
+ registry.schedulePersist()
305
+ return summary
306
+ }
307
+ }
308
+
309
+ module.exports = Sweeper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.36",
3
+ "version": "8.0.37",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -15732,6 +15732,97 @@ class Server {
15732
15732
  // }))
15733
15733
 
15734
15734
 
15735
+ // Vault dashboard data: everything from memory, never a walk. The scan
15736
+ // itself runs ONLY via the explicit scan action below.
15737
+ this.app.get("/info/dedup", ex(async (req, res) => {
15738
+ const vault = this.kernel.vault
15739
+ if (!vault || !vault.enabled) {
15740
+ res.json({ enabled: false })
15741
+ return
15742
+ }
15743
+ res.json(await vault.status())
15744
+ }))
15745
+ this.app.get("/vault", ex(async (req, res) => {
15746
+ res.render("vault", { theme: this.theme, agent: req.agent })
15747
+ }))
15748
+ this.app.post("/vault/action", ex(async (req, res) => {
15749
+ const vault = this.kernel.vault
15750
+ if (!vault || !vault.enabled) {
15751
+ res.json({ error: "vault is not enabled" })
15752
+ return
15753
+ }
15754
+ const body = req.body || {}
15755
+ try {
15756
+ switch (body.action) {
15757
+ case "deduplicate": {
15758
+ const scopeId = typeof body.scope_id === "string" ? body.scope_id : null
15759
+ if (scopeId) {
15760
+ const source = vault.sources().find((item) => item.id === scopeId && item.kind !== "virtual")
15761
+ if (!source) {
15762
+ res.json({ error: "That location is no longer available. Scan again to refresh it." })
15763
+ return
15764
+ }
15765
+ if (!source.shareable) {
15766
+ res.json({ error: "This location is on a disk that cannot share space with this vault." })
15767
+ return
15768
+ }
15769
+ if (source.kind === "app") {
15770
+ const appRoot = this.kernel.path("api", source.app) + path.sep
15771
+ const running = this.kernel.api && this.kernel.api.running ? this.kernel.api.running : {}
15772
+ const busy = Object.keys(running).some((k) => running[k] && k.startsWith(appRoot))
15773
+ if (busy) {
15774
+ res.json({ error: "This app has a running script. Stop it first, then deduplicate." })
15775
+ return
15776
+ }
15777
+ }
15778
+ res.json(await vault.sweeper.convertPending(undefined, {
15779
+ scope_id: scopeId, batch_id: `batch-${Date.now()}`
15780
+ }))
15781
+ return
15782
+ }
15783
+ const app = Object.prototype.hasOwnProperty.call(body, "app") ? (body.app || null) : undefined
15784
+ if (app) {
15785
+ const appRoot = this.kernel.path("api", app) + path.sep
15786
+ const running = this.kernel.api && this.kernel.api.running ? this.kernel.api.running : {}
15787
+ const busy = Object.keys(running).some((k) => running[k] && k.startsWith(appRoot))
15788
+ if (busy) {
15789
+ res.json({ error: "This app has a running script. Stop it first, then deduplicate." })
15790
+ return
15791
+ }
15792
+ }
15793
+ res.json(await vault.sweeper.convertPending(app, { batch_id: `batch-${Date.now()}` }))
15794
+ return
15795
+ }
15796
+ case "reclaim":
15797
+ res.json(await vault.reclaim(body.hash))
15798
+ return
15799
+ case "reclaim_all":
15800
+ res.json(await vault.reclaimAll())
15801
+ return
15802
+ case "scan":
15803
+ vault.sweeper.scan().catch(() => {})
15804
+ res.json({ started: true })
15805
+ return
15806
+ case "rebuild":
15807
+ await vault.rebuild()
15808
+ res.json({ done: true })
15809
+ return
15810
+ case "undo":
15811
+ res.json(await vault.undoBatch(body.batch_id))
15812
+ return
15813
+ case "detach":
15814
+ res.json(await vault.detach(body.path))
15815
+ return
15816
+ case "reshare":
15817
+ res.json(await vault.reshare(body.path))
15818
+ return
15819
+ default:
15820
+ res.json({ error: "unknown action" })
15821
+ }
15822
+ } catch (e) {
15823
+ res.json({ error: e && e.message ? e.message : String(e) })
15824
+ }
15825
+ }))
15735
15826
  this.app.get("/info/scripts", ex(async (req, res) => {
15736
15827
  /*
15737
15828
  returns something like this by using the this.kernel.memory.local variable, extracting the api name and adding all running scripts in each associated array, setting the uri as the script path, and the local variables as the local attribute
@@ -26,6 +26,7 @@
26
26
  <div class='main-sidebar-section' aria-label="Manage">
27
27
  <div class="main-sidebar-section-title">Manage</div>
28
28
  <a class="tab <%= sidebarSelected === 'checkpoints' ? 'selected' : '' %>" href="/checkpoints"><i class="fa-solid fa-clock-rotate-left"></i><div class='caption'>Checkpoints</div></a>
29
+ <a class="tab <%= sidebarSelected === 'vault' ? 'selected' : '' %>" href="/vault"><i class="fa-solid fa-hard-drive"></i><div class='caption'>Vault</div></a>
29
30
  <a class="tab <%= sidebarSelected === 'tools' ? 'selected' : '' %>" href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Tools</div></a>
30
31
  <a class="tab <%= sidebarSelected === 'plugins' ? 'selected' : '' %>" href="/plugins"><i class="fa-solid fa-plug-circle-bolt"></i><div class='caption'>Plugins</div></a>
31
32
  <a class="tab <%= sidebarSelected === 'tasks' ? 'selected' : '' %>" href="/tasks"><i class="fa-solid fa-list-check"></i><div class='caption'>Tasks</div></a>