dsh-code 1.0.1 → 1.0.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/README.en.md +21 -13
- package/README.md +21 -13
- package/lib/index.mjs +1902 -1092
- package/lib/types/app.d.ts +4 -13
- package/lib/types/attachments.d.ts +1 -1
- package/lib/types/editor-keys.d.ts +105 -0
- package/lib/types/git-workflow.d.ts +6 -2
- package/lib/types/keyboard.d.ts +31 -0
- package/lib/types/mentions.d.ts +2 -0
- package/lib/types/model-capabilities.d.ts +82 -0
- package/lib/types/provider-settings.d.ts +7 -0
- package/lib/types/render/animations.d.ts +27 -11
- package/lib/types/render/editor.d.ts +32 -7
- package/lib/types/render/lines.d.ts +26 -1
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +15 -1
- package/lib/types/render/text.d.ts +15 -9
- package/lib/types/render/width.d.ts +29 -0
- package/lib/types/session-directory.d.ts +27 -0
- package/lib/types/settings-file.d.ts +33 -0
- package/lib/types/store.d.ts +10 -0
- package/lib/types/subagents.d.ts +13 -3
- package/package.json +159 -159
- package/src/app.ts +920 -764
- package/src/attachments.ts +7 -0
- package/src/editor-keys.ts +371 -0
- package/src/git-workflow.ts +10 -6
- package/src/index.ts +1637 -1523
- package/src/internals.ts +26 -9
- package/src/keyboard.ts +131 -7
- package/src/mentions.ts +6 -1
- package/src/model-capabilities.ts +318 -0
- package/src/provider-settings.ts +16 -0
- package/src/render/animations.ts +64 -17
- package/src/render/editor.ts +125 -25
- package/src/render/lines.ts +403 -342
- package/src/render/markdown.ts +4 -7
- package/src/render/projection.ts +63 -40
- package/src/render/text.ts +152 -150
- package/src/render/width.ts +189 -0
- package/src/session-directory.ts +56 -0
- package/src/settings-file.ts +56 -0
- package/src/store.ts +26 -7
- package/src/subagents.ts +39 -6
package/src/internals.ts
CHANGED
|
@@ -8,7 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
import { render } from 'ink'
|
|
10
10
|
import type { ReactElement } from 'react'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
BRACKETED_PASTE_DISABLE,
|
|
13
|
+
BRACKETED_PASTE_ENABLE,
|
|
14
|
+
KEYBOARD_ENHANCE_DISABLE,
|
|
15
|
+
KEYBOARD_ENHANCE_ENABLE,
|
|
16
|
+
TERMINAL_FOCUS_REPORT_DISABLE,
|
|
17
|
+
TERMINAL_FOCUS_REPORT_ENABLE,
|
|
18
|
+
isVsCodeTerminalEnv,
|
|
19
|
+
shouldEnableKeyboardEnhancement,
|
|
20
|
+
} from './keyboard.ts'
|
|
12
21
|
|
|
13
22
|
/** A mounted terminal app instance; the runner owns unmount ordering. */
|
|
14
23
|
export interface TuiMount {
|
|
@@ -29,11 +38,16 @@ export const internals: {
|
|
|
29
38
|
stderr: { write(chunk: string): unknown }
|
|
30
39
|
} = {
|
|
31
40
|
mount: (element: ReactElement): TuiMount => {
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
// VS Code's integrated terminal can route Tab to the workbench when Kitty
|
|
42
|
+
// enhancement is enabled. Keep bracketed paste everywhere, but only push
|
|
43
|
+
// the keyboard protocol on terminals that can safely own those key events.
|
|
44
|
+
const keyboardEnhanced = shouldEnableKeyboardEnhancement()
|
|
45
|
+
const focusReporting = isVsCodeTerminalEnv()
|
|
46
|
+
process.stdout.write(
|
|
47
|
+
(keyboardEnhanced ? KEYBOARD_ENHANCE_ENABLE : '')
|
|
48
|
+
+ BRACKETED_PASTE_ENABLE
|
|
49
|
+
+ (focusReporting ? TERMINAL_FOCUS_REPORT_ENABLE : ''),
|
|
50
|
+
)
|
|
37
51
|
// App owns Ctrl+C's deliberate three-state contract (interrupt, clear
|
|
38
52
|
// draft, quit). Ink's default `exitOnCtrlC: true` would intercept the
|
|
39
53
|
// normalized control byte first, unmount only its renderer, and leave the
|
|
@@ -45,9 +59,12 @@ export const internals: {
|
|
|
45
59
|
},
|
|
46
60
|
unmount(): void {
|
|
47
61
|
instance.unmount()
|
|
48
|
-
// Pop
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
// Pop only a stack this mount pushed, then disable bracketed paste.
|
|
63
|
+
process.stdout.write(
|
|
64
|
+
(keyboardEnhanced ? KEYBOARD_ENHANCE_DISABLE : '')
|
|
65
|
+
+ BRACKETED_PASTE_DISABLE
|
|
66
|
+
+ (focusReporting ? TERMINAL_FOCUS_REPORT_DISABLE : ''),
|
|
67
|
+
)
|
|
51
68
|
},
|
|
52
69
|
}
|
|
53
70
|
},
|
package/src/keyboard.ts
CHANGED
|
@@ -20,13 +20,56 @@
|
|
|
20
20
|
export const KEYBOARD_ENHANCE_ENABLE = '\x1b[>4;0m\x1b[>5u'
|
|
21
21
|
|
|
22
22
|
/** Pop the enhancement stack and reset modifyOtherKeys (exit path). */
|
|
23
|
-
export const KEYBOARD_ENHANCE_DISABLE = '\x1b[<u\x1b[>4;0m'
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
export const
|
|
23
|
+
export const KEYBOARD_ENHANCE_DISABLE = '\x1b[<u\x1b[>4;0m'
|
|
24
|
+
|
|
25
|
+
/** Explicit environment overrides for terminal keyboard enhancement. */
|
|
26
|
+
export const DSH_DISABLE_KEYBOARD_ENHANCEMENT = 'DSH_DISABLE_KEYBOARD_ENHANCEMENT'
|
|
27
|
+
export const DSH_ENABLE_KEYBOARD_ENHANCEMENT = 'DSH_ENABLE_KEYBOARD_ENHANCEMENT'
|
|
28
|
+
|
|
29
|
+
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
|
30
|
+
if (value === undefined) return undefined
|
|
31
|
+
const normalized = value.trim().toLowerCase()
|
|
32
|
+
if (normalized === '1' || normalized === 'true' || normalized === 'yes') return true
|
|
33
|
+
if (normalized === '0' || normalized === 'false' || normalized === 'no') return false
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when the process is running inside the VS Code integrated terminal. */
|
|
38
|
+
export function isVsCodeTerminalEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
39
|
+
return env.TERM_PROGRAM?.trim().toLowerCase() === 'vscode' || env.VSCODE_INJECTION === '1'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether to push Kitty keyboard enhancement for the current terminal. */
|
|
43
|
+
export function shouldEnableKeyboardEnhancement(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
44
|
+
const explicitDisable = parseBooleanEnv(env[DSH_DISABLE_KEYBOARD_ENHANCEMENT])
|
|
45
|
+
if (explicitDisable === true) return false
|
|
46
|
+
const explicitEnable = parseBooleanEnv(env[DSH_ENABLE_KEYBOARD_ENHANCEMENT])
|
|
47
|
+
if (explicitEnable !== undefined) return explicitEnable
|
|
48
|
+
return !isVsCodeTerminalEnv(env)
|
|
49
|
+
}
|
|
27
50
|
|
|
28
|
-
/**
|
|
29
|
-
export const
|
|
51
|
+
/** Enable bracketed paste reporting. */
|
|
52
|
+
export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h'
|
|
53
|
+
|
|
54
|
+
/** Disable bracketed paste reporting. */
|
|
55
|
+
export const BRACKETED_PASTE_DISABLE = '\x1b[?2004l'
|
|
56
|
+
|
|
57
|
+
/** Enable terminal focus-in/focus-out reporting (xterm focus protocol). */
|
|
58
|
+
export const TERMINAL_FOCUS_REPORT_ENABLE = '\x1b[?1004h'
|
|
59
|
+
|
|
60
|
+
/** Disable terminal focus-in/focus-out reporting. */
|
|
61
|
+
export const TERMINAL_FOCUS_REPORT_DISABLE = '\x1b[?1004l'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remove xterm focus reports from one input chunk and update the caller's
|
|
65
|
+
* focus state. Focus reports are terminal protocol, not composer text.
|
|
66
|
+
*/
|
|
67
|
+
export function stripTerminalFocusEvents(chunk: string, onFocus: (focused: boolean) => void): string {
|
|
68
|
+
return chunk.replace(/\x1b\[(I|O)/gu, (_whole, event: string) => {
|
|
69
|
+
onFocus(event === 'I')
|
|
70
|
+
return ''
|
|
71
|
+
})
|
|
72
|
+
}
|
|
30
73
|
|
|
31
74
|
/** Bracketed paste markers as Ink delivers them (it strips the leading ESC). */
|
|
32
75
|
export const PASTE_START_MARKER = '[200~'
|
|
@@ -123,4 +166,85 @@ export function normalizeKeyboardChunk(chunk: string): string {
|
|
|
123
166
|
})
|
|
124
167
|
return legacy ?? whole
|
|
125
168
|
})
|
|
126
|
-
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Editor actions Ink cannot distinguish reliably when terminal bytes batch. */
|
|
172
|
+
export type RawEditorToken =
|
|
173
|
+
| { readonly kind: 'text'; readonly text: string }
|
|
174
|
+
| { readonly kind: 'home' | 'end' | 'delete-backward' | 'delete-word-backward' | 'delete-forward' | 'delete-word-forward' }
|
|
175
|
+
|
|
176
|
+
const HOME_SEQUENCES = ['\x1b[H', '\x1b[1~', '\x1b[7~', '\x1bOH'] as const
|
|
177
|
+
const END_SEQUENCES = ['\x1b[F', '\x1b[4~', '\x1b[8~', '\x1bOF'] as const
|
|
178
|
+
|
|
179
|
+
/** Parse one CSI functional-key sequence at `offset`. */
|
|
180
|
+
function functionalToken(chunk: string, offset: number): { token: RawEditorToken; length: number } | undefined {
|
|
181
|
+
const tail = chunk.slice(offset)
|
|
182
|
+
for (const sequence of HOME_SEQUENCES) {
|
|
183
|
+
if (tail.startsWith(sequence)) return { token: { kind: 'home' }, length: sequence.length }
|
|
184
|
+
}
|
|
185
|
+
for (const sequence of END_SEQUENCES) {
|
|
186
|
+
if (tail.startsWith(sequence)) return { token: { kind: 'end' }, length: sequence.length }
|
|
187
|
+
}
|
|
188
|
+
const modifiedHome = /^\x1b\[1;(\d+)H/u.exec(tail)
|
|
189
|
+
if (modifiedHome !== null) return { token: { kind: 'home' }, length: modifiedHome[0].length }
|
|
190
|
+
const modifiedEnd = /^\x1b\[1;(\d+)F/u.exec(tail)
|
|
191
|
+
if (modifiedEnd !== null) return { token: { kind: 'end' }, length: modifiedEnd[0].length }
|
|
192
|
+
const modifiedDelete = /^\x1b\[3(?:;(\d+))?~/u.exec(tail)
|
|
193
|
+
if (modifiedDelete !== null) {
|
|
194
|
+
const modifiers = Number.parseInt(modifiedDelete[1] ?? '1', 10) - 1
|
|
195
|
+
const byWord = (modifiers & 2) !== 0 || (modifiers & 4) !== 0
|
|
196
|
+
return {
|
|
197
|
+
token: { kind: byWord ? 'delete-word-forward' : 'delete-forward' },
|
|
198
|
+
length: modifiedDelete[0].length,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Tokenize a stdin chunk containing at least one editor-only key. Ink calls
|
|
206
|
+
* `useInput` once for a pasted/batched chunk, so preserving each action here
|
|
207
|
+
* prevents repeated Backspace/Home/End/Delete presses from collapsing into
|
|
208
|
+
* one blurred key event. Unknown escape sequences return undefined and stay
|
|
209
|
+
* under Ink's ownership.
|
|
210
|
+
*/
|
|
211
|
+
export function tokenizeRawEditorChunk(chunk: string): readonly RawEditorToken[] | undefined {
|
|
212
|
+
const tokens: RawEditorToken[] = []
|
|
213
|
+
let text = ''
|
|
214
|
+
let special = false
|
|
215
|
+
const flushText = (): void => {
|
|
216
|
+
if (text === '') return
|
|
217
|
+
tokens.push({ kind: 'text', text })
|
|
218
|
+
text = ''
|
|
219
|
+
}
|
|
220
|
+
for (let offset = 0; offset < chunk.length;) {
|
|
221
|
+
if (chunk.startsWith('\x1b\x7f', offset) || chunk.startsWith('\x1b\b', offset)) {
|
|
222
|
+
flushText()
|
|
223
|
+
tokens.push({ kind: 'delete-word-backward' })
|
|
224
|
+
special = true
|
|
225
|
+
offset += 2
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
const functional = functionalToken(chunk, offset)
|
|
229
|
+
if (functional !== undefined) {
|
|
230
|
+
flushText()
|
|
231
|
+
tokens.push(functional.token)
|
|
232
|
+
special = true
|
|
233
|
+
offset += functional.length
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
const char = chunk[offset]!
|
|
237
|
+
if (char === '\x7f' || char === '\b') {
|
|
238
|
+
flushText()
|
|
239
|
+
tokens.push({ kind: 'delete-backward' })
|
|
240
|
+
special = true
|
|
241
|
+
offset += 1
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (char === '\x1b') return undefined
|
|
245
|
+
text += char
|
|
246
|
+
offset += 1
|
|
247
|
+
}
|
|
248
|
+
flushText()
|
|
249
|
+
return special ? tokens : undefined
|
|
250
|
+
}
|
package/src/mentions.ts
CHANGED
|
@@ -75,6 +75,11 @@ interface FileReferenceServiceLike {
|
|
|
75
75
|
/** Menu cap on file rows; the service owns ranking and default rows. */
|
|
76
76
|
const MAX_FILE_ROWS = 20
|
|
77
77
|
|
|
78
|
+
/** Whether a mention token is already navigating a filesystem path. */
|
|
79
|
+
export function isPathLikeMentionQuery(query: string): boolean {
|
|
80
|
+
return /[\\/]/u.test(query)
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
/** The mention API the input editor and the runner share. */
|
|
79
84
|
export interface MentionsApi {
|
|
80
85
|
/** Ranked menu candidates for the typed `@` query. */
|
|
@@ -136,7 +141,7 @@ export function createMentions(ctx: Context, agent: Agent | undefined, cwd: stri
|
|
|
136
141
|
: agent === undefined
|
|
137
142
|
? preSessionFiles(needle, signal).catch(() => [] as readonly ServiceFileCandidate[])
|
|
138
143
|
: Promise.resolve([] as readonly ServiceFileCandidate[]),
|
|
139
|
-
sessionCapable && needle !== '' && agent !== undefined
|
|
144
|
+
sessionCapable && needle !== '' && !isPathLikeMentionQuery(needle) && agent !== undefined
|
|
140
145
|
? resolver!.listCandidates(agent, needle, 10, signal).catch(() => [] as readonly SessionReferenceCandidate[])
|
|
141
146
|
: Promise.resolve([] as readonly SessionReferenceCandidate[]),
|
|
142
147
|
])
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-id reasoning-capability inheritance for hand-declared pi-ai routes:
|
|
3
|
+
* the model catalog inherits capabilities by route key, not by model id, so a
|
|
4
|
+
* relay route listing `gpt-5.5` reads nothing from the installed `openai`
|
|
5
|
+
* catalog entry and materializes as `reasoning: false` until its settings
|
|
6
|
+
* entry declares `reasoningEfforts`. This adapter closes that gap without
|
|
7
|
+
* touching upstream: whenever a pi-ai profile's model entry carries no
|
|
8
|
+
* declaration and its live row advertises no efforts, but the same model id
|
|
9
|
+
* is declared (in a sibling settings entry) or advertised (on another route)
|
|
10
|
+
* elsewhere, the declaration is materialized into settings — verbatim from a
|
|
11
|
+
* sibling declaration when one exists, otherwise as an identity level map
|
|
12
|
+
* (`off` maps to null, every other level to its own name), which is the
|
|
13
|
+
* correct wire spelling for OpenAI-compatible relays. Writes ride the same
|
|
14
|
+
* `settings.mutate` path as the provider panel, so the upstream
|
|
15
|
+
* `assertServiceable` gate still rejects anything invalid atomically.
|
|
16
|
+
*
|
|
17
|
+
* @module @deepseek-ai/dsh-tui/model-capabilities
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
import { loadModelDirectory, type ModelRow } from './models.ts'
|
|
22
|
+
|
|
23
|
+
/** The settings namespace the llm-pi-ai plugin owns (identity via `settingsNamespace`). */
|
|
24
|
+
const PI_AI_SETTINGS_NS = 'llm-pi-ai'
|
|
25
|
+
|
|
26
|
+
/** The effort level that means "send no reasoning parameter"; its wire value is always null. */
|
|
27
|
+
const OFF_LEVEL = 'off'
|
|
28
|
+
|
|
29
|
+
/* ------------------------------------------------------------------ *
|
|
30
|
+
* Structural service faces (same discipline as provider-settings: the
|
|
31
|
+
* settings package is never imported, only described structurally).
|
|
32
|
+
* ------------------------------------------------------------------ */
|
|
33
|
+
|
|
34
|
+
/** One configurable-provider directory entry (subset of the `llm` service face). */
|
|
35
|
+
interface ConfigurableEntryFace {
|
|
36
|
+
readonly provider: string
|
|
37
|
+
readonly settingsNs: string
|
|
38
|
+
readonly settingsPath: readonly string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One redacted settings descriptor (subset of `SettingsDescriptor`). */
|
|
42
|
+
interface DescriptorFace {
|
|
43
|
+
readonly ns: string
|
|
44
|
+
readonly value: unknown
|
|
45
|
+
readonly revision: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One path-addressed edit to a stored section (subset of `SettingsPathOp`). */
|
|
49
|
+
interface PathOpFace {
|
|
50
|
+
readonly op: 'set' | 'unset'
|
|
51
|
+
readonly path: readonly string[]
|
|
52
|
+
readonly value?: unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The subset of the `settings` service this module reads and writes. */
|
|
56
|
+
interface SettingsFace {
|
|
57
|
+
describe(options?: { readonly redactSecrets?: boolean }): readonly DescriptorFace[]
|
|
58
|
+
mutate(ns: string, ops: readonly PathOpFace[], expectedRevision?: number): Promise<void>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A notice sink structurally compatible with the app bridge's `notify`. */
|
|
62
|
+
export type CapabilityNotice = (text: string, tone?: 'info' | 'warning' | 'error') => void
|
|
63
|
+
|
|
64
|
+
/** Single-line a misbehaving error for the notice channel. */
|
|
65
|
+
function singleLine(message: string): string {
|
|
66
|
+
const spaced = [...message].map(ch => { const code = ch.charCodeAt(0); return code < 32 || code === 127 ? ' ' : ch }).join('')
|
|
67
|
+
return spaced.split(' ').filter(part => part !== '').join(' ')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Human text for a rejection value. */
|
|
71
|
+
function messageOf(error: unknown): string {
|
|
72
|
+
return error instanceof Error ? error.message : String(error)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read the value at a path through plain objects; undefined when any segment misses. */
|
|
76
|
+
function getPath(value: unknown, path: readonly string[]): unknown {
|
|
77
|
+
let current = value
|
|
78
|
+
for (const segment of path) {
|
|
79
|
+
if (typeof current !== 'object' || current === null) return undefined
|
|
80
|
+
current = (current as Record<string, unknown>)[segment]
|
|
81
|
+
}
|
|
82
|
+
return current
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether a raw settings value is a usable `reasoningEfforts` dict (non-empty, non-false). */
|
|
86
|
+
function isDeclaredEfforts(value: unknown): value is Record<string, unknown> {
|
|
87
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The identity level map for an advertised effort list: `off` sends no
|
|
92
|
+
* parameter, every other level keeps its own name — the wire spelling
|
|
93
|
+
* OpenAI-compatible relays accept as-is.
|
|
94
|
+
*/
|
|
95
|
+
function identityEfforts(levels: readonly string[]): Record<string, string | null> {
|
|
96
|
+
const dict: Record<string, string | null> = {}
|
|
97
|
+
for (const level of levels) dict[level] = level === OFF_LEVEL ? null : level
|
|
98
|
+
return dict
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ------------------------------------------------------------------ *
|
|
102
|
+
* Pure planning.
|
|
103
|
+
* ------------------------------------------------------------------ */
|
|
104
|
+
|
|
105
|
+
/** One pi-ai provider profile as stored in settings, addressed for mutation. */
|
|
106
|
+
export interface CapabilityProfileSource {
|
|
107
|
+
/** Settings namespace owning the profile (`llm-pi-ai`). */
|
|
108
|
+
readonly settingsNs: string
|
|
109
|
+
/** Path from the section root to this provider's profile. */
|
|
110
|
+
readonly settingsPath: readonly string[]
|
|
111
|
+
/** Revision of the owning section at read time. */
|
|
112
|
+
readonly revision: number
|
|
113
|
+
/** Raw model entries, exactly as stored (declaration fields included). */
|
|
114
|
+
readonly models: readonly Record<string, unknown>[]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One planned per-provider models rewrite. */
|
|
118
|
+
export interface CapabilitySyncPlan {
|
|
119
|
+
/** Provider route the plan targets. */
|
|
120
|
+
readonly provider: string
|
|
121
|
+
/** Settings namespace owning the profile. */
|
|
122
|
+
readonly settingsNs: string
|
|
123
|
+
/** Path from the section root to the provider profile. */
|
|
124
|
+
readonly settingsPath: readonly string[]
|
|
125
|
+
/** Raw model entries, exactly as stored (declaration fields included). */
|
|
126
|
+
readonly models: readonly Record<string, unknown>[]
|
|
127
|
+
/** Fingerprint of the source models array this plan was derived from. */
|
|
128
|
+
readonly sourceFingerprint: string
|
|
129
|
+
/** Document revision of the owning section when the plan was derived. */
|
|
130
|
+
readonly sourceRevision: number
|
|
131
|
+
/** Model ids that gained a declaration, notice-facing. */
|
|
132
|
+
readonly inherited: readonly string[]
|
|
133
|
+
/** `provider/model` labels the declarations came from, notice-facing. */
|
|
134
|
+
readonly sources: readonly string[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Plan the reasoning declarations to materialize. A model entry inherits
|
|
139
|
+
* when it declares nothing (`reasoningEfforts` absent — a dict or `false` is
|
|
140
|
+
* an explicit choice and is never touched) and its live row advertises no
|
|
141
|
+
* efforts; the donor is the first sibling settings declaration for the same
|
|
142
|
+
* id, copied verbatim so dialect wire spellings survive, otherwise the first
|
|
143
|
+
* other-route row advertising efforts for that id, mapped by identity.
|
|
144
|
+
* Entries never lose fields and keep their key order; a provider appears in
|
|
145
|
+
* the result only when at least one entry changes.
|
|
146
|
+
* @param input - the live model rows and the raw pi-ai profiles from settings.
|
|
147
|
+
* @returns one plan per provider with at least one inheritance.
|
|
148
|
+
*/
|
|
149
|
+
export function planCapabilitySync(input: {
|
|
150
|
+
readonly rows: readonly ModelRow[]
|
|
151
|
+
readonly profiles: ReadonlyMap<string, CapabilityProfileSource>
|
|
152
|
+
}): readonly CapabilitySyncPlan[] {
|
|
153
|
+
// Sibling declarations, first profile order wins: { id -> { dict, provider } }.
|
|
154
|
+
const declared = new Map<string, { dict: Record<string, unknown>; provider: string }>()
|
|
155
|
+
for (const [provider, profile] of input.profiles) {
|
|
156
|
+
for (const entry of profile.models) {
|
|
157
|
+
const efforts = entry.reasoningEfforts
|
|
158
|
+
if (!isDeclaredEfforts(efforts)) continue
|
|
159
|
+
if (!declared.has(String(entry.id))) declared.set(String(entry.id), { dict: efforts, provider })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Advertised donors, first row order wins: { id -> { levels, provider } }.
|
|
163
|
+
// A row whose only level is `off` advertises nothing usable and is skipped.
|
|
164
|
+
const advertised = new Map<string, { levels: readonly string[]; provider: string }>()
|
|
165
|
+
for (const row of input.rows) {
|
|
166
|
+
const levels = row.reasoning?.efforts.map(effort => effort.id) ?? []
|
|
167
|
+
if (levels.filter(level => level !== OFF_LEVEL).length === 0) continue
|
|
168
|
+
if (!advertised.has(row.model)) advertised.set(row.model, { levels, provider: row.provider })
|
|
169
|
+
}
|
|
170
|
+
const plans: CapabilitySyncPlan[] = []
|
|
171
|
+
for (const [provider, profile] of input.profiles) {
|
|
172
|
+
const inherited: string[] = []
|
|
173
|
+
const sources: string[] = []
|
|
174
|
+
const models = profile.models.map((entry): Record<string, unknown> => {
|
|
175
|
+
if (entry.reasoningEfforts !== undefined) return entry
|
|
176
|
+
const id = String(entry.id)
|
|
177
|
+
const sibling = declared.get(id)
|
|
178
|
+
if (sibling !== undefined && sibling.provider !== provider) {
|
|
179
|
+
inherited.push(id)
|
|
180
|
+
if (!sources.includes(`${sibling.provider}/${id}`)) sources.push(`${sibling.provider}/${id}`)
|
|
181
|
+
return { ...entry, reasoningEfforts: sibling.dict }
|
|
182
|
+
}
|
|
183
|
+
const donor = advertised.get(id)
|
|
184
|
+
if (donor !== undefined && donor.provider !== provider) {
|
|
185
|
+
inherited.push(id)
|
|
186
|
+
if (!sources.includes(`${donor.provider}/${id}`)) sources.push(`${donor.provider}/${id}`)
|
|
187
|
+
return { ...entry, reasoningEfforts: identityEfforts(donor.levels) }
|
|
188
|
+
}
|
|
189
|
+
return entry
|
|
190
|
+
})
|
|
191
|
+
if (inherited.length > 0) {
|
|
192
|
+
plans.push({
|
|
193
|
+
provider,
|
|
194
|
+
settingsNs: profile.settingsNs,
|
|
195
|
+
settingsPath: profile.settingsPath,
|
|
196
|
+
models,
|
|
197
|
+
sourceFingerprint: JSON.stringify(profile.models),
|
|
198
|
+
sourceRevision: profile.revision,
|
|
199
|
+
inherited,
|
|
200
|
+
sources,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return plans
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ------------------------------------------------------------------ *
|
|
208
|
+
* Application.
|
|
209
|
+
* ------------------------------------------------------------------ */
|
|
210
|
+
|
|
211
|
+
/** Provider -> the source snapshot and document revision its last write landed against. */
|
|
212
|
+
const lastApplied = new Map<string, { fingerprint: string; revision: number }>()
|
|
213
|
+
|
|
214
|
+
/** Fresh revision of one namespace, or undefined when it does not resolve. */
|
|
215
|
+
function revisionOf(settings: SettingsFace, ns: string): number | undefined {
|
|
216
|
+
try {
|
|
217
|
+
for (const descriptor of settings.describe({ redactSecrets: true })) {
|
|
218
|
+
if (descriptor.ns === ns) return descriptor.revision
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Materialize same-id reasoning declarations once per provider. Reads the
|
|
228
|
+
* configurable directory, the redacted settings document, and the live model
|
|
229
|
+
* rows; plans; then writes each provider's merged models array through
|
|
230
|
+
* `settings.mutate` under a fresh revision (writes bump the section
|
|
231
|
+
* revision, so per-plan revisions are re-read). Every failure converges to a
|
|
232
|
+
* single-line notice — the caller's promise never rejects and the session
|
|
233
|
+
* keeps running on the previous configuration.
|
|
234
|
+
* @param ctx - context carrying the `llm` and `settings` services (optional).
|
|
235
|
+
* @param notify - the app bridge's notice sink, when one is live.
|
|
236
|
+
*/
|
|
237
|
+
export async function syncModelCapabilities(ctx: Context, notify?: CapabilityNotice): Promise<void> {
|
|
238
|
+
try {
|
|
239
|
+
const llm = ctx.get('llm') as { listConfigurableProviders?: () => readonly ConfigurableEntryFace[] } | undefined
|
|
240
|
+
if (llm?.listConfigurableProviders === undefined) return
|
|
241
|
+
const settings = ctx.get('settings') as SettingsFace | undefined
|
|
242
|
+
if (settings === undefined) return
|
|
243
|
+
let entries: readonly ConfigurableEntryFace[]
|
|
244
|
+
try {
|
|
245
|
+
entries = llm.listConfigurableProviders()
|
|
246
|
+
} catch {
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
const routed = entries.filter(entry => entry.settingsNs === PI_AI_SETTINGS_NS)
|
|
250
|
+
if (routed.length === 0) return
|
|
251
|
+
let descriptors: readonly DescriptorFace[]
|
|
252
|
+
try {
|
|
253
|
+
descriptors = settings.describe({ redactSecrets: true })
|
|
254
|
+
} catch {
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
const namespaces = new Map(descriptors.map(descriptor => [descriptor.ns, descriptor] as const))
|
|
258
|
+
const profiles = new Map<string, CapabilityProfileSource>()
|
|
259
|
+
for (const entry of routed) {
|
|
260
|
+
const namespace = namespaces.get(entry.settingsNs)
|
|
261
|
+
if (namespace === undefined) continue
|
|
262
|
+
const profile = entry.settingsPath.length === 0
|
|
263
|
+
? namespace.value
|
|
264
|
+
: getPath(namespace.value, entry.settingsPath)
|
|
265
|
+
const models = Array.isArray((profile as { models?: unknown } | null)?.models)
|
|
266
|
+
? (profile as { models: unknown }).models as readonly Record<string, unknown>[]
|
|
267
|
+
: undefined
|
|
268
|
+
// An absent models list is a dormant or unconfigured route: nothing to
|
|
269
|
+
// inherit into (and the empty allow-list there is deliberate upstream).
|
|
270
|
+
if (models === undefined) continue
|
|
271
|
+
profiles.set(entry.provider, {
|
|
272
|
+
settingsNs: entry.settingsNs,
|
|
273
|
+
settingsPath: entry.settingsPath,
|
|
274
|
+
revision: namespace.revision,
|
|
275
|
+
models,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
if (profiles.size === 0) return
|
|
279
|
+
const directory = await loadModelDirectory(ctx)
|
|
280
|
+
for (const plan of planCapabilitySync({ rows: directory.rows, profiles })) {
|
|
281
|
+
// Skip only a provable echo of our own write: the same source
|
|
282
|
+
// snapshot at the same document revision we already wrote against
|
|
283
|
+
// (our write re-triggers the document event before the re-read
|
|
284
|
+
// catches up). An external edit that removes a materialized
|
|
285
|
+
// declaration bumps the revision, so identical source content
|
|
286
|
+
// re-plans and re-writes instead of being skipped forever.
|
|
287
|
+
const applied = lastApplied.get(plan.provider)
|
|
288
|
+
if (applied !== undefined && applied.fingerprint === plan.sourceFingerprint && applied.revision === plan.sourceRevision) continue
|
|
289
|
+
// Writes bump the section revision; re-read per plan so the second
|
|
290
|
+
// provider's optimistic revision is not already stale.
|
|
291
|
+
const revision = revisionOf(settings, plan.settingsNs)
|
|
292
|
+
if (revision === undefined) continue
|
|
293
|
+
try {
|
|
294
|
+
await settings.mutate(
|
|
295
|
+
plan.settingsNs,
|
|
296
|
+
[{ op: 'set', path: [...plan.settingsPath, 'models'], value: plan.models }],
|
|
297
|
+
revision,
|
|
298
|
+
)
|
|
299
|
+
lastApplied.set(plan.provider, { fingerprint: plan.sourceFingerprint, revision: plan.sourceRevision })
|
|
300
|
+
const shown = plan.sources.slice(0, 3).join(', ')
|
|
301
|
+
const more = plan.sources.length > 3 ? `, +${plan.sources.length - 3}` : ''
|
|
302
|
+
notify?.(
|
|
303
|
+
`inherited reasoning levels for ${plan.inherited.length} model${plan.inherited.length === 1 ? '' : 's'} on ${plan.provider} (from ${shown}${more})`,
|
|
304
|
+
'info',
|
|
305
|
+
)
|
|
306
|
+
} catch (error) {
|
|
307
|
+
notify?.(`capability inheritance failed on ${plan.provider}: ${singleLine(messageOf(error))}`, 'warning')
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
// A background sync must never surface as an unhandled rejection.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Test seam: forget the applied-write fingerprints. */
|
|
316
|
+
export function resetCapabilitySyncState(): void {
|
|
317
|
+
lastApplied.clear()
|
|
318
|
+
}
|
package/src/provider-settings.ts
CHANGED
|
@@ -138,11 +138,16 @@ function configurationOf(profile: unknown): ProviderConfiguration {
|
|
|
138
138
|
if (typeof value !== 'object' || value === null) return []
|
|
139
139
|
const entry = value as Record<string, unknown>
|
|
140
140
|
if (typeof entry.id !== 'string' || entry.id.trim() === '') return []
|
|
141
|
+
// Fields the editor does not understand ride along untouched: dropping
|
|
142
|
+
// them here would let the next save strip hand-written reasoningEfforts
|
|
143
|
+
// or compat declarations out of the stored profile.
|
|
144
|
+
const { id: _id, name: _name, contextWindow: _contextWindow, maxTokens: _maxTokens, ...extras } = entry
|
|
141
145
|
return [{
|
|
142
146
|
id: entry.id,
|
|
143
147
|
...(typeof entry.name === 'string' && entry.name.trim() !== '' ? { name: entry.name } : {}),
|
|
144
148
|
...(typeof entry.contextWindow === 'number' && Number.isFinite(entry.contextWindow) ? { contextWindow: entry.contextWindow } : {}),
|
|
145
149
|
...(typeof entry.maxTokens === 'number' && Number.isFinite(entry.maxTokens) ? { maxTokens: entry.maxTokens } : {}),
|
|
150
|
+
...Object.keys(extras).length > 0 ? { extras } : {},
|
|
146
151
|
}]
|
|
147
152
|
})
|
|
148
153
|
return {
|
|
@@ -194,6 +199,13 @@ export interface ProviderModelSettings {
|
|
|
194
199
|
readonly name?: string
|
|
195
200
|
readonly contextWindow?: number
|
|
196
201
|
readonly maxTokens?: number
|
|
202
|
+
/**
|
|
203
|
+
* Remaining entry fields the editor does not model (`reasoningEfforts`,
|
|
204
|
+
* `compat`, `input`, …), carried verbatim so a save preserves them.
|
|
205
|
+
* Populated by {@link loadProviderSettings}; never contains the four
|
|
206
|
+
* modelled keys.
|
|
207
|
+
*/
|
|
208
|
+
readonly extras?: Readonly<Record<string, unknown>>
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
/** The small, portable subset of a provider profile the terminal edits. */
|
|
@@ -514,6 +526,10 @@ export async function saveProviderConfiguration(
|
|
|
514
526
|
}
|
|
515
527
|
return {
|
|
516
528
|
id,
|
|
529
|
+
// Editor-invisible fields ride along after the id; the modelled keys
|
|
530
|
+
// spread last so a panel edit (or clear) always wins over a carried
|
|
531
|
+
// value. extras never contains those keys — see configurationOf.
|
|
532
|
+
...model.extras,
|
|
517
533
|
...(model.name === undefined || model.name.trim() === '' ? {} : { name: model.name.trim() }),
|
|
518
534
|
...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }),
|
|
519
535
|
...(model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }),
|