docks-kit 0.16.16 → 0.17.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/AGENTS.md +5 -1
- package/cli/docs/flags.md +21 -0
- package/cli/docs/install.md +1 -0
- package/cli/docs/omp-models.md +59 -0
- package/cli/docs/overview.md +1 -1
- package/cli/docs/sync-layers.md +1 -1
- package/cli/docs/toolchain.md +16 -3
- package/cli/src/argv.ts +63 -4
- package/cli/src/commands/omp.ts +189 -0
- package/cli/src/commands/sync.ts +1 -1
- package/cli/src/commands/update.ts +8 -52
- package/cli/src/engine-native/claudePlugins.ts +78 -35
- package/cli/src/engine-native/deps.ts +9 -0
- package/cli/src/engine-native/exec.ts +54 -2
- package/cli/src/engine-native/harnesses.ts +93 -12
- package/cli/src/engine-native/ompOverlay.ts +174 -0
- package/cli/src/engine-native/toolchain.ts +3 -0
- package/cli/src/generated/sotPayload.ts +4 -4
- package/cli/src/main.ts +4 -1
- package/package.json +1 -1
|
@@ -402,43 +402,10 @@ async function nodeBelowServerFloor(ctx: Ctx): Promise<string> {
|
|
|
402
402
|
return belowFloor(installed, floor) ? installed : ""
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
-
|
|
405
|
+
/** npm-global channel: intelephense, typescript-language-server, typescript. */
|
|
406
|
+
async function installNpmServers(ctx: Ctx, missing: ReadonlyArray<string>): Promise<void> {
|
|
406
407
|
const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
|
|
407
|
-
const sot = parseJson(payloadText("SoT/.claude/settings.json"))
|
|
408
|
-
const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
|
|
409
|
-
if (enabled === undefined) return
|
|
410
|
-
const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
|
|
411
|
-
const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
|
|
412
|
-
if (!hasPhp && !hasTs) return
|
|
413
|
-
|
|
414
|
-
const phpMissing = hasPhp && ctx.services.deps.probe("intelephense").state === "missing"
|
|
415
|
-
const tsServerMissing = hasTs && ctx.services.deps.probe("typescript-language-server").state === "missing"
|
|
416
|
-
const tscMissing = hasTs && ctx.services.deps.probe("tsc").state === "missing"
|
|
417
|
-
const missingToolCount = Number(phpMissing) + Number(tsServerMissing) + Number(tscMissing)
|
|
418
|
-
const blockingNode = tsServerMissing ? await nodeBelowServerFloor(ctx) : ""
|
|
419
|
-
if (blockingNode !== "") {
|
|
420
|
-
warn(
|
|
421
|
-
`Skipping typescript-language-server install: Node ${blockingNode} is older than the ${field("node", "floor")} that version requires. Upgrade Node, then re-run sync.`
|
|
422
|
-
)
|
|
423
|
-
}
|
|
424
|
-
const missing = [
|
|
425
|
-
phpMissing ? lspPkg(ctx, "intelephense", "intelephense") : undefined,
|
|
426
|
-
tsServerMissing && blockingNode === ""
|
|
427
|
-
? lspPkg(ctx, "typescript-language-server", "typescript-language-server")
|
|
428
|
-
: undefined,
|
|
429
|
-
tscMissing ? lspPkg(ctx, "tsc", "typescript") : undefined
|
|
430
|
-
].filter((spec): spec is string => spec !== undefined)
|
|
431
|
-
|
|
432
|
-
if (missingToolCount === 0) {
|
|
433
|
-
if (ctx.dryRun) {
|
|
434
|
-
echo("[dry-run] LSP server binaries present")
|
|
435
|
-
} else {
|
|
436
|
-
verbose("LSP server binaries present")
|
|
437
|
-
}
|
|
438
|
-
return
|
|
439
|
-
}
|
|
440
408
|
if (missing.length === 0) return
|
|
441
|
-
|
|
442
409
|
const specs = missing.join(" ")
|
|
443
410
|
if (ctx.dryRun) {
|
|
444
411
|
echo(`[dry-run] would install: npm install -g ${specs}`)
|
|
@@ -465,3 +432,79 @@ export async function syncLspServers(ctx: Ctx): Promise<void> {
|
|
|
465
432
|
warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
|
|
466
433
|
}
|
|
467
434
|
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* rustup channel: rust-analyzer ships no npm package, and upstream's first
|
|
438
|
+
* recommendation is the rustup component, which tracks the toolchain the host
|
|
439
|
+
* already trusts. The component version therefore follows that toolchain, so
|
|
440
|
+
* the manifest row carries no verified pin - the stance bubblewrap already
|
|
441
|
+
* takes for a distro package.
|
|
442
|
+
*/
|
|
443
|
+
async function installRustAnalyzer(ctx: Ctx): Promise<void> {
|
|
444
|
+
const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
|
|
445
|
+
if (ctx.dryRun) {
|
|
446
|
+
echo("[dry-run] would install: rustup component add rust-analyzer")
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
verbose("Installing rust-analyzer via rustup...")
|
|
450
|
+
progress("Installing rust-analyzer via rustup...")
|
|
451
|
+
const result = await spawnProcess("rustup", ["component", "add", "rust-analyzer"], { stdio: "ignore" })
|
|
452
|
+
clearProgress()
|
|
453
|
+
if (result.exitCode === 0) {
|
|
454
|
+
change("LSP server installed (rust-analyzer via rustup)")
|
|
455
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
456
|
+
} else {
|
|
457
|
+
warn("rustup component add rust-analyzer failed. Try manually: rustup component add rust-analyzer")
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export async function syncLspServers(ctx: Ctx): Promise<void> {
|
|
462
|
+
const { echo, verbose, warn } = ctx.services.logger
|
|
463
|
+
const sot = parseJson(payloadText("SoT/.claude/settings.json"))
|
|
464
|
+
const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
|
|
465
|
+
if (enabled === undefined) return
|
|
466
|
+
const enables = (plugin: string): boolean => Object.prototype.hasOwnProperty.call(enabled, plugin)
|
|
467
|
+
const hasPhp = enables("php-lsp@claude-plugins-official")
|
|
468
|
+
const hasTs = enables("typescript-lsp@claude-plugins-official")
|
|
469
|
+
const hasRust = enables("rust-analyzer-lsp@claude-plugins-official")
|
|
470
|
+
if (!hasPhp && !hasTs && !hasRust) return
|
|
471
|
+
|
|
472
|
+
const phpMissing = hasPhp && ctx.services.deps.probe("intelephense").state === "missing"
|
|
473
|
+
const tsServerMissing = hasTs && ctx.services.deps.probe("typescript-language-server").state === "missing"
|
|
474
|
+
const tscMissing = hasTs && ctx.services.deps.probe("tsc").state === "missing"
|
|
475
|
+
const rustMissing = hasRust && ctx.services.deps.probe("rust-analyzer").state === "missing"
|
|
476
|
+
// A host without rustup does no Rust work, so the plugin has nothing to
|
|
477
|
+
// serve there. Installing is impossible and warning every sync would name no
|
|
478
|
+
// action the user wants, so that host is not counted as missing a tool.
|
|
479
|
+
const rustInstallable = rustMissing && ctx.services.deps.probe("rustup").state === "present"
|
|
480
|
+
if (rustMissing && !rustInstallable) {
|
|
481
|
+
verbose("Skipping rust-analyzer: rustup is not installed, so the rust-analyzer-lsp plugin stays a no-op")
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const missingToolCount = Number(phpMissing) + Number(tsServerMissing) + Number(tscMissing) + Number(rustInstallable)
|
|
485
|
+
if (missingToolCount === 0) {
|
|
486
|
+
if (ctx.dryRun) {
|
|
487
|
+
echo("[dry-run] LSP server binaries present")
|
|
488
|
+
} else {
|
|
489
|
+
verbose("LSP server binaries present")
|
|
490
|
+
}
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const blockingNode = tsServerMissing ? await nodeBelowServerFloor(ctx) : ""
|
|
495
|
+
if (blockingNode !== "") {
|
|
496
|
+
warn(
|
|
497
|
+
`Skipping typescript-language-server install: Node ${blockingNode} is older than the ${field("node", "floor")} that version requires. Upgrade Node, then re-run sync.`
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
const missing = [
|
|
501
|
+
phpMissing ? lspPkg(ctx, "intelephense", "intelephense") : undefined,
|
|
502
|
+
tsServerMissing && blockingNode === ""
|
|
503
|
+
? lspPkg(ctx, "typescript-language-server", "typescript-language-server")
|
|
504
|
+
: undefined,
|
|
505
|
+
tscMissing ? lspPkg(ctx, "tsc", "typescript") : undefined
|
|
506
|
+
].filter((spec): spec is string => spec !== undefined)
|
|
507
|
+
|
|
508
|
+
await installNpmServers(ctx, missing)
|
|
509
|
+
if (rustInstallable) await installRustAnalyzer(ctx)
|
|
510
|
+
}
|
|
@@ -30,6 +30,8 @@ export type ToolId =
|
|
|
30
30
|
| "intelephense"
|
|
31
31
|
| "typescript-language-server"
|
|
32
32
|
| "tsc"
|
|
33
|
+
| "rust-analyzer"
|
|
34
|
+
| "rustup"
|
|
33
35
|
| "apt-get"
|
|
34
36
|
| "dnf"
|
|
35
37
|
| "pacman"
|
|
@@ -224,6 +226,13 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
|
|
|
224
226
|
resolve: pathProbe("tsc"),
|
|
225
227
|
version: versionProbe("tsc")
|
|
226
228
|
}),
|
|
229
|
+
"rust-analyzer": spec("rust-analyzer", "optional", () => "rustup component add rust-analyzer", {
|
|
230
|
+
resolve: pathProbe("rust-analyzer"),
|
|
231
|
+
version: versionProbe("rust-analyzer")
|
|
232
|
+
}),
|
|
233
|
+
rustup: spec("rustup", "optional", () => "install the Rust toolchain from https://rustup.rs", {
|
|
234
|
+
version: versionProbe("rustup")
|
|
235
|
+
}),
|
|
227
236
|
"apt-get": spec("apt-get", "optional", () => "install apt via your Linux distribution"),
|
|
228
237
|
dnf: spec("dnf", "optional", () => "install dnf via your Linux distribution"),
|
|
229
238
|
pacman: spec("pacman", "optional", () => "install pacman via your Linux distribution"),
|
|
@@ -3,10 +3,62 @@
|
|
|
3
3
|
* the intended binary with deterministic argv, and capture() mirrors command
|
|
4
4
|
* substitution: stdout with trailing newlines stripped, empty on failure.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
spawn,
|
|
8
|
+
spawnSync,
|
|
9
|
+
type ChildProcess,
|
|
10
|
+
type SpawnOptions,
|
|
11
|
+
type SpawnSyncOptions,
|
|
12
|
+
type SpawnSyncOptionsWithStringEncoding,
|
|
13
|
+
type SpawnSyncReturns
|
|
14
|
+
} from "node:child_process"
|
|
7
15
|
import { accessSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
8
16
|
import { delimiter, extname, isAbsolute, join } from "node:path"
|
|
9
|
-
import { hostOs, type HostOs } from "./os"
|
|
17
|
+
import { hostOs, type HostOs, type Invocation } from "./os"
|
|
18
|
+
|
|
19
|
+
/** A tool this host cannot resolve, shaped like the failed spawn it replaces. */
|
|
20
|
+
const notFound = (command: string): SpawnSyncReturns<string> => ({
|
|
21
|
+
pid: 0,
|
|
22
|
+
output: [],
|
|
23
|
+
stdout: "",
|
|
24
|
+
stderr: "",
|
|
25
|
+
status: null,
|
|
26
|
+
signal: null,
|
|
27
|
+
error: new Error(`command not found on PATH: ${command}`)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every synchronous child starts here, because two host facts must never be
|
|
32
|
+
* separated from the argv they describe: a Windows shim invocation is only
|
|
33
|
+
* correct with the verbatim-arguments flag, and a pathless name would let
|
|
34
|
+
* CreateProcess search the parent's current directory before the system one.
|
|
35
|
+
*/
|
|
36
|
+
export const spawnHost = (
|
|
37
|
+
command: string,
|
|
38
|
+
args: ReadonlyArray<string>,
|
|
39
|
+
overrides: SpawnSyncOptions = {},
|
|
40
|
+
host: HostOs = hostOs()
|
|
41
|
+
): SpawnSyncReturns<string> => {
|
|
42
|
+
const resolvesSuffixes = host.executableSuffixes.some((suffix) => suffix !== "")
|
|
43
|
+
const executablePath = resolvesSuffixes ? which(command, host.executableSuffixes) : command
|
|
44
|
+
if (executablePath === "") return notFound(command)
|
|
45
|
+
let invocation: Invocation
|
|
46
|
+
try {
|
|
47
|
+
invocation = host.invoke(executablePath, args)
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
// A value this host cannot put on a command line at all. Print the encoder's
|
|
50
|
+
// reason and exit, matching how a caller reports a failed child.
|
|
51
|
+
process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
|
|
52
|
+
return process.exit(2)
|
|
53
|
+
}
|
|
54
|
+
const options: SpawnSyncOptionsWithStringEncoding = {
|
|
55
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
56
|
+
...overrides,
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments
|
|
59
|
+
}
|
|
60
|
+
return spawnSync(invocation.command, [...invocation.args], options)
|
|
61
|
+
}
|
|
10
62
|
|
|
11
63
|
/** Keep engine paths slash-separated so rendered output is host-stable. */
|
|
12
64
|
export function p(...parts: Array<string>): string {
|
|
@@ -14,6 +14,21 @@ export type Harness = "claude" | "codex" | "agents" | "omp"
|
|
|
14
14
|
export const HARNESSES: ReadonlyArray<Harness> = ["claude", "codex", "agents", "omp"]
|
|
15
15
|
export const LEGACY_SELECTION: ReadonlyArray<Harness> = ["claude", "codex", "agents"]
|
|
16
16
|
|
|
17
|
+
export interface OmpSessionModel {
|
|
18
|
+
readonly selector: string
|
|
19
|
+
// Session ceiling; absent when the model publishes no ladder, so no
|
|
20
|
+
// invented level ever reaches a selector that omp must resolve.
|
|
21
|
+
readonly thinking?: string
|
|
22
|
+
// Advisor level; absent when the model publishes no ladder.
|
|
23
|
+
readonly advisorThinking?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_OMP_SESSION_MODEL: OmpSessionModel = {
|
|
27
|
+
selector: "opencode-zen/muse-spark-1.3-contributor-free",
|
|
28
|
+
thinking: "xhigh",
|
|
29
|
+
advisorThinking: "medium",
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
function isHarness(value: unknown): value is Harness {
|
|
18
33
|
return value === "claude" || value === "codex" || value === "agents" || value === "omp"
|
|
19
34
|
}
|
|
@@ -36,8 +51,9 @@ export function harnessStateFile(home: string): string {
|
|
|
36
51
|
return p(home, ".docks-kit", "state.json")
|
|
37
52
|
}
|
|
38
53
|
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
// Read the whole state record so one key writer keeps sibling keys intact.
|
|
55
|
+
// A corrupt file degrades to undefined so callers fall back to defaults.
|
|
56
|
+
function readWholeState(home: string): Record<string, unknown> | undefined {
|
|
41
57
|
let parsed: unknown
|
|
42
58
|
try {
|
|
43
59
|
parsed = JSON.parse(readFileSync(harnessStateFile(home), "utf8")) as unknown
|
|
@@ -47,7 +63,30 @@ export function readHarnessSelection(home: string): ReadonlyArray<Harness> | und
|
|
|
47
63
|
|
|
48
64
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
|
|
49
65
|
const state = parsed as Record<string, unknown>
|
|
50
|
-
if (state["version"] !== 1
|
|
66
|
+
if (state["version"] !== 1) return undefined
|
|
67
|
+
return state
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Merge the patch over the stored record so independent keys never erase
|
|
71
|
+
// each other when only one writer runs.
|
|
72
|
+
function writeWholeState(home: string, patch: Record<string, unknown>): void {
|
|
73
|
+
const existing = readWholeState(home) ?? {}
|
|
74
|
+
const next = { ...existing, ...patch, version: 1 }
|
|
75
|
+
const directory = p(home, ".docks-kit")
|
|
76
|
+
const file = harnessStateFile(home)
|
|
77
|
+
const text = `${JSON.stringify(next, null, 2)}\n`
|
|
78
|
+
// `mode` applies only when mkdir creates the path, so an existing permissive
|
|
79
|
+
// ~/.docks-kit would keep its mode.
|
|
80
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
81
|
+
chmodSync(directory, 0o700)
|
|
82
|
+
writeFileSync(file, text, { mode: 0o600 })
|
|
83
|
+
chmodSync(file, 0o600)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read valid local state without allowing corruption to make sync unusable. */
|
|
87
|
+
export function readHarnessSelection(home: string): ReadonlyArray<Harness> | undefined {
|
|
88
|
+
const state = readWholeState(home)
|
|
89
|
+
if (state === undefined || !Array.isArray(state["harnesses"])) return undefined
|
|
51
90
|
|
|
52
91
|
const selection = normalizeHarnesses(state["harnesses"])
|
|
53
92
|
return selection.length > 0 ? selection : undefined
|
|
@@ -63,13 +102,55 @@ export function writeHarnessSelection(home: string, selection: ReadonlyArray<Har
|
|
|
63
102
|
throw new Error("Harness selection must contain at least one known harness name")
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
105
|
+
writeWholeState(home, { harnesses })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isNonBlankString(value: unknown): value is string {
|
|
109
|
+
return typeof value === "string" && value.trim() !== ""
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Read the stored session model without throwing so a corrupt entry falls
|
|
113
|
+
// back to the default instead of breaking sync.
|
|
114
|
+
export function readOmpSessionModel(home: string): OmpSessionModel | undefined {
|
|
115
|
+
const state = readWholeState(home)
|
|
116
|
+
if (state === undefined) return undefined
|
|
117
|
+
const entry = state["ompSession"]
|
|
118
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined
|
|
119
|
+
const record = entry as Record<string, unknown>
|
|
120
|
+
if (!isNonBlankString(record["selector"])) {
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
// Each level stands alone, so a level-free model reads back with no
|
|
124
|
+
// levels while a half-corrupt entry keeps the valid level.
|
|
125
|
+
const model: { selector: string; thinking?: string; advisorThinking?: string } = {
|
|
126
|
+
selector: record["selector"],
|
|
127
|
+
}
|
|
128
|
+
if (isNonBlankString(record["thinking"])) {
|
|
129
|
+
model.thinking = record["thinking"]
|
|
130
|
+
}
|
|
131
|
+
if (isNonBlankString(record["advisorThinking"])) {
|
|
132
|
+
model.advisorThinking = record["advisorThinking"]
|
|
133
|
+
}
|
|
134
|
+
return model
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function writeOmpSessionModel(home: string, model: OmpSessionModel): void {
|
|
138
|
+
// A blank selector would make omp resolve an arbitrary model, so reject it
|
|
139
|
+
// before anything reaches disk.
|
|
140
|
+
if (!isNonBlankString(model.selector)) {
|
|
141
|
+
throw new Error("Omp session model selector must be a non-empty string")
|
|
142
|
+
}
|
|
143
|
+
// Persist only non-blank levels so a switch to a level-free model leaves
|
|
144
|
+
// no stale level behind in the stored record.
|
|
145
|
+
const entry: { selector: string; thinking?: string; advisorThinking?: string } = {
|
|
146
|
+
selector: model.selector,
|
|
147
|
+
}
|
|
148
|
+
if (isNonBlankString(model.thinking)) {
|
|
149
|
+
entry.thinking = model.thinking
|
|
150
|
+
}
|
|
151
|
+
if (isNonBlankString(model.advisorThinking)) {
|
|
152
|
+
entry.advisorThinking = model.advisorThinking
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
writeWholeState(home, { ompSession: entry })
|
|
75
156
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run overlay for `docks-kit omp`. The overlay merges over the deployed omp
|
|
3
|
+
* config for one run only, so every key that can cause paid spend is set here
|
|
4
|
+
* and every other key falls through to the deployed paid config.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto"
|
|
7
|
+
import type { OmpSessionModel } from "./harnesses"
|
|
8
|
+
|
|
9
|
+
export interface CatalogModel {
|
|
10
|
+
readonly selector: string
|
|
11
|
+
readonly name: string
|
|
12
|
+
readonly thinking: ReadonlyArray<string>
|
|
13
|
+
readonly contextWindow: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Thinking levels from lowest to highest effort. */
|
|
17
|
+
export const THINKING_LADDER: ReadonlyArray<string> = [
|
|
18
|
+
"minimal",
|
|
19
|
+
"low",
|
|
20
|
+
"medium",
|
|
21
|
+
"high",
|
|
22
|
+
"xhigh",
|
|
23
|
+
"max",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One overlay file per model, so a second launcher on another model cannot
|
|
28
|
+
* rewrite the file a running session still reads. A selector carries a slash
|
|
29
|
+
* and may carry other characters a host rejects in a file name, so unsafe
|
|
30
|
+
* characters become hyphens. That mapping alone is not injective, because
|
|
31
|
+
* `a/b` and `a-b` both fold to `a-b`, so a digest of the exact selector keeps
|
|
32
|
+
* two distinct models on two distinct files.
|
|
33
|
+
*/
|
|
34
|
+
export function overlayFileName(selector: string): string {
|
|
35
|
+
const safe = selector.replace(/[^A-Za-z0-9._-]/g, "-")
|
|
36
|
+
const digest = createHash("sha256").update(selector).digest("hex").slice(0, 8)
|
|
37
|
+
return `omp-free-${safe}-${digest}.yml`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Role keys in the order used by SoT/.omp/config.yml for readable diffs. */
|
|
41
|
+
const ROLE_ORDER: ReadonlyArray<string> = [
|
|
42
|
+
"smol",
|
|
43
|
+
"advisor",
|
|
44
|
+
"designer",
|
|
45
|
+
"plan",
|
|
46
|
+
"commit",
|
|
47
|
+
"task",
|
|
48
|
+
"vision",
|
|
49
|
+
"tiny",
|
|
50
|
+
"default",
|
|
51
|
+
"slow",
|
|
52
|
+
"fable",
|
|
53
|
+
"switch_fable",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/** Retry chain keys in the order used by SoT/.omp/config.yml. */
|
|
57
|
+
const FALLBACK_ORDER: ReadonlyArray<string> = [
|
|
58
|
+
"default",
|
|
59
|
+
"advisor",
|
|
60
|
+
"task",
|
|
61
|
+
"vision",
|
|
62
|
+
"smol",
|
|
63
|
+
"tiny",
|
|
64
|
+
"commit",
|
|
65
|
+
"switch_fable",
|
|
66
|
+
"fable",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Collect free models from parsed `omp models --json` output. The function is
|
|
71
|
+
* total because catalog output varies across omp versions, and one malformed
|
|
72
|
+
* row must not discard the remaining picker list.
|
|
73
|
+
*/
|
|
74
|
+
export function parseFreeModels(catalogJson: unknown): ReadonlyArray<CatalogModel> {
|
|
75
|
+
if (typeof catalogJson !== "object" || catalogJson === null || Array.isArray(catalogJson)) {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
const models = (catalogJson as Record<string, unknown>)["models"]
|
|
79
|
+
if (!Array.isArray(models)) return []
|
|
80
|
+
const found: Array<CatalogModel> = []
|
|
81
|
+
for (const row of models) {
|
|
82
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) continue
|
|
83
|
+
const record = row as Record<string, unknown>
|
|
84
|
+
const selector = record["selector"]
|
|
85
|
+
if (typeof selector !== "string" || selector.length === 0) continue
|
|
86
|
+
const cost = record["cost"]
|
|
87
|
+
if (typeof cost !== "object" || cost === null || Array.isArray(cost)) continue
|
|
88
|
+
const charges = cost as Record<string, unknown>
|
|
89
|
+
if (charges["input"] !== 0 || charges["output"] !== 0) continue
|
|
90
|
+
const rawName = record["name"]
|
|
91
|
+
const name = typeof rawName === "string" && rawName.length > 0 ? rawName : selector
|
|
92
|
+
const rawLevels = record["thinking"]
|
|
93
|
+
// Keep only known ladder levels in ladder order so an unknown future
|
|
94
|
+
// level never reaches a role value that omp would reject.
|
|
95
|
+
const thinking = THINKING_LADDER.filter(
|
|
96
|
+
(level) => Array.isArray(rawLevels) && (rawLevels as Array<unknown>).includes(level),
|
|
97
|
+
)
|
|
98
|
+
const rawWindow = record["contextWindow"]
|
|
99
|
+
const contextWindow =
|
|
100
|
+
typeof rawWindow === "number" && Number.isFinite(rawWindow) ? rawWindow : 0
|
|
101
|
+
found.push({ selector, name, thinking, contextWindow })
|
|
102
|
+
}
|
|
103
|
+
// Sort by selector so the picker list is stable across catalog orderings.
|
|
104
|
+
found.sort((a, b) => (a.selector < b.selector ? -1 : a.selector > b.selector ? 1 : 0))
|
|
105
|
+
return found
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the highest ladder level a model supports. A model without a
|
|
110
|
+
* recognized ladder yields undefined so callers never invent a level the
|
|
111
|
+
* model would reject at session start.
|
|
112
|
+
*/
|
|
113
|
+
export function ladderCeiling(levels: ReadonlyArray<string>): string | undefined {
|
|
114
|
+
for (let index = THINKING_LADDER.length - 1; index >= 0; index -= 1) {
|
|
115
|
+
if (levels.includes(THINKING_LADDER[index] as string)) return THINKING_LADDER[index] as string
|
|
116
|
+
}
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the advisor level from the model own ladder. The advisor runs
|
|
122
|
+
* quick review passes, so it takes the highest level at or below medium,
|
|
123
|
+
* and the lowest level when the ladder starts above medium. The result
|
|
124
|
+
* never exceeds the ladder ceiling by construction.
|
|
125
|
+
*/
|
|
126
|
+
export function advisorLevelFor(levels: ReadonlyArray<string>): string | undefined {
|
|
127
|
+
const known = THINKING_LADDER.filter((level) => levels.includes(level))
|
|
128
|
+
if (known.length === 0) return undefined
|
|
129
|
+
const mediumIndex = THINKING_LADDER.indexOf("medium")
|
|
130
|
+
let advisor: string | undefined
|
|
131
|
+
for (const level of known) {
|
|
132
|
+
if ((THINKING_LADDER.indexOf(level) as number) <= (mediumIndex as number)) advisor = level
|
|
133
|
+
}
|
|
134
|
+
return advisor ?? (known[0] as string)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render the run overlay as YAML text. Empty fallback chains keep a retry
|
|
139
|
+
* from falling back onto a paid model. A model without a level renders bare
|
|
140
|
+
* selectors with no thinking keys so the deployed values apply.
|
|
141
|
+
*/
|
|
142
|
+
export function renderFreeOverlay(model: OmpSessionModel): string {
|
|
143
|
+
const thinking = typeof model.thinking === "string" && model.thinking.trim() !== "" ? model.thinking : undefined
|
|
144
|
+
const lines: Array<string> = ["# Generated per run by docks-kit omp. Edits are overwritten."]
|
|
145
|
+
if (thinking !== undefined) {
|
|
146
|
+
lines.push(`defaultThinkingLevel: ${thinking}`, "modelRoles:")
|
|
147
|
+
const advisor =
|
|
148
|
+
typeof model.advisorThinking === "string" && model.advisorThinking.trim() !== ""
|
|
149
|
+
? model.advisorThinking
|
|
150
|
+
: thinking
|
|
151
|
+
for (const role of ROLE_ORDER) {
|
|
152
|
+
lines.push(` ${role}: ${model.selector}:${role === "advisor" ? advisor : thinking}`)
|
|
153
|
+
}
|
|
154
|
+
lines.push("task:", ` maxEffort: ${thinking}`)
|
|
155
|
+
} else {
|
|
156
|
+
lines.push("modelRoles:")
|
|
157
|
+
for (const role of ROLE_ORDER) {
|
|
158
|
+
lines.push(` ${role}: ${model.selector}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
lines.push("retry:", " fallbackChains:")
|
|
162
|
+
for (const chain of FALLBACK_ORDER) {
|
|
163
|
+
lines.push(` ${chain}: []`)
|
|
164
|
+
}
|
|
165
|
+
return `${lines.join("\n")}\n`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Build the omp argv with the overlay flag ahead of the forwarded args. */
|
|
169
|
+
export function buildOmpArgs(
|
|
170
|
+
overlayPath: string,
|
|
171
|
+
passthrough: ReadonlyArray<string>,
|
|
172
|
+
): ReadonlyArray<string> {
|
|
173
|
+
return ["--config", overlayPath, ...passthrough]
|
|
174
|
+
}
|
|
@@ -67,6 +67,9 @@ export async function installedVersion(ctx: Ctx, tool: ToolId): Promise<string>
|
|
|
67
67
|
return (await version()).replace(/^jq-/, "")
|
|
68
68
|
case "curl":
|
|
69
69
|
case "tsc":
|
|
70
|
+
// `rust-analyzer --version` prints "rust-analyzer <version>", where the
|
|
71
|
+
// version tail also carries a commit and a date in parentheses.
|
|
72
|
+
case "rust-analyzer":
|
|
70
73
|
return firstLineField(await version(), 1)
|
|
71
74
|
case "bun":
|
|
72
75
|
case "omp":
|