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.
- package/AGENTS.md +8 -5
- package/README.md +22 -5
- package/README.zh.md +21 -4
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +41 -1
- package/docs/troubleshooting.md +395 -350
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +703 -50
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +61 -4
- package/lib/inject.js +195 -49
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +121 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- 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
|
+
}
|