dsh-memu-memory 1.1.1 → 1.1.3

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 (50) hide show
  1. package/AGENTS.md +8 -5
  2. package/README.md +22 -5
  3. package/README.zh.md +21 -4
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +41 -1
  8. package/docs/troubleshooting.md +395 -350
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +703 -50
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +61 -4
  20. package/lib/inject.js +195 -49
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/preset-scope.js +121 -0
  31. package/lib/private.js +139 -13
  32. package/lib/rpc.js +45 -4
  33. package/lib/state.js +109 -161
  34. package/lib/tools.js +9 -5
  35. package/lib/vector-migration.js +878 -0
  36. package/lib/vector-profile.js +941 -0
  37. package/package.json +3 -2
  38. package/scripts/acceptance-local.mjs +5 -4
  39. package/scripts/chain-matrix.mjs +527 -0
  40. package/scripts/check-all.mjs +19 -0
  41. package/scripts/check-doc-drift.mjs +353 -0
  42. package/scripts/diagnose-bindings.mjs +176 -0
  43. package/scripts/local-runtime-install.mjs +142 -0
  44. package/scripts/local-runtime-verify.mjs +56 -0
  45. package/scripts/local-vector-verify.mjs +93 -0
  46. package/scripts/recall-bench.mjs +813 -0
  47. package/scripts/repetition-ab-scenarios.mjs +93 -0
  48. package/scripts/repetition-detector-snapshot.mjs +314 -0
  49. package/scripts/repetition-eval-live.mjs +360 -0
  50. package/scripts/repetition-eval.mjs +702 -0
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ // Anti-drift gates for documented counts and tracked test files.
3
+ //
4
+ // Gate 1 - RPC method count:
5
+ // Count the top-level case labels inside the dispatch() function in lib/rpc.js.
6
+ // Other switch statements in that file must not contribute. Then require every
7
+ // <!-- rpc-method-count: N --> marker in README*.md, AGENTS.md and docs/*.md
8
+ // to exist and carry that same measured value.
9
+ //
10
+ // Gate 2 - test tracking:
11
+ // Compare every tests/**/*.mjs on disk with git ls-files tests/. Files present
12
+ // on disk but not in the index are untracked or never staged, and fail.
13
+ // Only active in a development Git checkout; an npm package install directory
14
+ // has no .git, so the gate is explicitly skipped / not applicable there.
15
+ //
16
+ // Gate 3 - loader and stub completeness:
17
+ // tests/loader-register.mjs, tests/loader.mjs and the three host stubs must
18
+ // exist and be non-empty; tests/ must contain at least one *.test.mjs.
19
+ //
20
+ // Run directly with: node scripts/check-doc-drift.mjs [--root <dir>]
21
+
22
+ import { execFileSync } from 'node:child_process'
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const DEFAULT_ROOT = path.resolve(import.meta.dirname, '..')
28
+ const RPC_FILE = 'lib/rpc.js'
29
+ const MARKER_RE = /<!--\s*rpc-method-count\s*:\s*(\d+)\s*-->/g
30
+ const REQUIRED_FILES = [
31
+ 'tests/loader-register.mjs',
32
+ 'tests/loader.mjs',
33
+ 'tests/stubs/dsh-llm.js',
34
+ 'tests/stubs/dsh-session.js',
35
+ 'tests/stubs/dsh-tools.js',
36
+ ]
37
+
38
+ const outcome = (label, status, summary, details = []) => ({ label, status, summary, details })
39
+ const firstLine = (value) => String(value || '').trim().split(/\r?\n/)[0] || ''
40
+
41
+ // Extract only the top-level case labels of async function dispatch(...).
42
+ // The scanner walks the function body with quote/comment awareness and records
43
+ // case labels at depth 2: function body depth 1, dispatch switch body depth 2.
44
+ // Nested switches inside a case body sit deeper and are ignored.
45
+ export function extractDispatchMethods(source) {
46
+ const head = /\basync\s+function\s+dispatch\s*\(/.exec(source)
47
+ if (!head) throw new Error('async function dispatch(...) not found in ' + RPC_FILE)
48
+ let index = head.index + head[0].length
49
+ let parens = 1
50
+ let quote = null
51
+ for (; index < source.length; index++) {
52
+ const ch = source[index]
53
+ if (quote) {
54
+ if (ch === '\\') { index++; continue }
55
+ if (ch === quote) quote = null
56
+ continue
57
+ }
58
+ if (ch === "'" || ch === '"' || ch === '\u0060') { quote = ch; continue }
59
+ if (ch === '(') parens++
60
+ else if (ch === ')') { parens--; if (parens === 0) { index++; break } }
61
+ }
62
+ while (index < source.length && /\s/.test(source[index])) index++
63
+ if (source[index] !== '{') throw new Error('dispatch() parameter list is not followed by a body')
64
+ const names = []
65
+ let depth = 0
66
+ quote = null
67
+ for (; index < source.length; index++) {
68
+ const ch = source[index]
69
+ if (quote) {
70
+ if (ch === '\\') { index++; continue }
71
+ if (ch === quote) quote = null
72
+ continue
73
+ }
74
+ if (ch === '/' && source[index + 1] === '/') {
75
+ const newline = source.indexOf('\n', index)
76
+ index = newline < 0 ? source.length : newline
77
+ continue
78
+ }
79
+ if (ch === '/' && source[index + 1] === '*') {
80
+ const close = source.indexOf('*/', index + 2)
81
+ index = close < 0 ? source.length : close + 1
82
+ continue
83
+ }
84
+ if (ch === "'" || ch === '"' || ch === '\u0060') { quote = ch; continue }
85
+ if (ch === '{') { depth++; continue }
86
+ if (ch === '}') {
87
+ depth--
88
+ if (depth === 0) break
89
+ continue
90
+ }
91
+ if (depth !== 2 || !source.startsWith('case', index)) continue
92
+ const before = source[index - 1]
93
+ const after = source[index + 4]
94
+ if ((before && /[\w$]/.test(before)) || (after && /[\w$]/.test(after))) continue
95
+ const label = /^case\s+(['"])([^'"]+)\1\s*:/.exec(source.slice(index))
96
+ if (!label) {
97
+ const line = source.slice(0, index).split('\n').length
98
+ throw new Error('unparseable case label at ' + RPC_FILE + ':' + line)
99
+ }
100
+ names.push(label[2])
101
+ }
102
+ const unique = [...new Set(names)]
103
+ if (unique.length === 0) throw new Error('no top-level case labels found in dispatch() (' + RPC_FILE + ')')
104
+ return unique
105
+ }
106
+
107
+ // Root README*.md, AGENTS.md and docs/*.md are the only docs allowed to carry
108
+ // the marker. Keeping the list narrow means a stray marker in another doc
109
+ // cannot silently satisfy the gate.
110
+ export function docFiles(root) {
111
+ const files = []
112
+ let rootEntries = []
113
+ try {
114
+ rootEntries = fs.readdirSync(root)
115
+ } catch {
116
+ rootEntries = []
117
+ }
118
+ for (const name of rootEntries) {
119
+ if (/^README.*\.md$/.test(name)) files.push(name)
120
+ }
121
+ if (rootEntries.includes('AGENTS.md')) files.push('AGENTS.md')
122
+ try {
123
+ const docsDir = path.join(root, 'docs')
124
+ for (const name of fs.readdirSync(docsDir)) {
125
+ if (name.endsWith('.md')) files.push('docs/' + name)
126
+ }
127
+ } catch {
128
+ // No docs/ directory is not this gate's failure; the marker scan below
129
+ // simply has fewer files and will report the missing marker.
130
+ }
131
+ return [...new Set(files)].sort()
132
+ }
133
+
134
+ export function collectTestFiles(root) {
135
+ const testsDir = path.join(root, 'tests')
136
+ try {
137
+ if (!fs.statSync(testsDir).isDirectory()) return null
138
+ } catch {
139
+ return null
140
+ }
141
+ const out = []
142
+ const walk = (dir, rel) => {
143
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
144
+ const nextRel = rel + '/' + entry.name
145
+ const abs = path.join(dir, entry.name)
146
+ if (entry.isDirectory()) walk(abs, nextRel)
147
+ else if (entry.isFile() && entry.name.endsWith('.mjs')) out.push(nextRel)
148
+ }
149
+ }
150
+ walk(testsDir, 'tests')
151
+ return out.sort()
152
+ }
153
+
154
+ export function checkRpcMethodCount(root) {
155
+ const label = 'rpc-method-count'
156
+ let methods = []
157
+ try {
158
+ methods = extractDispatchMethods(fs.readFileSync(path.join(root, RPC_FILE), 'utf8'))
159
+ } catch (error) {
160
+ return outcome(label, 'fail', 'cannot measure the RPC dispatch surface', [
161
+ String(error && error.message ? error.message : error),
162
+ 'expected: parse ' + RPC_FILE + ' dispatch()',
163
+ ])
164
+ }
165
+ const expected = methods.length
166
+ const docs = docFiles(root)
167
+ const markers = []
168
+ for (const rel of docs) {
169
+ let text = ''
170
+ try {
171
+ text = fs.readFileSync(path.join(root, rel), 'utf8')
172
+ } catch (error) {
173
+ return outcome(label, 'fail', 'cannot read doc file ' + rel, [firstLine(error.message)])
174
+ }
175
+ for (const match of text.matchAll(new RegExp(MARKER_RE.source, 'g'))) {
176
+ markers.push({ file: rel, value: Number(match[1]) })
177
+ }
178
+ }
179
+ if (markers.length === 0) {
180
+ return outcome(label, 'fail', 'no rpc-method-count marker found', [
181
+ 'marker: (none)',
182
+ 'expected: ' + expected + ' methods (dispatch in ' + RPC_FILE + ')',
183
+ 'scanned: ' + (docs.length > 0 ? docs.join(', ') : '(no docs)'),
184
+ 'add the marker with the measured value; doc content is owned by the W2 docs work',
185
+ ])
186
+ }
187
+ const values = [...new Set(markers.map((entry) => entry.value))]
188
+ if (values.length === 1 && values[0] === expected) {
189
+ const docCount = new Set(markers.map((entry) => entry.file)).size
190
+ return outcome(label, 'ok', markers.length + ' marker(s) in ' + docCount + ' doc(s) agree on ' + expected + ' RPC methods', [])
191
+ }
192
+ const details = ['expected: ' + expected + ' methods (dispatch in ' + RPC_FILE + ')']
193
+ for (const entry of markers) {
194
+ details.push(entry.file + ': marker ' + entry.value + (entry.value === expected ? ' (agrees)' : ' != expected ' + expected))
195
+ }
196
+ if (values.length > 1) details.push('markers disagree: ' + values.join(', ') + '; all markers must share one value')
197
+ return outcome(label, 'fail', 'rpc-method-count marker drift', details)
198
+ }
199
+
200
+ function gitWorkTree(root) {
201
+ const dotGit = path.join(root, '.git')
202
+ let present = false
203
+ try {
204
+ const stat = fs.statSync(dotGit)
205
+ present = stat.isDirectory() || stat.isFile()
206
+ } catch {
207
+ present = false
208
+ }
209
+ if (!present) return { active: false, reason: 'no .git at repository root' }
210
+ try {
211
+ const output = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
212
+ cwd: root,
213
+ encoding: 'utf8',
214
+ stdio: ['ignore', 'pipe', 'pipe'],
215
+ }).trim()
216
+ if (output !== 'true') return { active: false, reason: 'git rev-parse --is-inside-work-tree returned ' + (output || '(empty)') }
217
+ return { active: true }
218
+ } catch (error) {
219
+ return { active: false, reason: 'git rev-parse --is-inside-work-tree failed: ' + firstLine(error.stderr || error.message) }
220
+ }
221
+ }
222
+
223
+ export function checkTestTracking(root) {
224
+ const label = 'test-tracking'
225
+ const git = gitWorkTree(root)
226
+ if (!git.active) {
227
+ return outcome(label, 'skipped', git.reason + ' - skipped, not applicable (npm package install directory)', [
228
+ 'test tracking is enforced only in a development Git checkout',
229
+ 'this location is neither passed nor failed; stage the tests before they can be checked',
230
+ ])
231
+ }
232
+ const disk = collectTestFiles(root)
233
+ if (disk === null) return outcome(label, 'fail', 'tests/ directory not found', ['cannot compare disk tests with git ls-files tests/'])
234
+ let tracked = new Set()
235
+ try {
236
+ const raw = execFileSync('git', ['ls-files', '-z', '--', 'tests/'], {
237
+ cwd: root,
238
+ encoding: 'utf8',
239
+ stdio: ['ignore', 'pipe', 'pipe'],
240
+ maxBuffer: 16 * 1024 * 1024,
241
+ })
242
+ tracked = new Set(raw.split('\0').filter(Boolean).map((file) => file.split('\\').join('/')))
243
+ } catch (error) {
244
+ return outcome(label, 'fail', 'git ls-files tests/ failed', [firstLine(error.stderr || error.message)])
245
+ }
246
+ const untracked = disk.filter((file) => !tracked.has(file))
247
+ if (untracked.length > 0) {
248
+ const details = untracked.map((file) => 'untracked/unstaged: ' + file)
249
+ details.push('git ls-files tests/ reports ' + tracked.size + ' tracked file(s)')
250
+ return outcome(label, 'fail', untracked.length + ' of ' + disk.length + ' test .mjs file(s) are not tracked or staged', details)
251
+ }
252
+ return outcome(label, 'ok', disk.length + ' test .mjs file(s) tracked and staged', [])
253
+ }
254
+
255
+ export function checkLoaderStubs(root) {
256
+ const label = 'loader-stubs'
257
+ const problems = []
258
+ for (const rel of REQUIRED_FILES) {
259
+ const abs = path.join(root, rel)
260
+ let stat = null
261
+ try {
262
+ stat = fs.statSync(abs)
263
+ } catch {
264
+ stat = null
265
+ }
266
+ if (!stat) {
267
+ problems.push(rel + ': missing')
268
+ continue
269
+ }
270
+ if (!stat.isFile()) {
271
+ problems.push(rel + ': not a regular file')
272
+ continue
273
+ }
274
+ let text = ''
275
+ try {
276
+ text = fs.readFileSync(abs, 'utf8')
277
+ } catch (error) {
278
+ problems.push(rel + ': unreadable (' + firstLine(error.message) + ')')
279
+ continue
280
+ }
281
+ if (text.trim().length === 0) problems.push(rel + ': empty (' + stat.size + ' bytes)')
282
+ }
283
+ const tests = collectTestFiles(root)
284
+ if (tests === null) problems.push('tests/: directory missing')
285
+ else if (!tests.some((file) => file.endsWith('.test.mjs'))) problems.push('tests/: no *.test.mjs file')
286
+ if (problems.length > 0) return outcome(label, 'fail', 'loader/stub completeness check failed', problems)
287
+ const testCount = tests.filter((file) => file.endsWith('.test.mjs')).length
288
+ return outcome(label, 'ok', REQUIRED_FILES.length + ' required loader/stub file(s) present; ' + testCount + ' *.test.mjs file(s)', [])
289
+ }
290
+
291
+ export function runChecks(root = DEFAULT_ROOT) {
292
+ const checks = [
293
+ ['rpc-method-count', checkRpcMethodCount],
294
+ ['test-tracking', checkTestTracking],
295
+ ['loader-stubs', checkLoaderStubs],
296
+ ]
297
+ return checks.map(([label, run]) => {
298
+ try {
299
+ return run(root)
300
+ } catch (error) {
301
+ return outcome(label, 'fail', 'check crashed', [firstLine(error && error.stack ? error.stack : error)])
302
+ }
303
+ })
304
+ }
305
+
306
+ export function formatCheck(check) {
307
+ if (check.status === 'ok') return 'ok ' + check.label + ': ' + check.summary
308
+ if (check.status === 'skipped') return 'skip ' + check.label + ': ' + check.summary
309
+ const lines = ['FAIL ' + check.label + ': ' + check.summary]
310
+ for (const detail of check.details || []) lines.push(' ' + detail)
311
+ return lines.join('\n')
312
+ }
313
+
314
+ function main() {
315
+ const argv = process.argv.slice(2)
316
+ let root = DEFAULT_ROOT
317
+ for (let i = 0; i < argv.length; i++) {
318
+ const arg = argv[i]
319
+ if (arg === '--help' || arg === '-h') {
320
+ console.log('usage: node scripts/check-doc-drift.mjs [--root <dir>]')
321
+ return
322
+ }
323
+ if (arg === '--root') {
324
+ const value = argv[++i]
325
+ if (!value) {
326
+ console.error('--root requires a directory')
327
+ process.exitCode = 2
328
+ return
329
+ }
330
+ root = path.resolve(value)
331
+ continue
332
+ }
333
+ if (arg.startsWith('--root=')) {
334
+ root = path.resolve(arg.slice('--root='.length))
335
+ continue
336
+ }
337
+ console.error('unknown argument: ' + arg)
338
+ process.exitCode = 2
339
+ return
340
+ }
341
+ const checks = runChecks(root)
342
+ for (const check of checks) console.log(formatCheck(check))
343
+ const failed = checks.filter((check) => check.status === 'fail')
344
+ if (failed.length > 0) {
345
+ console.log('\n' + failed.length + ' doc-drift check(s) failed: ' + failed.map((check) => check.label).join(', '))
346
+ process.exitCode = 1
347
+ return
348
+ }
349
+ console.log('\nall doc-drift checks passed')
350
+ }
351
+
352
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
353
+ if (invokedDirectly) main()
@@ -0,0 +1,176 @@
1
+ #! /usr/bin/env node
2
+ // Read-only binding lifecycle CLI (W9).
3
+ //
4
+ // It prints a diagnostic report and refuses every write: no delete, no unbind, no library
5
+ // creation, no state rewrite, no timestamp bump. The only side effect is stdout/stderr.
6
+ //
7
+ // Exit codes are about the report, never about "fixing" anything:
8
+ // 0 report produced, nothing filed for inspection
9
+ // 1 report produced, inspection candidates present (a to-do list for a human, not a queue)
10
+ // 2 usage error (unknown flag, unreadable --now)
11
+ // 3 no report: state.json is missing, corrupt or unreadable
12
+
13
+ import path from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { diagnoseBindings, defaultStateFile, INSPECTION_DAYS, SCHEMA } from '../lib/binding-diagnostics.js'
16
+
17
+ export const EXIT = Object.freeze({ clean: 0, candidates: 1, usage: 2, unreadable: 3 })
18
+
19
+ const USAGE = [
20
+ 'usage: node scripts/diagnose-bindings.mjs [options]',
21
+ '',
22
+ 'Read-only binding lifecycle report. Reads state.json once, never writes, never creates',
23
+ 'a directory, never unbinds or deletes anything, and re-hashes the file afterwards so the',
24
+ 'report can prove it left the bytes and the mtime alone.',
25
+ '',
26
+ 'options:',
27
+ ' --state <file> state.json to read (default: the plugin data directory)',
28
+ ' --json print the machine-readable report instead of the summary',
29
+ ' --redact pseudonymise session ids (titles and paths are never printed either way)',
30
+ ' --now <iso> evaluate the ' + INSPECTION_DAYS + '-day inspection threshold at an instant, for tests',
31
+ ' --help, -h print this text',
32
+ '',
33
+ 'exit codes:',
34
+ ' 0 report produced, no inspection candidates',
35
+ ' 1 report produced, inspection candidates present (inspection only, nothing was changed)',
36
+ ' 2 usage error',
37
+ ' 3 state.json missing, corrupt or unreadable — nothing was created or repaired',
38
+ '',
39
+ 'The ' + INSPECTION_DAYS + '-day mark is a threshold for looking, never an age at which a binding, a',
40
+ 'library or a message is dropped. There is no --delete, --gc or --unbind flag, by design.',
41
+ ].join('\n')
42
+
43
+ function usage (message) {
44
+ if (message) process.stderr.write('error: ' + message + '\n\n')
45
+ process.stderr.write(USAGE + '\n')
46
+ process.exitCode = EXIT.usage
47
+ }
48
+
49
+ export function parseArgs (argv) {
50
+ const options = { json: false, redact: false, stateFile: null, now: null, help: false }
51
+ for (let index = 0; index < argv.length; index += 1) {
52
+ const arg = argv[index]
53
+ if (arg === '--help' || arg === '-h') { options.help = true; continue }
54
+ if (arg === '--json') { options.json = true; continue }
55
+ if (arg === '--redact') { options.redact = true; continue }
56
+ if (arg === '--state' || arg.startsWith('--state=')) {
57
+ const value = arg === '--state' ? argv[++index] : arg.slice('--state='.length)
58
+ if (!value) return { error: '--state requires a file' }
59
+ options.stateFile = path.resolve(value)
60
+ continue
61
+ }
62
+ if (arg === '--now' || arg.startsWith('--now=')) {
63
+ const value = arg === '--now' ? argv[++index] : arg.slice('--now='.length)
64
+ const parsed = Date.parse(String(value || ''))
65
+ if (!value || !Number.isFinite(parsed)) return { error: '--now requires an ISO timestamp' }
66
+ options.now = parsed
67
+ continue
68
+ }
69
+ return { error: 'unknown argument: ' + arg }
70
+ }
71
+ return { options }
72
+ }
73
+
74
+ function short (hash) {
75
+ return typeof hash === 'string' && hash.length > 12 ? hash.slice(0, 12) + '…' : String(hash || '-')
76
+ }
77
+
78
+ function render (report) {
79
+ const lines = []
80
+ lines.push('dsh-memu-memory binding diagnostic — read-only (' + SCHEMA + ')')
81
+ lines.push('state: ' + report.state.file)
82
+ if (report.state.status === 'ok') {
83
+ lines.push(' ' + report.state.bytes + ' bytes, sha256 ' + short(report.state.sha256) + ', mtime ' + new Date(report.state.mtimeMs).toISOString())
84
+ } else {
85
+ lines.push(' status: ' + report.state.status)
86
+ }
87
+ lines.push('generated: ' + report.generatedAt)
88
+ if (report.readOnlyProof) {
89
+ lines.push('read-only proof: ' + (report.readOnlyProof.unchanged
90
+ ? 'bytes and mtime identical before/after (' + short(report.readOnlyProof.sha256Before) + ')'
91
+ : 'CHANGED during the report — investigate, this tool does not write'))
92
+ }
93
+ if (!report.analysis) {
94
+ lines.push('')
95
+ lines.push('no analysis: the state file could not be read. Nothing was created, renamed or repaired.')
96
+ for (const unknown of report.unknowns) lines.push(' unknown ' + unknown.code + ': ' + unknown.detail)
97
+ return lines.join('\n')
98
+ }
99
+ const summary = report.analysis.summary
100
+ lines.push('')
101
+ lines.push('bindings: ' + summary.bindings + ' persistent binding(s)')
102
+ lines.push('host: ' + summary.host.status + ' / ' + summary.host.source + ' / ' + summary.host.sessions + ' session(s)' + (summary.host.complete ? ' (complete)' : ' (incomplete — absence is not inferred)'))
103
+ lines.push('candidates: ' + summary.candidates.total + (summary.candidates.total ? ' — ' + Object.entries(summary.candidates.byKind).map(([kind, count]) => kind + ' ' + count).join(', ') : ''))
104
+ lines.push('unfinished: ' + summary.unfinished.pendingMessages + ' pending message(s) in ' + summary.unfinished.pendingSessions + ' session(s), ' +
105
+ summary.unfinished.reinjectPending + ' reinject, ' + summary.unfinished.sleepRetryDue + ' sleep retry due, ' + summary.unfinished.sessionGone + ' session gone')
106
+ lines.push('switches: ' + summary.switches.libraryDisabled + ' library off, ' + summary.switches.globalDisabled + ' global off, ' +
107
+ summary.switches.folderBlockedLibrary + ' folder-blocked library, ' + summary.switches.folderBlockedGlobal + ' folder-blocked global')
108
+ lines.push('libraries: ' + summary.libraries.bound + ' bound, ' + summary.libraries.enabledButUnbound + ' enabled-but-unbound, ' +
109
+ summary.libraries.missing + ' missing, ' + summary.libraries.archived + ' archived')
110
+ if (report.analysis.candidates.length > 0) {
111
+ lines.push('')
112
+ lines.push('inspection candidates (a prompt to look, never a deletion queue):')
113
+ for (const candidate of report.analysis.candidates.slice(0, 20)) {
114
+ lines.push(' ' + candidate.kind.padEnd(28) + ' ' + candidate.sessionId)
115
+ }
116
+ if (report.analysis.candidates.length > 20) lines.push(' … ' + (report.analysis.candidates.length - 20) + ' more (use --json)')
117
+ }
118
+ if (report.unknowns.length > 0) {
119
+ lines.push('')
120
+ lines.push('unknowns (kept, not assumed away):')
121
+ for (const unknown of report.unknowns) lines.push(' ' + unknown.code + ': ' + unknown.detail)
122
+ }
123
+ lines.push('')
124
+ const runtime = report.analysis.runtime || { status: 'unknown', layers: [] }
125
+ const runtimeLabel = runtime.status === 'provided'
126
+ ? 'runtime layers supplied in-process: '
127
+ : 'runtime layers not readable from this process: '
128
+ lines.push(runtimeLabel + runtime.layers.length + ' declared (' + runtime.status + ')')
129
+ lines.push('threshold: ' + INSPECTION_DAYS + ' days marks what to inspect; it never triggers deletion. No gc, no unbind, no write.')
130
+ return lines.join('\n')
131
+ }
132
+
133
+ function main () {
134
+ const parsed = parseArgs(process.argv.slice(2))
135
+ if (parsed.error) {
136
+ usage(parsed.error)
137
+ return
138
+ }
139
+ const options = parsed.options
140
+ if (options.help) {
141
+ process.stdout.write(USAGE + '\n')
142
+ return
143
+ }
144
+ let report
145
+ try {
146
+ report = diagnoseBindings({
147
+ stateFile: options.stateFile || defaultStateFile(),
148
+ now: options.now === null ? undefined : options.now,
149
+ redactIds: options.redact,
150
+ })
151
+ } catch (error) {
152
+ process.stderr.write('error: the diagnostic failed without touching the state file: ' + String((error && error.message) || error) + '\n')
153
+ process.exitCode = EXIT.unreadable
154
+ return
155
+ }
156
+ try {
157
+ if (options.json) {
158
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
159
+ } else {
160
+ process.stdout.write(render(report) + '\n')
161
+ }
162
+ } catch (error) {
163
+ // A rendering bug must not masquerade as exit 1 ("candidates present"): no report was produced.
164
+ process.stderr.write('error: the report could not be rendered: ' + String((error && error.message) || error) + '\n')
165
+ process.exitCode = EXIT.unreadable
166
+ return
167
+ }
168
+ if (!report.analysis) {
169
+ process.exitCode = EXIT.unreadable
170
+ return
171
+ }
172
+ process.exitCode = report.analysis.candidates.length > 0 ? EXIT.candidates : EXIT.clean
173
+ }
174
+
175
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
176
+ if (invokedDirectly) main()
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // 受管本地向量运行时的可复现安装/核验脚本(§14.13)。
3
+ //
4
+ // node scripts/local-runtime-install.mjs # 只读:打印制品计划、当前状态、已装摘要
5
+ // node scripts/local-runtime-install.mjs --download # 显式联网安装(等同用户在设置里点确认)
6
+ // node scripts/local-runtime-install.mjs --download --target <dir> --id <modelId> --platform win32-x64
7
+ // node scripts/local-runtime-install.mjs --shallow # 复用判定只比字节数,不重算 sha256
8
+ // node scripts/local-runtime-install.mjs --download --cache <dir> # 用离线缓存里的同名 tarball 替代联网
9
+ //
10
+ // 边界:只写 --target(默认仓库内 .tmp-* 临时目录);不写 DSH_MEMU_HOME、不写 active profile、
11
+ // 不读不写真实记忆库;不装 Ollama,也不需要 DSH_MEMU_EMBED_RUNTIME_DIR。
12
+ // --cache 只改「字节从哪来」:大小与 sha256 仍按 catalog pin 逐项校验。
13
+
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { createEmbeddingRuntimeInstaller, embeddingRuntimePlatform } from '../lib/embedding-runtime.js'
18
+ import { getEmbeddingCatalog, getEmbeddingModel } from '../lib/embedding-catalog.js'
19
+
20
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
21
+ const REPO = path.resolve(HERE, '..')
22
+
23
+ const argv = process.argv.slice(2)
24
+ const flag = name => argv.includes('--' + name)
25
+ const option = name => {
26
+ const index = argv.indexOf('--' + name)
27
+ return index >= 0 ? argv[index + 1] : undefined
28
+ }
29
+
30
+ const target = path.resolve(option('target') || path.join(REPO, '.tmp-local-vector-20261030', 'runtime-managed'))
31
+ const platform = option('platform') || embeddingRuntimePlatform()
32
+ const modelId = option('id') || null
33
+ const download = flag('download')
34
+ const deep = !flag('shallow')
35
+ const cache = option('cache') ? path.resolve(option('cache')) : null
36
+
37
+ // 离线缓存:只替换字节来源,不跳过任何校验。tarball 文件名允许几种常见写法。
38
+ function cacheLookup() {
39
+ const entry = modelId ? getEmbeddingModel(modelId) : getEmbeddingCatalog().find(item => item.availability === 'available')
40
+ const artifacts = Array.isArray(entry?.runtime?.artifacts) ? entry.runtime.artifacts : []
41
+ const map = new Map()
42
+ for (const artifact of artifacts) {
43
+ const names = []
44
+ const base = String(artifact.url || '').split('/').pop()
45
+ if (base) names.push(base)
46
+ const pkg = String(artifact.name || '')
47
+ if (pkg) {
48
+ names.push(pkg + '.tgz', pkg.replace(/[\\/]+/g, '__') + '.tgz', pkg.split('/').pop() + '.tgz')
49
+ names.push(pkg.replace(/[^A-Za-z0-9._-]+/g, '-') + '.tgz')
50
+ }
51
+ for (const name of names) {
52
+ const candidate = path.join(cache, name)
53
+ if (fs.existsSync(candidate)) { map.set(artifact.url, candidate); break }
54
+ }
55
+ }
56
+ return map
57
+ }
58
+
59
+ const cacheFiles = cache ? cacheLookup() : null
60
+ const fetchImpl = cacheFiles
61
+ ? async url => {
62
+ const file = cacheFiles.get(url)
63
+ if (!file) throw new Error('offline cache ' + cache + ' has no tarball for ' + url)
64
+ const bytes = fs.readFileSync(file)
65
+ return new Response(bytes, { status: 200, headers: { 'content-length': String(bytes.length) } })
66
+ }
67
+ : undefined
68
+
69
+ const installer = createEmbeddingRuntimeInstaller({
70
+ root: target,
71
+ platform,
72
+ fetchImpl,
73
+ logger: {
74
+ info: message => console.error('info ' + message),
75
+ warn: message => console.error('warn ' + message),
76
+ },
77
+ })
78
+
79
+ const summarize = status => ({
80
+ status: status.status,
81
+ ready: status.ready,
82
+ platform: status.platform,
83
+ key: status.key,
84
+ path: status.path,
85
+ engine: status.engine,
86
+ version: status.version,
87
+ abi: status.abi,
88
+ root: status.root,
89
+ verifiedPlatforms: status.verifiedPlatforms,
90
+ artifacts: Array.isArray(status.artifacts)
91
+ ? status.artifacts.map(item => ({ name: item.name, version: item.version, bytes: item.bytes, sha256: item.sha256, license: item.license }))
92
+ : null,
93
+ installedAt: status.installedAt ?? null,
94
+ verifiedAt: status.verifiedAt ?? null,
95
+ smoke: status.smoke ?? null,
96
+ checks: Array.isArray(status.checks) ? status.checks : null,
97
+ progress: status.progress ?? null,
98
+ inspect: status.inspect ?? null,
99
+ error: status.error ?? null,
100
+ })
101
+
102
+ try {
103
+ const before = installer.getStatus({ deep, modelId })
104
+ console.log(JSON.stringify({ phase: 'before', deep, download, ...summarize(before) }, null, 2))
105
+ if (!before.ready && !download) {
106
+ console.error('\n未安装。加 --download 才会联网下载并安装(有明确确认语义);本脚本不会自动下载。')
107
+ process.exit(0)
108
+ }
109
+ if (download) {
110
+ const started = Date.now()
111
+ const result = await installer.ensure({ modelId, confirmed: true })
112
+ console.log(JSON.stringify({
113
+ phase: 'install',
114
+ elapsedMs: Date.now() - started,
115
+ status: result.status,
116
+ ready: result.ready,
117
+ reused: result.reused,
118
+ key: result.key,
119
+ platform: result.platform,
120
+ path: result.path,
121
+ engine: result.engine,
122
+ version: result.version,
123
+ abi: result.abi,
124
+ installedAt: result.installedAt,
125
+ verifiedAt: result.verifiedAt,
126
+ checks: result.checks,
127
+ smoke: result.smoke,
128
+ artifacts: result.artifacts,
129
+ }, null, 2))
130
+ const after = installer.getStatus({ deep: true, modelId })
131
+ console.log(JSON.stringify({ phase: 'after', ...summarize(after) }, null, 2))
132
+ if (!after.ready) throw new Error('installer reported installed but the runtime is not reusable')
133
+ }
134
+ } catch (error) {
135
+ const code = String(error?.code || 'LOCAL_VECTOR_RUNTIME_SCRIPT_FAILED')
136
+ console.error('FAIL ' + code + ': ' + String(error?.message || error))
137
+ if (Array.isArray(error?.errors)) for (const item of error.errors) console.error(' - ' + JSON.stringify(item))
138
+ if (error?.inspect) console.error(' inspect: ' + error.inspect)
139
+ process.exit(1)
140
+ } finally {
141
+ installer.dispose()
142
+ }