dsh-custom-mode 2.2.5 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +76 -57
- package/README.zh.md +76 -57
- package/assistants.mjs +8 -2
- package/atomic.mjs +156 -56
- package/client.js +653 -227
- package/composition.mjs +5 -4
- package/index.mjs +86 -47
- package/locales.mjs +138 -20
- package/package.json +4 -4
- package/preset/prompt-reader.mjs +35 -2
- package/preset/prompt-tool.mjs +1 -0
- package/preset/prompt.md +0 -2
- package/preset-backend/declarative.mjs +43 -15
- package/seed.mjs +5 -1
package/preset/prompt-reader.mjs
CHANGED
|
@@ -86,11 +86,44 @@ export const name = 'custom-prompt-file'
|
|
|
86
86
|
/** The prompt registry is a hard dependency: without it there is no identity. */
|
|
87
87
|
export const inject = ['systemPrompt']
|
|
88
88
|
|
|
89
|
+
/** Remove only our old standalone directory sentence; never rewrite the user's file. */
|
|
90
|
+
function withoutLegacyDirectoryLine(text) {
|
|
91
|
+
return text.replace(/^Your working directory is \{\{cwd\}\}\.(?:\r?\n|$)/gm, '')
|
|
92
|
+
}
|
|
93
|
+
|
|
89
94
|
export function apply(ctx, config = {}) {
|
|
90
95
|
const path = config.path ?? fileURLToPath(new URL('./prompt.md', import.meta.url))
|
|
91
96
|
const suffix = config.suffix ?? DEFAULT_SUFFIX
|
|
92
97
|
const fallback = config.fallback ?? DEFAULT_FALLBACK
|
|
93
98
|
const file = new PromptFile(path, fallback)
|
|
99
|
+
let ownsWorkingDirectory = false
|
|
100
|
+
|
|
101
|
+
// New hosts moved cwd out of the loop's variables and into this service's required runtime context.
|
|
102
|
+
// Keep older hosts on their own cwd variable. This optional child does not hold the persona pending.
|
|
103
|
+
// A scoped compatibility variable also preserves custom {{cwd}} references without string-substituting
|
|
104
|
+
// paths before interpolation (a directory containing braces must not itself become a template).
|
|
105
|
+
if (typeof ctx.inject === 'function') {
|
|
106
|
+
ctx.inject(['systemPrompt', 'workingDirectory'], (scope) => {
|
|
107
|
+
const directory = scope.workingDirectory
|
|
108
|
+
if (typeof directory?.get !== 'function') return
|
|
109
|
+
const currentDirectory = (context) => {
|
|
110
|
+
const session = context.agent?.session
|
|
111
|
+
if (session === undefined) return undefined
|
|
112
|
+
return directory.get(session)
|
|
113
|
+
}
|
|
114
|
+
scope.effect(() => scope.systemPrompt.variable('cwd', currentDirectory), 'custom-prompt.cwd-compatibility')
|
|
115
|
+
// The host can recover a missing directory during this waterfall, after variable providers were
|
|
116
|
+
// snapshotted. Refresh only our compatibility value after downstream assembly has settled.
|
|
117
|
+
scope.effect(() => scope.on('system-prompt/assemble', async (_assembly, context, next) => {
|
|
118
|
+
const presentation = await next()
|
|
119
|
+
presentation.variables.cwd = currentDirectory(context)
|
|
120
|
+
return presentation
|
|
121
|
+
}), 'custom-prompt.cwd-after-assembly')
|
|
122
|
+
ownsWorkingDirectory = true
|
|
123
|
+
scope.effect(() => () => { ownsWorkingDirectory = false }, 'custom-prompt.cwd-lifecycle')
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
const compatibleText = (text) => ownsWorkingDirectory ? withoutLegacyDirectoryLine(text) : text
|
|
94
127
|
|
|
95
128
|
ctx.effect(
|
|
96
129
|
() =>
|
|
@@ -98,7 +131,7 @@ export function apply(ctx, config = {}) {
|
|
|
98
131
|
name: PERSONA_PREFIX_SECTION,
|
|
99
132
|
order: PERSONA_PREFIX_ORDER,
|
|
100
133
|
// A provider, not a string: evaluated at every assembly.
|
|
101
|
-
text: () => file.read(),
|
|
134
|
+
text: () => compatibleText(file.read()),
|
|
102
135
|
complete: config.complete === true,
|
|
103
136
|
}),
|
|
104
137
|
'custom-prompt.prefix',
|
|
@@ -112,7 +145,7 @@ export function apply(ctx, config = {}) {
|
|
|
112
145
|
ctx.systemPrompt.section({
|
|
113
146
|
name: PERSONA_SUFFIX_SECTION,
|
|
114
147
|
order: PERSONA_SUFFIX_ORDER,
|
|
115
|
-
text: suffix,
|
|
148
|
+
text: () => compatibleText(suffix),
|
|
116
149
|
}),
|
|
117
150
|
'custom-prompt.suffix',
|
|
118
151
|
)
|
package/preset/prompt-tool.mjs
CHANGED
|
@@ -61,6 +61,7 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
|
|
61
61
|
* step; the duplication is deliberate so neither side depends on the other's
|
|
62
62
|
* install location.
|
|
63
63
|
*/
|
|
64
|
+
// cwd is supplied by the old loop, or by prompt-reader's scoped workingDirectory compatibility provider.
|
|
64
65
|
const KNOWN_VARIABLES = ['model', 'cwd', 'provider']
|
|
65
66
|
|
|
66
67
|
/**
|
package/preset/prompt.md
CHANGED
|
@@ -102,16 +102,20 @@ export function toPluginRows(rows, presetDir) {
|
|
|
102
102
|
export function createDeclarativeBackend({ scope, log = console.log, warn = console.error }) {
|
|
103
103
|
/** presetId → { dispose, dir } —— 当前挂在注册表里的东西,注销的唯一凭据。 */
|
|
104
104
|
const mounted = new Map()
|
|
105
|
+
let mutations = Promise.resolve()
|
|
106
|
+
let stopped = false
|
|
107
|
+
const stoppedResult = () => ({ ok: false, code: 'backendStopped', error: 'backend stopped' })
|
|
108
|
+
const serialize = (work) => {
|
|
109
|
+
const next = mutations.then(work, work)
|
|
110
|
+
mutations = next.then(() => undefined, () => undefined)
|
|
111
|
+
return next
|
|
112
|
+
}
|
|
105
113
|
|
|
106
114
|
async function unmount(id) {
|
|
107
115
|
const entry = mounted.get(id)
|
|
108
116
|
if (entry === undefined) return
|
|
117
|
+
await entry.dispose()
|
|
109
118
|
mounted.delete(id)
|
|
110
|
-
try {
|
|
111
|
-
await entry.dispose()
|
|
112
|
-
} catch (error) {
|
|
113
|
-
warn(`custom-mode: 注销 preset "${id}" 时出错(已忽略): ${describe(error)}`)
|
|
114
|
-
}
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/** Build the definition for one assistant directory, or null when it has no composition. */
|
|
@@ -133,6 +137,7 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
|
|
|
133
137
|
* registry refuses a duplicate id outright.
|
|
134
138
|
*/
|
|
135
139
|
async function mountOne(assistant) {
|
|
140
|
+
if (stopped) return stoppedResult()
|
|
136
141
|
let definition
|
|
137
142
|
try {
|
|
138
143
|
definition = buildDefinition(assistant)
|
|
@@ -140,26 +145,40 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
|
|
|
140
145
|
// 解析失败是**用户可见**的问题(手改坏了 composition),要显式说,不能静默跳过。
|
|
141
146
|
warn(`custom-mode: 预设 "${assistant.id}" 的 composition 解析失败 —— ${describe(error)}。该助手不会出现在选择器里。`)
|
|
142
147
|
await unmount(assistant.id)
|
|
143
|
-
return { ok: false, error: describe(error) }
|
|
148
|
+
return { ok: false, code: 'compositionParseFailed', error: describe(error) }
|
|
144
149
|
}
|
|
145
150
|
if (definition === null) {
|
|
146
151
|
await unmount(assistant.id)
|
|
147
|
-
return { ok: false, error: 'no composition' }
|
|
152
|
+
return { ok: false, code: 'noComposition', error: 'no composition' }
|
|
148
153
|
}
|
|
149
|
-
|
|
154
|
+
let registering = false
|
|
150
155
|
try {
|
|
156
|
+
await unmount(assistant.id)
|
|
157
|
+
if (stopped) return stoppedResult()
|
|
158
|
+
registering = true
|
|
151
159
|
const dispose = await scope.agentPresets.register(definition)
|
|
160
|
+
registering = false
|
|
152
161
|
mounted.set(assistant.id, { dispose, dir: assistant.dir })
|
|
162
|
+
// register() can finish after the scope starts stopping. Keep its disposer until
|
|
163
|
+
// it succeeds, and release only this generation's own registration.
|
|
164
|
+
if (stopped) {
|
|
165
|
+
await unmount(assistant.id)
|
|
166
|
+
return stoppedResult()
|
|
167
|
+
}
|
|
153
168
|
return { ok: true }
|
|
154
169
|
} catch (error) {
|
|
155
170
|
// register 会抛(Duplicate / id 空 / 已存在):说清是哪个助手、哪条错误。
|
|
156
171
|
warn(`custom-mode: 注册预设 "${assistant.id}" 失败 —— ${describe(error)}`)
|
|
157
|
-
|
|
172
|
+
const code = registering
|
|
173
|
+
? describe(error) === 'Duplicate agent preset: ' + assistant.id ? 'registrationConflict' : 'registrationFailed'
|
|
174
|
+
: 'registrationCleanupFailed'
|
|
175
|
+
return { ok: false, code, error: describe(error) }
|
|
158
176
|
}
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
/** Bring the registry in line with the given assistant list: mount all, drop the rest. */
|
|
162
180
|
async function sync(assistants) {
|
|
181
|
+
if (stopped) return assistants.map(({ id }) => ({ id, ...stoppedResult() }))
|
|
163
182
|
const results = []
|
|
164
183
|
for (const assistant of assistants) results.push({ id: assistant.id, ...(await mountOne(assistant)) })
|
|
165
184
|
const wanted = new Set(assistants.map((a) => a.id))
|
|
@@ -173,7 +192,11 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
|
|
|
173
192
|
}
|
|
174
193
|
|
|
175
194
|
async function disposeAll() {
|
|
176
|
-
|
|
195
|
+
const errors = []
|
|
196
|
+
for (const id of [...mounted.keys()]) {
|
|
197
|
+
try { await unmount(id) } catch (error) { errors.push(error) }
|
|
198
|
+
}
|
|
199
|
+
if (errors.length > 0) throw new AggregateError(errors, 'preset registration cleanup failed')
|
|
177
200
|
}
|
|
178
201
|
|
|
179
202
|
/**
|
|
@@ -257,11 +280,16 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
|
|
|
257
280
|
id: BACKEND_ID,
|
|
258
281
|
/** 新线不把 preset 落到磁盘,注册表就是唯一真相。 */
|
|
259
282
|
writesPresetFiles: false,
|
|
260
|
-
sync,
|
|
261
|
-
mountOne,
|
|
262
|
-
unmount,
|
|
263
|
-
remove,
|
|
264
|
-
disposeAll
|
|
283
|
+
sync: (assistants) => serialize(() => sync(assistants)),
|
|
284
|
+
mountOne: (assistant) => serialize(() => mountOne(assistant)),
|
|
285
|
+
unmount: (id) => serialize(() => unmount(id)),
|
|
286
|
+
remove: (id, dir) => serialize(() => stopped ? stoppedResult() : remove(id, dir)),
|
|
287
|
+
disposeAll: () => {
|
|
288
|
+
// Close admission immediately, before awaiting queued/in-flight mutations. A
|
|
289
|
+
// startup sync still reading host metadata must not resurrect this backend.
|
|
290
|
+
stopped = true
|
|
291
|
+
return serialize(disposeAll)
|
|
292
|
+
},
|
|
265
293
|
fetchBaseCompositions,
|
|
266
294
|
mountedIds: () => [...mounted.keys()],
|
|
267
295
|
}
|
package/seed.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
25
25
|
import { renderComposition } from './composition.mjs'
|
|
26
26
|
import { readPresetMeta, writePresetMeta } from './meta.mjs'
|
|
27
|
-
import { writeAtomic } from './atomic.mjs'
|
|
27
|
+
import { restoreBackups, writeAtomic } from './atomic.mjs'
|
|
28
28
|
import { dirname, join } from 'node:path'
|
|
29
29
|
import { fileURLToPath } from 'node:url'
|
|
30
30
|
|
|
@@ -116,6 +116,10 @@ export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options =
|
|
|
116
116
|
if (typeof presetDir !== 'string' || presetDir === '') {
|
|
117
117
|
return { created, kept, errors: ['没有可用的 preset 目录路径'] }
|
|
118
118
|
}
|
|
119
|
+
const recovery = restoreBackups(presetDir)
|
|
120
|
+
if (recovery.ok !== true) {
|
|
121
|
+
return { created, refreshed, kept, errors: ['Preset recovery required: ' + recovery.detail] }
|
|
122
|
+
}
|
|
119
123
|
if (!existsSync(sourceDir)) {
|
|
120
124
|
return { created, kept, errors: [`包内预设目录不存在: ${sourceDir}`] }
|
|
121
125
|
}
|