dsh-llm-fallbacks 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -33
- package/README.zh-CN.md +89 -33
- package/dist/client/index.js +68 -61
- package/dist/client/locales.d.ts +4 -0
- package/dist/events.d.ts +5 -3
- package/dist/index.d.ts +6 -4
- package/dist/index.js +165 -69
- package/dist/role-resolution.d.ts +11 -0
- package/dist/roles.d.ts +4 -1
- package/dist/seeds.d.ts +63 -21
- package/dist/time-slots.d.ts +21 -11
- package/dist/virtual-adapter.d.ts +42 -6
- package/package.json +47 -26
- package/scripts/build-client.ts +9 -5
- package/scripts/repair-session-logs.ts +1858 -0
- package/scripts/session-logs/catalog.ts +547 -0
- package/scripts/session-logs/classify.ts +47 -0
- package/scripts/session-logs/publish.ts +530 -0
- package/scripts/session-logs/rules.ts +1227 -0
- package/scripts/repair-fallbacks-switch-logs.ts +0 -374
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* repair-fallbacks-switch-logs.ts — repair session logs poisoned by the old
|
|
3
|
-
* plugin's durable `fallbacks/switch` events.
|
|
4
|
-
*
|
|
5
|
-
* Background: until Task 1 of `fallbacks-session-event-stop-write`, the
|
|
6
|
-
* plugin wrote durable `fallbacks/switch` session events. Host persistence
|
|
7
|
-
* (`assertEventsSupported`) refuses to load any session log whose event type
|
|
8
|
-
* is outside its baked catalog unless the event carries `ignorable: true`.
|
|
9
|
-
* `Session.append` cannot write `ignorable`, and runtime registration proved
|
|
10
|
-
* ineffective (module-instance mismatch). Session logs written while the old
|
|
11
|
-
* plugin was active therefore fail to load after a dsh restart. This script
|
|
12
|
-
* marks those events `ignorable: true` so the read path accepts them again.
|
|
13
|
-
*
|
|
14
|
-
* Session log format (`~/.dsh/sessions/<namespace>/<session-id>/session.jsonl.zstd`):
|
|
15
|
-
* - concatenated-zstd-frame container: **first frame MUST decode to
|
|
16
|
-
* exactly one header line** (rc.7 `assertZstdHeaderFrame`:
|
|
17
|
-
* `indexOf(10) === length-1`). Subsequent frames hold events.
|
|
18
|
-
* - `node:zlib.zstdDecompress` only decodes the FIRST frame, so this
|
|
19
|
-
* script shells out to the `zstd` CLI (`zstd -d -c` decodes every
|
|
20
|
-
* concatenated frame). Re-encoding MUST emit frame-1 = header only
|
|
21
|
-
* + a following frame for the rest — a single-frame rewrite of the
|
|
22
|
-
* whole log fails host boot (`first frame is not exactly one header line`).
|
|
23
|
-
*
|
|
24
|
-
* IMPORTANT: stop dsh before running with `--apply`. The script replaces a
|
|
25
|
-
* session log via read → transform → atomic rename; a live dsh that appends
|
|
26
|
-
* a new zstd frame between the read and the rename would have that frame
|
|
27
|
-
* lost (read-modify-write race). Report / `--dry-run` runs are safe any
|
|
28
|
-
* time — they never write files.
|
|
29
|
-
*
|
|
30
|
-
* Usage (from the repo root, via tsx — the package.json
|
|
31
|
-
* `repair:fallbacks-switch-logs` script):
|
|
32
|
-
* pnpm repair:fallbacks-switch-logs -- --dry-run
|
|
33
|
-
* pnpm repair:fallbacks-switch-logs -- --apply --backup
|
|
34
|
-
* pnpm repair:fallbacks-switch-logs -- --root /tmp/repair-fixture --dry-run
|
|
35
|
-
*
|
|
36
|
-
* Flags:
|
|
37
|
-
* --root <dir> session root to walk (default: ~/.dsh/sessions)
|
|
38
|
-
* --dry-run report only — never write files
|
|
39
|
-
* --backup required with --apply: copy the original to <file>.bak
|
|
40
|
-
* before replacing (apply is refused without it)
|
|
41
|
-
* --apply actually replace repaired files; requires --backup and a
|
|
42
|
-
* stopped dsh; without it (or with --dry-run) the run only
|
|
43
|
-
* reports would-change
|
|
44
|
-
*
|
|
45
|
-
* Safe by construction: a file is only replaced after its re-encoded form
|
|
46
|
-
* passes `zstd -t`, has a one-line first frame, and carries the original
|
|
47
|
-
* file's permission bits; a file that fails to decompress or re-encode is
|
|
48
|
-
* reported and skipped (never corrupted); the replace is an atomic rename
|
|
49
|
-
* of a temp file in the same directory. Report/dry-run mode performs no
|
|
50
|
-
* filesystem writes at all.
|
|
51
|
-
*/
|
|
52
|
-
import { execFileSync } from 'node:child_process'
|
|
53
|
-
import {
|
|
54
|
-
chmodSync,
|
|
55
|
-
copyFileSync,
|
|
56
|
-
existsSync,
|
|
57
|
-
readdirSync,
|
|
58
|
-
renameSync,
|
|
59
|
-
rmSync,
|
|
60
|
-
statSync,
|
|
61
|
-
writeFileSync,
|
|
62
|
-
} from 'node:fs'
|
|
63
|
-
import { homedir } from 'node:os'
|
|
64
|
-
import { basename, dirname, join } from 'node:path'
|
|
65
|
-
import { pathToFileURL } from 'node:url'
|
|
66
|
-
|
|
67
|
-
/** Decoded plaintext cap for one session log (512 MB; logs can be large). */
|
|
68
|
-
const MAX_BUFFER = 512 * 1024 * 1024
|
|
69
|
-
|
|
70
|
-
/** A line matched only when the PARSED type is exactly `fallbacks/switch`. */
|
|
71
|
-
const SWITCH_TYPE = 'fallbacks/switch'
|
|
72
|
-
|
|
73
|
-
export interface MarkFallbacksSwitchIgnorableResult {
|
|
74
|
-
lines: string[]
|
|
75
|
-
changed: number
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Pure transform: mark `fallbacks/switch` events without an `ignorable` field
|
|
80
|
-
* as `ignorable: true`.
|
|
81
|
-
*
|
|
82
|
-
* - `type === 'session'` header lines are skipped untouched;
|
|
83
|
-
* - every other line (non-switch events, malformed JSON, empty lines, switch
|
|
84
|
-
* events that already carry `ignorable`) passes through byte-identical;
|
|
85
|
-
* - switch events are re-serialized with `ignorable` appended (insertion
|
|
86
|
-
* order — the read path only reads `event.ignorable`, so field position is
|
|
87
|
-
* irrelevant); `seq`/`time`/`data` are preserved verbatim;
|
|
88
|
-
* - `changed` counts only lines that were modified.
|
|
89
|
-
*
|
|
90
|
-
* Matching is on the parsed `type` field, never on the raw string — the
|
|
91
|
-
* substring `fallbacks/switch` legitimately appears inside user/message data
|
|
92
|
-
* and must not be treated as an event.
|
|
93
|
-
*/
|
|
94
|
-
export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwitchIgnorableResult {
|
|
95
|
-
const out = new Array<string>(lines.length)
|
|
96
|
-
let changed = 0
|
|
97
|
-
for (let i = 0; i < lines.length; i++) {
|
|
98
|
-
const line = lines[i]
|
|
99
|
-
if (line === '') {
|
|
100
|
-
out[i] = line
|
|
101
|
-
continue
|
|
102
|
-
}
|
|
103
|
-
let parsed: unknown
|
|
104
|
-
try {
|
|
105
|
-
parsed = JSON.parse(line)
|
|
106
|
-
} catch {
|
|
107
|
-
// Malformed JSON — pass through untouched, never corrupt.
|
|
108
|
-
out[i] = line
|
|
109
|
-
continue
|
|
110
|
-
}
|
|
111
|
-
if (parsed === null || typeof parsed !== 'object') {
|
|
112
|
-
out[i] = line
|
|
113
|
-
continue
|
|
114
|
-
}
|
|
115
|
-
const event = parsed as Record<string, unknown>
|
|
116
|
-
if (event.type === 'session') {
|
|
117
|
-
out[i] = line
|
|
118
|
-
continue
|
|
119
|
-
}
|
|
120
|
-
if (
|
|
121
|
-
event.type === SWITCH_TYPE &&
|
|
122
|
-
!Object.prototype.hasOwnProperty.call(event, 'ignorable')
|
|
123
|
-
) {
|
|
124
|
-
out[i] = JSON.stringify({ ...event, ignorable: true })
|
|
125
|
-
changed++
|
|
126
|
-
continue
|
|
127
|
-
}
|
|
128
|
-
out[i] = line
|
|
129
|
-
}
|
|
130
|
-
return { lines: out, changed }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/* ------------------------------------------------------------------ */
|
|
134
|
-
/* CLI */
|
|
135
|
-
/* ------------------------------------------------------------------ */
|
|
136
|
-
|
|
137
|
-
interface CliOptions {
|
|
138
|
-
root: string
|
|
139
|
-
dryRun: boolean
|
|
140
|
-
backup: boolean
|
|
141
|
-
apply: boolean
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function usage(): string {
|
|
145
|
-
return `usage: tsx scripts/repair-fallbacks-switch-logs.ts [--root DIR] [--dry-run] [--backup] [--apply]
|
|
146
|
-
|
|
147
|
-
--root DIR session root to walk (default: ~/.dsh/sessions)
|
|
148
|
-
--dry-run report only — never write files
|
|
149
|
-
--backup required with --apply: copy the original to <file>.bak before
|
|
150
|
-
replacing
|
|
151
|
-
--apply actually replace repaired files (requires --backup; stop dsh
|
|
152
|
-
before applying)`
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function expandHome(p: string): string {
|
|
156
|
-
if (p === '~') return homedir()
|
|
157
|
-
return p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function parseArgs(argv: string[]): CliOptions {
|
|
161
|
-
let root = join(homedir(), '.dsh', 'sessions')
|
|
162
|
-
let dryRun = false
|
|
163
|
-
let backup = false
|
|
164
|
-
let apply = false
|
|
165
|
-
for (let i = 0; i < argv.length; i++) {
|
|
166
|
-
const arg = argv[i]
|
|
167
|
-
// pnpm run <script> -- <args> forwards the separator `--`; skip it.
|
|
168
|
-
if (arg === '--') continue
|
|
169
|
-
switch (arg) {
|
|
170
|
-
case '--root':
|
|
171
|
-
i++
|
|
172
|
-
if (i >= argv.length) throw new Error(`${usage()}\n\n--root requires a directory argument`)
|
|
173
|
-
root = expandHome(argv[i])
|
|
174
|
-
break
|
|
175
|
-
case '--dry-run':
|
|
176
|
-
dryRun = true
|
|
177
|
-
break
|
|
178
|
-
case '--backup':
|
|
179
|
-
backup = true
|
|
180
|
-
break
|
|
181
|
-
case '--apply':
|
|
182
|
-
apply = true
|
|
183
|
-
break
|
|
184
|
-
default:
|
|
185
|
-
throw new Error(`${usage()}\n\nunknown argument: ${arg}`)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
if (apply && !backup) {
|
|
189
|
-
throw new Error(
|
|
190
|
-
`${usage()}\n\n--apply requires --backup: real modification must keep a .bak copy of every replaced session log (run with --dry-run first to review)`,
|
|
191
|
-
)
|
|
192
|
-
}
|
|
193
|
-
return { root, dryRun, backup, apply }
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Resolve the zstd CLI binary; throw a clear error when it is missing. */
|
|
197
|
-
function resolveZstd(): string {
|
|
198
|
-
try {
|
|
199
|
-
const resolved = execFileSync('which', ['zstd'], { encoding: 'utf8' }).trim()
|
|
200
|
-
if (resolved) return resolved
|
|
201
|
-
} catch {
|
|
202
|
-
// fall through to the direct check below
|
|
203
|
-
}
|
|
204
|
-
try {
|
|
205
|
-
execFileSync('zstd', ['--version'], { stdio: 'ignore' })
|
|
206
|
-
return 'zstd'
|
|
207
|
-
} catch {
|
|
208
|
-
throw new Error(
|
|
209
|
-
'zstd CLI not found on PATH. Install it (e.g. `brew install zstd`) and retry.',
|
|
210
|
-
)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** Recursively find every `<namespace>/<session-id>/session.jsonl.zstd` under root. */
|
|
215
|
-
function findSessionLogs(root: string): string[] {
|
|
216
|
-
const found: string[] = []
|
|
217
|
-
const walk = (dir: string): void => {
|
|
218
|
-
const entries = readdirSync(dir, { withFileTypes: true })
|
|
219
|
-
for (const entry of entries) {
|
|
220
|
-
const full = join(dir, entry.name)
|
|
221
|
-
if (entry.isDirectory()) {
|
|
222
|
-
walk(full)
|
|
223
|
-
} else if (entry.isFile() && entry.name === 'session.jsonl.zstd') {
|
|
224
|
-
found.push(full)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
walk(root)
|
|
229
|
-
return found.sort()
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
type FileOutcome =
|
|
233
|
-
| { action: 'unchanged'; changed: 0 }
|
|
234
|
-
| { action: 'would-change'; changed: number }
|
|
235
|
-
| { action: 'changed'; changed: number }
|
|
236
|
-
| { action: 'error'; changed: number; error: string }
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Encode repaired plaintext as concatenated zstd frames that rc.7 will
|
|
240
|
-
* accept: frame 1 = header line + `\n` only; frame 2 = remaining lines.
|
|
241
|
-
*/
|
|
242
|
-
export function encodeRepairedSessionLog(zstd: string, lines: string[]): Buffer {
|
|
243
|
-
const header = lines[0] ?? ''
|
|
244
|
-
const headerPlain = header.endsWith('\n') ? header : `${header}\n`
|
|
245
|
-
const zstdOut = { input: headerPlain, maxBuffer: MAX_BUFFER }
|
|
246
|
-
const frame1 = execFileSync(zstd, ['-c'], zstdOut)
|
|
247
|
-
const rest = lines.slice(1)
|
|
248
|
-
if (rest.length === 0 || (rest.length === 1 && rest[0] === '')) return frame1
|
|
249
|
-
const restPlain = rest.join('\n')
|
|
250
|
-
const frame2 = execFileSync(zstd, ['-c'], {
|
|
251
|
-
input: restPlain.endsWith('\n') ? restPlain : `${restPlain}\n`,
|
|
252
|
-
maxBuffer: MAX_BUFFER,
|
|
253
|
-
})
|
|
254
|
-
return Buffer.concat([frame1, frame2])
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Decompress all frames of one log, transform it, and (only when actually
|
|
259
|
-
* applying) atomically replace it with a two-frame re-encode (header frame
|
|
260
|
-
* + events frame) that passed `zstd -t` and carries the original file's
|
|
261
|
-
* permission bits. Report/dry-run returns would-change WITHOUT touching the
|
|
262
|
-
* filesystem.
|
|
263
|
-
*/
|
|
264
|
-
export function processFile(zstd: string, file: string, opts: CliOptions): FileOutcome {
|
|
265
|
-
let plain: string
|
|
266
|
-
try {
|
|
267
|
-
plain = execFileSync(zstd, ['-d', '-c', file], { encoding: 'utf8', maxBuffer: MAX_BUFFER })
|
|
268
|
-
} catch (err) {
|
|
269
|
-
return {
|
|
270
|
-
action: 'error',
|
|
271
|
-
changed: 0,
|
|
272
|
-
error: `zstd -d -c failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const { lines, changed } = markFallbacksSwitchIgnorable(plain.split('\n'))
|
|
277
|
-
if (changed === 0) return { action: 'unchanged', changed: 0 }
|
|
278
|
-
|
|
279
|
-
// Report / dry-run: nothing to write — return would-change right after the
|
|
280
|
-
// transform (parseArgs refuses `--apply` without `--backup`, so any run
|
|
281
|
-
// reaching here without `write` is a report-only invocation).
|
|
282
|
-
const write = opts.apply && !opts.dryRun
|
|
283
|
-
if (!write) return { action: 'would-change', changed }
|
|
284
|
-
|
|
285
|
-
// Apply: temp files live next to the target so the final rename is atomic
|
|
286
|
-
// (same filesystem) and the walker never mistakes them for session logs.
|
|
287
|
-
const dir = dirname(file)
|
|
288
|
-
const tmpZstd = join(dir, `.${basename(file)}.${process.pid}.zstd.tmp`)
|
|
289
|
-
const mode = statSync(file).mode & 0o7777
|
|
290
|
-
try {
|
|
291
|
-
writeFileSync(tmpZstd, encodeRepairedSessionLog(zstd, lines))
|
|
292
|
-
execFileSync(zstd, ['-t', tmpZstd], { stdio: 'ignore' })
|
|
293
|
-
copyFileSync(file, `${file}.bak`)
|
|
294
|
-
chmodSync(tmpZstd, mode)
|
|
295
|
-
renameSync(tmpZstd, file)
|
|
296
|
-
} catch (err) {
|
|
297
|
-
return {
|
|
298
|
-
action: 'error',
|
|
299
|
-
changed,
|
|
300
|
-
error: `re-encode/validate failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
301
|
-
}
|
|
302
|
-
} finally {
|
|
303
|
-
rmSync(tmpZstd, { force: true })
|
|
304
|
-
}
|
|
305
|
-
return { action: 'changed', changed }
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export function main(): void {
|
|
309
|
-
const opts = parseArgs(process.argv.slice(2))
|
|
310
|
-
const zstd = resolveZstd()
|
|
311
|
-
if (!existsSync(opts.root)) {
|
|
312
|
-
console.error(`repair-fallbacks-switch-logs: root directory not found: ${opts.root}`)
|
|
313
|
-
process.exit(1)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const files = findSessionLogs(opts.root)
|
|
317
|
-
if (!files.length) {
|
|
318
|
-
console.log(`repair-fallbacks-switch-logs: no session.jsonl.zstd files under ${opts.root}`)
|
|
319
|
-
return
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const mode = opts.dryRun ? 'dry-run' : opts.apply ? 'apply' : 'report'
|
|
323
|
-
console.log(`root: ${opts.root}`)
|
|
324
|
-
console.log(`mode: ${mode}${opts.backup ? ' (backup .bak before replace)' : ''}`)
|
|
325
|
-
console.log(`zstd: ${zstd}`)
|
|
326
|
-
console.log(`files: ${files.length}`)
|
|
327
|
-
|
|
328
|
-
let totalFiles = 0
|
|
329
|
-
let totalEvents = 0
|
|
330
|
-
let skipped = 0
|
|
331
|
-
let errors = 0
|
|
332
|
-
for (const file of files) {
|
|
333
|
-
const outcome = processFile(zstd, file, opts)
|
|
334
|
-
switch (outcome.action) {
|
|
335
|
-
case 'unchanged':
|
|
336
|
-
skipped++
|
|
337
|
-
console.log(` unchanged ${file}`)
|
|
338
|
-
break
|
|
339
|
-
case 'would-change':
|
|
340
|
-
totalFiles++
|
|
341
|
-
totalEvents += outcome.changed
|
|
342
|
-
console.log(` would-change ${outcome.changed} ${file}`)
|
|
343
|
-
break
|
|
344
|
-
case 'changed':
|
|
345
|
-
totalFiles++
|
|
346
|
-
totalEvents += outcome.changed
|
|
347
|
-
console.log(` changed ${outcome.changed} ${file}`)
|
|
348
|
-
break
|
|
349
|
-
case 'error':
|
|
350
|
-
errors++
|
|
351
|
-
console.error(` error ${file}: ${outcome.error}`)
|
|
352
|
-
break
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
console.log(
|
|
357
|
-
`\nsummary: ${totalFiles} file(s), ${totalEvents} fallbacks/switch event(s) ` +
|
|
358
|
-
`${mode === 'dry-run' || mode === 'report' ? 'to be marked' : 'marked'}, ` +
|
|
359
|
-
`${skipped} unchanged skipped, ${errors} error(s)`,
|
|
360
|
-
)
|
|
361
|
-
if (errors) process.exitCode = 1
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Run only when executed directly (tsx scripts/repair-fallbacks-switch-logs.ts)
|
|
365
|
-
// — importing the module (unit tests) must not start the CLI.
|
|
366
|
-
const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''
|
|
367
|
-
if (import.meta.url === entry) {
|
|
368
|
-
try {
|
|
369
|
-
main()
|
|
370
|
-
} catch (err) {
|
|
371
|
-
console.error(`\nrepair-fallbacks-switch-logs failed: ${err instanceof Error ? err.message : err}`)
|
|
372
|
-
process.exit(1)
|
|
373
|
-
}
|
|
374
|
-
}
|