dsh-llm-fallbacks 0.4.4 → 0.5.1
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 +86 -25
- package/README.zh-CN.md +86 -25
- package/dist/client/SubagentRoleBadge.d.ts +40 -15
- package/dist/client/fallbacks-store.d.ts +0 -53
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +77 -150
- package/dist/client/locales.d.ts +2 -0
- package/dist/events.d.ts +2 -2
- package/dist/gateway.d.ts +2 -55
- package/dist/index.d.ts +6 -27
- package/dist/index.js +713 -129
- package/dist/role-notice.d.ts +156 -0
- package/dist/role-projection-key.d.ts +41 -0
- package/dist/role-projection.d.ts +151 -0
- package/dist/role-resolution.d.ts +11 -0
- package/dist/roles.d.ts +4 -1
- package/dist/seeds.d.ts +63 -21
- package/dist/subagents-seam.d.ts +292 -0
- package/dist/time-slots.d.ts +3 -2
- package/dist/virtual-adapter.d.ts +42 -6
- package/package.json +27 -25
- package/scripts/build-client.ts +1 -1
- package/scripts/repair-session-logs.ts +2040 -0
- package/scripts/session-logs/catalog.ts +547 -0
- package/scripts/session-logs/classify.ts +47 -0
- package/scripts/session-logs/publish.ts +583 -0
- package/scripts/session-logs/rules.ts +1227 -0
- package/scripts/repair-fallbacks-switch-logs.ts +0 -342
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalog.ts — resolve the released session-format catalog (the refusal ORACLE)
|
|
3
|
+
* and classify one parsed log against it.
|
|
4
|
+
*
|
|
5
|
+
* Why a resolved host artifact and not a constant table: the migration truth of
|
|
6
|
+
* an old session log is the migration chain of the RELEASE that must read it.
|
|
7
|
+
* This repo may not duplicate those constants (Task 1's rules encode only the
|
|
8
|
+
* classes they can prove), and it may not declare `@deepseek-ai/*` as a
|
|
9
|
+
* dependency (AGENTS.md: peers only, and this is a host artifact, not a plugin
|
|
10
|
+
* peer). The catalog is therefore located at run time and imported
|
|
11
|
+
* DYNAMICALLY — no static import, so the repo builds and its tests run without
|
|
12
|
+
* a DSH install.
|
|
13
|
+
*
|
|
14
|
+
* Resolution order (first hit wins; every miss yields `null` — the caller fails
|
|
15
|
+
* closed and writes nothing):
|
|
16
|
+
* 1. explicit `catalogPath` (the CLI's `--catalog <path>`);
|
|
17
|
+
* 2. `$DSH_SESSION_FORMAT_CATALOG`;
|
|
18
|
+
* 3. the catalog the `dsh` binary on `PATH` itself resolves (its own
|
|
19
|
+
* `createRequire` resolution first, then the nearest ancestor
|
|
20
|
+
* `node_modules` that carries it);
|
|
21
|
+
* 4. `~/.npm/_npx/ *\/node_modules` (the npx cache `dsh` runs from).
|
|
22
|
+
* An explicitly configured catalog that does not resolve is NOT silently
|
|
23
|
+
* replaced by a different release: `resolveCatalog` returns `null` instead of
|
|
24
|
+
* falling through to step 3/4.
|
|
25
|
+
*
|
|
26
|
+
* TRUST BOUNDARY: every step *imports* the resolved module — a catalog is
|
|
27
|
+
* EXECUTED code, not a data file, and the same privilege the user already grants
|
|
28
|
+
* by running this tool as `tsx`. A directory candidate is accepted only when its
|
|
29
|
+
* `package.json` names the catalog package; a **file** candidate is accepted when
|
|
30
|
+
* no owning `package.json` is found (a bare module, e.g. a test fixture) or when
|
|
31
|
+
* the nearest owning one names the catalog package, and is rejected when it
|
|
32
|
+
* belongs to a different package. That check is a guard rail, not a sandbox:
|
|
33
|
+
* `--catalog` and the environment variable still execute what they name.
|
|
34
|
+
*
|
|
35
|
+
* RELEASE PIN: a resolved catalog is only trusted when its own
|
|
36
|
+
* `currentVersion` is at least {@link REQUIRED_CATALOG_VERSION} — the caller
|
|
37
|
+
* asserts it with {@link catalogVersionRefusal} — because this tool encodes a
|
|
38
|
+
* successor with that module and then reads it back with the SAME module, so
|
|
39
|
+
* "it accepts its own output" is not evidence about the release the GUI runs.
|
|
40
|
+
* The handle also carries the catalog's own package version so a report can name
|
|
41
|
+
* the build it trusted.
|
|
42
|
+
*
|
|
43
|
+
* A candidate path may be the catalog package directory, a directory that
|
|
44
|
+
* contains it (`node_modules/...`), or the module entry file itself.
|
|
45
|
+
*
|
|
46
|
+
* Row contract (`ParsedRow`): a row is one physical JSONL record. Event records
|
|
47
|
+
* are exactly `{ type, seq, time, data }`; the FIRST record of a log is the
|
|
48
|
+
* session header, which carries `version` / `id` / `createdAt` / ... at the top
|
|
49
|
+
* level and has no `seq`, so a reader synthesizes the `seq` member `ParsedRow`
|
|
50
|
+
* requires and {@link rowRecord} drops it again. A caller that prefers to keep
|
|
51
|
+
* the header payload under `data` (a wrapped row) is also accepted.
|
|
52
|
+
*/
|
|
53
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
54
|
+
import { createRequire } from 'node:module'
|
|
55
|
+
import { homedir } from 'node:os'
|
|
56
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
57
|
+
import { pathToFileURL } from 'node:url'
|
|
58
|
+
import type { ParsedRow, RefusalClass } from './rules.ts'
|
|
59
|
+
|
|
60
|
+
/** Released catalog package this tool resolves (never a `package.json` peer). */
|
|
61
|
+
const CATALOG_PACKAGE = '@deepseek-ai/dsh-session-format-catalog'
|
|
62
|
+
|
|
63
|
+
/** Short package name, used when a caller points at a directory holding it. */
|
|
64
|
+
const CATALOG_PACKAGE_DIR = 'dsh-session-format-catalog'
|
|
65
|
+
|
|
66
|
+
/** Environment override honoured as resolution step 2. */
|
|
67
|
+
export const CATALOG_ENV_VAR = 'DSH_SESSION_FORMAT_CATALOG'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The oldest released catalog this tool accepts as its oracle: the format
|
|
71
|
+
* generation it repairs TO (`CURRENT_VERSION_FLOOR`, the pre-V3 scope). A catalog
|
|
72
|
+
* below it cannot read a successor this tool would write, so `--apply` must
|
|
73
|
+
* refuse rather than verify its own output against a build the GUI does not run.
|
|
74
|
+
* One constant for both the selection scope and the oracle floor: they are the
|
|
75
|
+
* same number and must not drift.
|
|
76
|
+
*/
|
|
77
|
+
export const REQUIRED_CATALOG_VERSION = 3
|
|
78
|
+
|
|
79
|
+
/** Recovery policy of one catalog restore (released `SessionFormatRecovery`). */
|
|
80
|
+
export type RestoreRecovery = 'strict' | 'recoverable'
|
|
81
|
+
|
|
82
|
+
/** Validation policy of one catalog restore (released `SessionFormatRestoreOptions`). */
|
|
83
|
+
export type RestoreValidation = 'transformed' | 'current'
|
|
84
|
+
|
|
85
|
+
/** One restore request, mirroring the released `SessionFormatRestoreOptions`. */
|
|
86
|
+
export interface RestoreOptions {
|
|
87
|
+
recovery: RestoreRecovery
|
|
88
|
+
validation: RestoreValidation
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The released current artifact a restore settles on (structural view). */
|
|
92
|
+
export interface ReleasedArtifact {
|
|
93
|
+
readonly header: { readonly version: number; readonly [member: string]: unknown }
|
|
94
|
+
readonly inheritedEventCount: number
|
|
95
|
+
readonly events: readonly unknown[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The released single-pass restore (structural view). */
|
|
99
|
+
export interface ReleasedRestore {
|
|
100
|
+
readonly header: ReleasedArtifact['header']
|
|
101
|
+
decodeRow(rowValue: unknown): void
|
|
102
|
+
finish(): ReleasedArtifact
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The catalog members this tool uses, as a STRUCTURAL view of the released
|
|
107
|
+
* `SessionFormatCatalog`. Only these four members are relied upon; a module
|
|
108
|
+
* that does not expose them is not accepted as an oracle.
|
|
109
|
+
*/
|
|
110
|
+
export interface ReleasedCatalog {
|
|
111
|
+
readonly currentVersion: number
|
|
112
|
+
createRestore(headerValue: unknown, options: RestoreOptions): ReleasedRestore
|
|
113
|
+
encodeCurrentHeader(header: unknown, inheritedEventCount: number): Record<string, unknown>
|
|
114
|
+
encodeCurrentEvent(event: unknown): Record<string, unknown>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Which resolution step produced a handle (diagnostics for the CLI report). */
|
|
118
|
+
export type CatalogResolvedBy = 'option' | 'env' | 'dsh-path' | 'npx-store'
|
|
119
|
+
|
|
120
|
+
/** A resolved, loadable released catalog. Opaque to callers but for diagnostics. */
|
|
121
|
+
export interface CatalogHandle {
|
|
122
|
+
/** Absolute path of the resolved catalog module entry file. */
|
|
123
|
+
readonly modulePath: string
|
|
124
|
+
/** Resolution step that produced this handle. */
|
|
125
|
+
readonly resolvedBy: CatalogResolvedBy
|
|
126
|
+
/**
|
|
127
|
+
* The catalog package's OWN `version` from its `package.json`, or `null` when
|
|
128
|
+
* no owning manifest could be read. Diagnostic: the report names the build it
|
|
129
|
+
* trusted, not just the path.
|
|
130
|
+
*/
|
|
131
|
+
readonly packageVersion: string | null
|
|
132
|
+
/** The released catalog module's `sessionFormatCatalog` export. */
|
|
133
|
+
readonly catalog: ReleasedCatalog
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Why a resolved catalog may not be used as this run's oracle, or `null` when it
|
|
138
|
+
* may. The caller turns a non-`null` reason into a fatal (exit 2) refusal: a
|
|
139
|
+
* below-floor catalog would encode a successor this tool's own read-back cannot
|
|
140
|
+
* vouch for, and the GUI would not run it either.
|
|
141
|
+
*/
|
|
142
|
+
export function catalogVersionRefusal(handle: CatalogHandle): string | null {
|
|
143
|
+
const version = handle.catalog.currentVersion
|
|
144
|
+
if (typeof version === 'number' && Number.isSafeInteger(version) && version >= REQUIRED_CATALOG_VERSION) {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
return `the resolved released catalog at ${handle.modulePath}`
|
|
148
|
+
+ `${handle.packageVersion === null ? '' : ` (package version ${handle.packageVersion})`}`
|
|
149
|
+
+ ` declares currentVersion ${JSON.stringify(version)}, but this tool requires at least `
|
|
150
|
+
+ `${REQUIRED_CATALOG_VERSION}: it would publish a successor that build cannot read. `
|
|
151
|
+
+ 'Point --catalog (or DSH_SESSION_FORMAT_CATALOG) at the catalog of the release the GUI runs.'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Inputs for {@link resolveCatalog}. */
|
|
155
|
+
export interface ResolveCatalogOptions {
|
|
156
|
+
/** Explicit catalog path (CLI `--catalog`); highest precedence when given. */
|
|
157
|
+
catalogPath?: string
|
|
158
|
+
/** Environment to read (`DSH_SESSION_FORMAT_CATALOG`, `PATH`, home); defaults to `process.env`. */
|
|
159
|
+
env?: NodeJS.ProcessEnv
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ------------------------------------------------------------------ */
|
|
163
|
+
/* resolution */
|
|
164
|
+
/* ------------------------------------------------------------------ */
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve the released catalog, or `null` when no usable catalog exists.
|
|
168
|
+
*
|
|
169
|
+
* Never throws for a missing/broken oracle: the caller fails closed. A catalog
|
|
170
|
+
* that resolves but does not load (or does not expose the released members) is
|
|
171
|
+
* also reported as `null`.
|
|
172
|
+
*
|
|
173
|
+
* @param opts explicit path and/or environment to resolve against.
|
|
174
|
+
* @returns a handle wrapping the released catalog, or `null`.
|
|
175
|
+
*/
|
|
176
|
+
export async function resolveCatalog(opts: ResolveCatalogOptions = {}): Promise<CatalogHandle | null> {
|
|
177
|
+
const env = opts.env ?? process.env
|
|
178
|
+
const explicit = opts.catalogPath
|
|
179
|
+
if (explicit !== undefined && explicit.length > 0) return loadCatalog(explicit, 'option', env)
|
|
180
|
+
const fromEnv = env[CATALOG_ENV_VAR]
|
|
181
|
+
if (fromEnv !== undefined && fromEnv.length > 0) return loadCatalog(fromEnv, 'env', env)
|
|
182
|
+
return (
|
|
183
|
+
(await loadCatalog(resolveFromDshBinary(env), 'dsh-path', env))
|
|
184
|
+
?? (await loadCatalog(resolveFromNpxStore(env), 'npx-store', env))
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Import the fixed member of a resolved candidate path, or reject the candidate. */
|
|
189
|
+
async function loadCatalog(
|
|
190
|
+
candidate: string | null,
|
|
191
|
+
resolvedBy: CatalogResolvedBy,
|
|
192
|
+
env: NodeJS.ProcessEnv,
|
|
193
|
+
): Promise<CatalogHandle | null> {
|
|
194
|
+
if (candidate === null) return null
|
|
195
|
+
const modulePath = resolveCatalogEntry(candidate, env)
|
|
196
|
+
if (modulePath === null) return null
|
|
197
|
+
try {
|
|
198
|
+
const loaded = (await import(pathToFileURL(modulePath).href)) as { sessionFormatCatalog?: unknown }
|
|
199
|
+
if (!isReleasedCatalog(loaded.sessionFormatCatalog)) return null
|
|
200
|
+
return {
|
|
201
|
+
modulePath,
|
|
202
|
+
resolvedBy,
|
|
203
|
+
packageVersion: catalogPackageVersion(modulePath),
|
|
204
|
+
catalog: loaded.sessionFormatCatalog,
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The owning catalog package's own `version`, walking up from a module entry to
|
|
213
|
+
* the nearest `package.json` that names the catalog package; `null` when none is
|
|
214
|
+
* found or it carries no string `version`.
|
|
215
|
+
*/
|
|
216
|
+
function catalogPackageVersion(modulePath: string): string | null {
|
|
217
|
+
const owner = owningPackageDirectory(modulePath)
|
|
218
|
+
if (owner === null) return null
|
|
219
|
+
const manifest = readManifest(owner)
|
|
220
|
+
const version = manifest === null ? undefined : manifest['version']
|
|
221
|
+
return typeof version === 'string' && version.length > 0 ? version : null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The nearest ancestor directory of `modulePath` whose `package.json` names the
|
|
226
|
+
* catalog package, or `null`. Bounded: a module entry sits at most a few levels
|
|
227
|
+
* below its package root.
|
|
228
|
+
*/
|
|
229
|
+
function owningPackageDirectory(modulePath: string): string | null {
|
|
230
|
+
let dir = dirname(modulePath)
|
|
231
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
232
|
+
if (isCatalogPackage(dir)) return dir
|
|
233
|
+
const parent = dirname(dir)
|
|
234
|
+
if (parent === dir) break
|
|
235
|
+
dir = parent
|
|
236
|
+
}
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Whether a candidate path exposes the released catalog members this tool relies on. */
|
|
241
|
+
function isReleasedCatalog(value: unknown): value is ReleasedCatalog {
|
|
242
|
+
if (typeof value !== 'object' || value === null) return false
|
|
243
|
+
const candidate = value as Partial<ReleasedCatalog>
|
|
244
|
+
return (
|
|
245
|
+
typeof candidate.currentVersion === 'number'
|
|
246
|
+
&& typeof candidate.createRestore === 'function'
|
|
247
|
+
&& typeof candidate.encodeCurrentHeader === 'function'
|
|
248
|
+
&& typeof candidate.encodeCurrentEvent === 'function'
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Expand `~` and make a caller-supplied path absolute. */
|
|
253
|
+
function resolveCandidate(candidate: string, env: NodeJS.ProcessEnv): string {
|
|
254
|
+
const home = homeDirectory(env)
|
|
255
|
+
if (candidate === '~') return home
|
|
256
|
+
if (candidate.startsWith('~/') || candidate.startsWith('~\\')) return join(home, candidate.slice(2))
|
|
257
|
+
return isAbsolute(candidate) ? candidate : resolve(candidate)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The home directory the given environment names, falling back to the OS. */
|
|
261
|
+
function homeDirectory(env: NodeJS.ProcessEnv): string {
|
|
262
|
+
return env['HOME'] ?? env['USERPROFILE'] ?? homedir()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Normalize a candidate (package dir, a dir containing it, or a module file)
|
|
267
|
+
* to the catalog module entry file that can be imported.
|
|
268
|
+
*
|
|
269
|
+
* A FILE candidate is accepted when no owning `package.json` names a different
|
|
270
|
+
* package: a bare module (a fixture, a hand-written shim) has no owner and is
|
|
271
|
+
* taken at face value, while a module that provably belongs to another package is
|
|
272
|
+
* refused — the documented trust boundary of an executed, not parsed, input.
|
|
273
|
+
*/
|
|
274
|
+
function resolveCatalogEntry(candidate: string, env: NodeJS.ProcessEnv): string | null {
|
|
275
|
+
const path = resolveCandidate(candidate, env)
|
|
276
|
+
if (!existsSync(path)) return null
|
|
277
|
+
if (statSync(path).isFile()) return fileCandidate(path)
|
|
278
|
+
if (isCatalogPackage(path)) return packageEntry(path)
|
|
279
|
+
for (const nested of [
|
|
280
|
+
join(path, CATALOG_PACKAGE),
|
|
281
|
+
join(path, 'node_modules', CATALOG_PACKAGE),
|
|
282
|
+
join(path, CATALOG_PACKAGE_DIR),
|
|
283
|
+
]) {
|
|
284
|
+
if (isCatalogPackage(nested)) return packageEntry(nested)
|
|
285
|
+
}
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** One explicit FILE candidate: refuse it when it belongs to another package. */
|
|
290
|
+
function fileCandidate(path: string): string | null {
|
|
291
|
+
let dir = dirname(path)
|
|
292
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
293
|
+
const manifest = readManifest(dir)
|
|
294
|
+
if (manifest !== null) {
|
|
295
|
+
// The nearest owning manifest decides: the catalog package, or nothing.
|
|
296
|
+
return manifest['name'] === CATALOG_PACKAGE ? path : null
|
|
297
|
+
}
|
|
298
|
+
const parent = dirname(dir)
|
|
299
|
+
if (parent === dir) break
|
|
300
|
+
dir = parent
|
|
301
|
+
}
|
|
302
|
+
return path
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Whether a directory's own `package.json` names the released catalog package. */
|
|
306
|
+
function isCatalogPackage(dir: string): boolean {
|
|
307
|
+
const manifest = readManifest(dir)
|
|
308
|
+
return manifest !== null && manifest['name'] === CATALOG_PACKAGE
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Read one package directory's `package.json` as a record, or `null`. */
|
|
312
|
+
function readManifest(dir: string): Record<string, unknown> | null {
|
|
313
|
+
const manifestPath = join(dir, 'package.json')
|
|
314
|
+
if (!existsSync(manifestPath)) return null
|
|
315
|
+
try {
|
|
316
|
+
const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
317
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null
|
|
318
|
+
} catch {
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Resolve one package directory's ESM entry file (`exports['.']` then `main`). */
|
|
324
|
+
function packageEntry(packageDir: string): string | null {
|
|
325
|
+
const manifest = readManifest(packageDir)
|
|
326
|
+
if (manifest === null) return null
|
|
327
|
+
const exports = manifest['exports']
|
|
328
|
+
const root = typeof exports === 'object' && exports !== null
|
|
329
|
+
? (exports as Record<string, unknown>)['.']
|
|
330
|
+
: undefined
|
|
331
|
+
const entry = typeof root === 'string'
|
|
332
|
+
? root
|
|
333
|
+
: typeof root === 'object' && root !== null
|
|
334
|
+
? (root as Record<string, unknown>)['default']
|
|
335
|
+
: manifest['main']
|
|
336
|
+
if (typeof entry !== 'string') return null
|
|
337
|
+
const entryPath = join(packageDir, entry)
|
|
338
|
+
return existsSync(entryPath) ? entryPath : null
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Step 3: the catalog the `dsh` binary on `PATH` itself resolves.
|
|
343
|
+
*
|
|
344
|
+
* The binary's OWN `createRequire` resolution comes first, because that is the
|
|
345
|
+
* resolution the running dsh package performs (it follows the package's real
|
|
346
|
+
* dependency layout, including pnpm's global layout where the binary sits outside
|
|
347
|
+
* the tree that owns its dependencies) — a nearer ancestor `node_modules` that
|
|
348
|
+
* merely *contains* a catalog is not necessarily the one the user's dsh runs, so
|
|
349
|
+
* it is only a fallback.
|
|
350
|
+
*/
|
|
351
|
+
function resolveFromDshBinary(env: NodeJS.ProcessEnv): string | null {
|
|
352
|
+
const found = findOnPath('dsh', env)
|
|
353
|
+
if (found === null) return null
|
|
354
|
+
const binary = realpath(found)
|
|
355
|
+
try {
|
|
356
|
+
return createRequire(binary).resolve(CATALOG_PACKAGE)
|
|
357
|
+
} catch {
|
|
358
|
+
// Fall through to the ancestor walk below.
|
|
359
|
+
}
|
|
360
|
+
let dir = dirname(binary)
|
|
361
|
+
for (;;) {
|
|
362
|
+
const nested = join(dir, 'node_modules', CATALOG_PACKAGE)
|
|
363
|
+
if (existsSync(nested)) return nested
|
|
364
|
+
const parent = dirname(dir)
|
|
365
|
+
if (parent === dir) break
|
|
366
|
+
dir = parent
|
|
367
|
+
}
|
|
368
|
+
return null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Absolute path of one executable name on the environment's `PATH`, or `null`. */
|
|
372
|
+
function findOnPath(name: string, env: NodeJS.ProcessEnv): string | null {
|
|
373
|
+
for (const entry of (env['PATH'] ?? '').split(delimiter)) {
|
|
374
|
+
if (entry.length === 0) continue
|
|
375
|
+
for (const candidate of [name, `${name}.cmd`, `${name}.exe`]) {
|
|
376
|
+
const full = join(entry, candidate)
|
|
377
|
+
try {
|
|
378
|
+
if (statSync(full).isFile()) return full
|
|
379
|
+
} catch {
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return null
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Follow a symlink to its target, falling back to the path itself. */
|
|
388
|
+
function realpath(path: string): string {
|
|
389
|
+
try {
|
|
390
|
+
return realpathSync(path)
|
|
391
|
+
} catch {
|
|
392
|
+
return path
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Step 4: the npx cache (`~/.npm/_npx/<hash>/node_modules`), newest install first. */
|
|
397
|
+
function resolveFromNpxStore(env: NodeJS.ProcessEnv): string | null {
|
|
398
|
+
const npxDir = join(homeDirectory(env), '.npm', '_npx')
|
|
399
|
+
if (!existsSync(npxDir)) return null
|
|
400
|
+
let entries: string[]
|
|
401
|
+
try {
|
|
402
|
+
entries = readdirSync(npxDir)
|
|
403
|
+
} catch {
|
|
404
|
+
return null
|
|
405
|
+
}
|
|
406
|
+
const candidates = entries
|
|
407
|
+
.map((entry) => join(npxDir, entry, 'node_modules', CATALOG_PACKAGE))
|
|
408
|
+
.filter((candidate) => existsSync(candidate))
|
|
409
|
+
.map((candidate) => ({ candidate, mtime: modificationTime(candidate) }))
|
|
410
|
+
.sort((left, right) => right.mtime - left.mtime)
|
|
411
|
+
return candidates[0]?.candidate ?? null
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Modification time of one path in ms, or 0 when it cannot be read. */
|
|
415
|
+
function modificationTime(path: string): number {
|
|
416
|
+
try {
|
|
417
|
+
return statSync(path).mtimeMs
|
|
418
|
+
} catch {
|
|
419
|
+
return 0
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* ------------------------------------------------------------------ */
|
|
424
|
+
/* row / record conversion and restore */
|
|
425
|
+
/* ------------------------------------------------------------------ */
|
|
426
|
+
|
|
427
|
+
/** Plain-object guard for the physical records handed to the catalog. */
|
|
428
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
429
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Copy own enumerable members, dropping `undefined` (JSON has no `undefined`). */
|
|
433
|
+
function withoutUndefined(record: object): Record<string, unknown> {
|
|
434
|
+
const out: Record<string, unknown> = {}
|
|
435
|
+
for (const [key, value] of Object.entries(record)) {
|
|
436
|
+
if (value !== undefined) out[key] = value
|
|
437
|
+
}
|
|
438
|
+
return out
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The physical JSON record of one parsed row (see the module docblock).
|
|
443
|
+
*
|
|
444
|
+
* Event rows are their own record. The header record carries no `seq` on the
|
|
445
|
+
* wire, so the reader-synthesized `seq` is dropped for `type: 'session'`; a row
|
|
446
|
+
* that keeps the header payload under `data` is resolved to that payload.
|
|
447
|
+
*/
|
|
448
|
+
export function rowRecord(row: ParsedRow): Record<string, unknown> {
|
|
449
|
+
if (row.type !== 'session') return withoutUndefined(row)
|
|
450
|
+
const data = row.data
|
|
451
|
+
if (isRecord(data) && typeof data['version'] === 'number' && typeof data['id'] === 'string') {
|
|
452
|
+
return withoutUndefined(data)
|
|
453
|
+
}
|
|
454
|
+
const { seq: _seq, time: _time, data: _data, ...rest } = row
|
|
455
|
+
return withoutUndefined(rest)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Restore parsed rows through the released catalog and return the current
|
|
460
|
+
* artifact. Throws the FIRST refusal (released chain order) unchanged, so the
|
|
461
|
+
* caller can classify it; no partial artifact escapes.
|
|
462
|
+
*
|
|
463
|
+
* @param catalog released catalog to restore through.
|
|
464
|
+
* @param rows parsed log rows in log order (header first).
|
|
465
|
+
* @param options recovery and validation policy of the restore.
|
|
466
|
+
*/
|
|
467
|
+
export function restoreRows(
|
|
468
|
+
catalog: ReleasedCatalog,
|
|
469
|
+
rows: readonly ParsedRow[],
|
|
470
|
+
options: RestoreOptions,
|
|
471
|
+
): ReleasedArtifact {
|
|
472
|
+
const [header, ...events] = rows
|
|
473
|
+
if (header === undefined) throw new Error('cannot restore an empty session log: the header row is missing')
|
|
474
|
+
const restore = catalog.createRestore(rowRecord(header), options)
|
|
475
|
+
for (const row of events) restore.decodeRow(rowRecord(row))
|
|
476
|
+
return restore.finish()
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Concatenated message text of an error and its `cause` chain. */
|
|
480
|
+
function errorChainText(error: unknown): string {
|
|
481
|
+
const parts: string[] = []
|
|
482
|
+
let current: unknown = error
|
|
483
|
+
for (let depth = 0; depth < 8 && current !== null && current !== undefined; depth += 1) {
|
|
484
|
+
if (!(current instanceof Error)) {
|
|
485
|
+
parts.push(String(current))
|
|
486
|
+
break
|
|
487
|
+
}
|
|
488
|
+
parts.push(current.message)
|
|
489
|
+
current = current.cause
|
|
490
|
+
}
|
|
491
|
+
return parts.join('\n')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The class of one refusal thrown by the released catalog restore.
|
|
496
|
+
*
|
|
497
|
+
* The mapping is over the released build's OWN prose, so every entry cites the
|
|
498
|
+
* raise site that produces it (pinned checkout; the installed `0.1.5-rc.1`
|
|
499
|
+
* build's line is given for the same statement). A released reword therefore
|
|
500
|
+
* degrades these logs to `other-refusal` (unrepairable, nothing written) until
|
|
501
|
+
* this table is updated — which is exactly what the two real-catalog fixtures in
|
|
502
|
+
* `tests/repair-session-logs.spec.ts` (`describe.skipIf(realCatalog === null)`,
|
|
503
|
+
* foreign-`source.kind`-first and legacy-first) exist to catch:
|
|
504
|
+
* - `cannot safely transform unclassified message source` —
|
|
505
|
+
* `session-format-v2-to-v3/src/payload.ts:113` (`assertSource`; installed
|
|
506
|
+
* `dsh-session-format-v2-to-v3/lib/index.js:125`);
|
|
507
|
+
* - `uses unsupported descriptor version` —
|
|
508
|
+
* `session-format-v0-to-v1/src/validation.ts:202`
|
|
509
|
+
* (`assertReleasedEventPayload`; installed `…-v0-to-v1/lib/index.js:1586`);
|
|
510
|
+
* - `unknown historical event type` —
|
|
511
|
+
* `session-format-v0-to-v1/src/validation.ts:120` and `:194` (installed
|
|
512
|
+
* `…-v0-to-v1/lib/index.js:1530`, `:1582`).
|
|
513
|
+
*/
|
|
514
|
+
function refusalClass(error: unknown): RefusalClass {
|
|
515
|
+
const text = errorChainText(error)
|
|
516
|
+
if (text.includes('unclassified message source')) return 'source-kind'
|
|
517
|
+
if (text.includes('unsupported descriptor version')) return 'subagent-descriptor-version'
|
|
518
|
+
if (text.includes('unknown historical event type')) return 'unknown-event-type'
|
|
519
|
+
return 'other-refusal'
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Classify parsed rows against the released catalog: the truthful first-refusal
|
|
524
|
+
* class, not the structural approximation `classifyRows` reports.
|
|
525
|
+
*
|
|
526
|
+
* The restore runs under the pinned policy
|
|
527
|
+
* `createRestore(header, { recovery: 'recoverable', validation: 'transformed' })`
|
|
528
|
+
* and every row is decoded before `finish()`, so the class is the one the
|
|
529
|
+
* released chain raises first for this log.
|
|
530
|
+
*
|
|
531
|
+
* Returned classes: `'ok'` when the restore completes, `'source-kind'`,
|
|
532
|
+
* `'subagent-descriptor-version'`, `'unknown-event-type'` for the three released
|
|
533
|
+
* refusals this tool repairs or reports, and `'other-refusal'` for everything
|
|
534
|
+
* else. `'decompress-failed'` is not produced here: the caller passes rows that
|
|
535
|
+
* already decoded.
|
|
536
|
+
*
|
|
537
|
+
* @param rows parsed log rows in log order (header first).
|
|
538
|
+
* @param catalog resolved released catalog oracle.
|
|
539
|
+
*/
|
|
540
|
+
export function classifyWithCatalog(rows: readonly ParsedRow[], catalog: CatalogHandle): RefusalClass {
|
|
541
|
+
try {
|
|
542
|
+
restoreRows(catalog.catalog, rows, { recovery: 'recoverable', validation: 'transformed' })
|
|
543
|
+
return 'ok'
|
|
544
|
+
} catch (error) {
|
|
545
|
+
return refusalClass(error)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* classify.ts — the refusal classifier over one parsed session log.
|
|
3
|
+
*
|
|
4
|
+
* The released chain validates a log row by row and throws at the FIRST bad
|
|
5
|
+
* row, so this classifier walks the rows in log order and reports the class of
|
|
6
|
+
* that first refusal, together with every finding it collected. Each rule's
|
|
7
|
+
* `detect` is called once per row (a single-row slice) for exactly that reason —
|
|
8
|
+
* see the row-local contract on `LogRule`.
|
|
9
|
+
*
|
|
10
|
+
* Scope: this is the STRUCTURAL classifier. It knows only the classes the rule
|
|
11
|
+
* registry models, so `'ok'` means "no structural refusal found", never "the
|
|
12
|
+
* released chain accepts this log" — the catalog read-back is the truth. The
|
|
13
|
+
* read/restore layers, not a row rule, produce `'other-refusal'` (a row shape
|
|
14
|
+
* failure no rule models) and `'decompress-failed'`.
|
|
15
|
+
*/
|
|
16
|
+
import type { Finding, LogRule, ParsedRow, RefusalClass } from './rules.ts'
|
|
17
|
+
|
|
18
|
+
export interface Classification {
|
|
19
|
+
/** Class of the first refusal in log order; `'ok'` when no rule reports one. */
|
|
20
|
+
class: RefusalClass
|
|
21
|
+
/** Every finding, in log order (row order, then registry order). */
|
|
22
|
+
findings: Finding[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Classify one parsed log: `{ class, findings }` for its first refusal class.
|
|
27
|
+
*
|
|
28
|
+
* @param rows parsed log rows in log order (header first).
|
|
29
|
+
* @param rules rules to apply, in check order.
|
|
30
|
+
*/
|
|
31
|
+
export function classifyRows(rows: readonly ParsedRow[], rules: readonly LogRule[]): Classification {
|
|
32
|
+
const findings: Finding[] = []
|
|
33
|
+
let refusalClass: RefusalClass = 'ok'
|
|
34
|
+
let firstRefusal = true
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
for (const rule of rules) {
|
|
37
|
+
const detected = rule.detect([row])
|
|
38
|
+
if (detected.length === 0) continue
|
|
39
|
+
if (firstRefusal) {
|
|
40
|
+
refusalClass = rule.class
|
|
41
|
+
firstRefusal = false
|
|
42
|
+
}
|
|
43
|
+
findings.push(...detected)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { class: refusalClass, findings }
|
|
47
|
+
}
|