dsh-loadout 0.11.2 → 0.12.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/CHANGELOG.md +19 -0
- package/README.en.md +19 -12
- package/README.md +38 -12
- package/client.js +24 -10
- package/cordis.patch.yml +62 -13
- package/index.js +473 -67
- package/package.json +1 -1
- package/scripts/client-smoke.mjs +8 -6
- package/scripts/selftest.mjs +149 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loadout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Loadout for DeepSeek Harness: group tools into kits, keep only the current kit's tools visible in each conversation, and manage everything from a settings panel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/scripts/client-smoke.mjs
CHANGED
|
@@ -244,9 +244,9 @@ if (menu !== undefined) {
|
|
|
244
244
|
check(`menu shows «${label}»`, allText.includes(label), allText.slice(0, 400))
|
|
245
245
|
}
|
|
246
246
|
check('我的套装 counts user kits only', allText.includes('我的套装(2)'), allText.slice(0, 400))
|
|
247
|
-
// Presets
|
|
248
|
-
//
|
|
249
|
-
check('menu
|
|
247
|
+
// Presets are back from 0.12: they are the quickest way to start a known kind
|
|
248
|
+
// of work, they stay read-only, and each one now says what it leaves out.
|
|
249
|
+
check('menu lists the presets again', allText.includes('预设套装(3)') && allText.includes('写作'), allText.slice(0, 400))
|
|
250
250
|
check('加挂 counts extras', allText.includes('加挂(1)'), allText.slice(0, 400))
|
|
251
251
|
check('footer text is the approved sentence', allText.includes(FOOTER))
|
|
252
252
|
check('the menu shows the note with the kit name', allText.includes('我的套(用于视频制作)'), allText.slice(0, 400))
|
|
@@ -320,7 +320,7 @@ try {
|
|
|
320
320
|
try {
|
|
321
321
|
const panel = expand(registration.component({ preview: { data: fixture, openSections: { presets: true, kits: true } } }))
|
|
322
322
|
const panelText = textOf(panel)
|
|
323
|
-
check('settings
|
|
323
|
+
check('settings lists the presets again', panelText.includes('预设套装 · 只读(3)') && panelText.includes('写作'), panelText.slice(0, 400))
|
|
324
324
|
check('settings lists my kits', panelText.includes('我的套装'), panelText.slice(0, 400))
|
|
325
325
|
check('settings picks a default kit', panelText.includes('新对话默认套装') && panelText.includes('仅内置'), panelText.slice(0, 400))
|
|
326
326
|
check('a note rides next to the kit name', panelText.includes('我的套(用于视频制作)'), panelText.slice(0, 400))
|
|
@@ -330,10 +330,12 @@ try {
|
|
|
330
330
|
check('settings says which conversation the ledger describes', panelText.includes('最近活跃对话保留') && panelText.includes('设置页不绑定具体会话'), panelText.slice(0, 400))
|
|
331
331
|
// 全部 / 仅内置 sit in the same list as the kits, so they are rendered as cards
|
|
332
332
|
// with the same two actions; presets are read-only and carry no save-as button.
|
|
333
|
-
|
|
333
|
+
// The default section says out loud that it cannot reach an open conversation.
|
|
334
|
+
check('settings says the default only affects new conversations', panelText.includes('只影响之后新建的对话'), panelText.slice(0, 400))
|
|
334
335
|
const copyButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '复制切换命令')
|
|
335
336
|
const defaultButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '设为默认')
|
|
336
|
-
|
|
337
|
+
// 2 defaults + 3 presets + 2 user kits — every card carries both actions.
|
|
338
|
+
check('全部 and 仅内置 are cards with the two actions', copyButtons.length === 7 && defaultButtons.length === 7, `${copyButtons.length}/${defaultButtons.length}`)
|
|
337
339
|
check('preset cards no longer offer save-as', !panelText.includes('另存为我的套装'), panelText.slice(0, 400))
|
|
338
340
|
const editButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '编辑')
|
|
339
341
|
check('settings offers an edit action for saved kits', editButtons.length === 2, `${editButtons.length} · 编辑`)
|
package/scripts/selftest.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* & "…\DeepSeek Harness.exe" dsh-loadout\scripts\selftest.mjs
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { tmpdir } from 'node:os'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
|
|
@@ -32,7 +32,7 @@ const check = (label, ok, detail) => {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const SESSION_ID = 'session-1'
|
|
35
|
-
const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete']
|
|
35
|
+
const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete', 'kit_assign', 'kit_rename']
|
|
36
36
|
const SCOPED = ['read', 'write', 'edit', 'glob', 'grep', 'pwsh', 'web_search', 'web_fetch', 'todo_write']
|
|
37
37
|
/** Registered by each agent into its own layer: visible, but never restrictable. */
|
|
38
38
|
const SCOPE_OWNED = ['subagent']
|
|
@@ -61,6 +61,14 @@ const definitionOf = (toolName) => ({
|
|
|
61
61
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Session headers and agents are shared by every fake host in this file: the
|
|
66
|
+
* plugin keeps one registry per process, and a second `apply()` (the legacy and
|
|
67
|
+
* v2 blocks) must see the conversations the first one registered.
|
|
68
|
+
*/
|
|
69
|
+
const fakeSessions = new Map()
|
|
70
|
+
const fakeAgents = new Map()
|
|
71
|
+
|
|
64
72
|
/** One fake host. `layers` is what the real tool runtime exposes. */
|
|
65
73
|
function makeCtx() {
|
|
66
74
|
const tools = new Map()
|
|
@@ -92,10 +100,15 @@ function makeCtx() {
|
|
|
92
100
|
optional(dependencies, install) { install(childFor(dependencies, built, commands, routes, sessionAgentOf)) },
|
|
93
101
|
inject(dependencies, install) { install(childFor(dependencies, built, commands, routes, sessionAgentOf)) },
|
|
94
102
|
}
|
|
95
|
-
|
|
103
|
+
// `sessions` is the host's Session registry: `get(id)` hands back a Session
|
|
104
|
+
// whose `header` carries `createdAt` / `parentSession` / `origin`. The agents
|
|
105
|
+
// themselves live in their own map — the plugin only reads headers from this
|
|
106
|
+
// one, and `sessionController` is what resolves a Session id to an agent.
|
|
107
|
+
const sessions = fakeSessions
|
|
108
|
+
const agents = fakeAgents
|
|
96
109
|
built.sessionController = {
|
|
97
110
|
async resolveAgent(sessionId) {
|
|
98
|
-
if (
|
|
111
|
+
if (agents.has(sessionId)) return { agent: agents.get(sessionId) }
|
|
99
112
|
if (sessionId === SESSION_ID) return { agent: sessionAgentOf.agent }
|
|
100
113
|
return { error: { message: `找不到会话 "${sessionId}"` } }
|
|
101
114
|
},
|
|
@@ -104,7 +117,9 @@ function makeCtx() {
|
|
|
104
117
|
built.handlers = handlers
|
|
105
118
|
built.commands = commands
|
|
106
119
|
built.routes = routes
|
|
107
|
-
built.sessions = sessions
|
|
120
|
+
built.sessions = { get: (sessionId) => sessions.get(sessionId) }
|
|
121
|
+
built.sessionTable = sessions
|
|
122
|
+
built.agentTable = agents
|
|
108
123
|
return built
|
|
109
124
|
}
|
|
110
125
|
|
|
@@ -130,6 +145,9 @@ function childFor(dependencies, built, commands, routes) {
|
|
|
130
145
|
}
|
|
131
146
|
}
|
|
132
147
|
if (dependencies.includes('sessionController')) child.sessionController = built.sessionController
|
|
148
|
+
// The plugin injects `sessions` optionally; a fake host without it must still
|
|
149
|
+
// work (that is the 0.x path), so this mirrors the real `optional` contract.
|
|
150
|
+
if (dependencies.includes('sessions')) child.sessions = built.sessions
|
|
133
151
|
return child
|
|
134
152
|
}
|
|
135
153
|
|
|
@@ -178,8 +196,13 @@ check('registered state route', ctx.routes.has('/dsh-loadout/state'))
|
|
|
178
196
|
* `layers.effect` is the append `restrict()` performs internally, which is how
|
|
179
197
|
* the plugin masks those same tools without ever writing an allow list.
|
|
180
198
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
199
|
+
let agentSeq = 0
|
|
200
|
+
|
|
201
|
+
function makeAgent(tools = [...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOOLS], options = {}) {
|
|
202
|
+
// Every agent carries a Session id, because that id is the key a conversation's
|
|
203
|
+
// binding is stored under (`assignments[sessionId]`). An id-less fake made the
|
|
204
|
+
// whole binding layer look like a no-op.
|
|
205
|
+
const agent = { id: options.id ?? `session-agent-${++agentSeq}`, tools, filters: [], restrictable: new Set(GLOBAL) }
|
|
183
206
|
const append = (filter) => {
|
|
184
207
|
const entry = { filter, active: true }
|
|
185
208
|
agent.filters.push(entry)
|
|
@@ -230,6 +253,15 @@ function makeAgent(tools = [...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOO
|
|
|
230
253
|
},
|
|
231
254
|
},
|
|
232
255
|
}
|
|
256
|
+
// Publish the Session header and the agent itself, so `ensureBinding()` can
|
|
257
|
+
// read `createdAt` / `parentSession` / `origin` exactly like the host does.
|
|
258
|
+
const header = {
|
|
259
|
+
createdAt: options.createdAt ?? Date.now(),
|
|
260
|
+
...(options.parentSession === undefined ? {} : { parentSession: options.parentSession }),
|
|
261
|
+
...(options.origin === undefined ? {} : { origin: options.origin }),
|
|
262
|
+
}
|
|
263
|
+
fakeSessions.set(agent.id, { id: agent.id, header })
|
|
264
|
+
fakeAgents.set(agent.id, agent)
|
|
233
265
|
return agent
|
|
234
266
|
}
|
|
235
267
|
|
|
@@ -315,6 +347,17 @@ check('kit_list base is kit', listed.base === 'kit', listed.base)
|
|
|
315
347
|
check('kit_list prefers the shipped config', listed.source === 'bundle 配置', listed.source)
|
|
316
348
|
check('kit_list has three presets', listed.presets.length === 3, String(listed.presets.length))
|
|
317
349
|
check('kit_list has no user kit yet', listed.kits.length === 0, String(listed.kits.length))
|
|
350
|
+
// The host validates execute() output against the declared schema, so a field
|
|
351
|
+
// that is returned but not declared fails the whole call at runtime
|
|
352
|
+
// (`kit_list returned invalid output: "note" is not a declared property`).
|
|
353
|
+
// Keep the two sides in sync.
|
|
354
|
+
{
|
|
355
|
+
const schema = registered.get('kit_list').output.schema
|
|
356
|
+
const kitItem = schema.properties.kits.items.properties
|
|
357
|
+
const presetItem = schema.properties.presets.items.properties
|
|
358
|
+
check('kit_list declares every field it returns', 'note' in kitItem && 'note' in presetItem,
|
|
359
|
+
`kits=${Object.keys(kitItem).join(',')} presets=${Object.keys(presetItem).join(',')}`)
|
|
360
|
+
}
|
|
318
361
|
|
|
319
362
|
// ---- kit_apply by title ------------------------------------------------------
|
|
320
363
|
const appliedKit = await run('kit_apply', { kit: '编程' }, agent)
|
|
@@ -540,7 +583,7 @@ check('a note survives a restart', afterRestart.kits.find((kit) => kit.name ===
|
|
|
540
583
|
// ---- store schema ----------------------------------------------------------
|
|
541
584
|
// 0.11.0 stamps the file so a later release can migrate instead of guessing.
|
|
542
585
|
const stamped = JSON.parse(readFileSync(storePath, 'utf8')).schema
|
|
543
|
-
check('the store file records its schema version', stamped ===
|
|
586
|
+
check('the store file records its schema version', stamped === 2, String(stamped))
|
|
544
587
|
|
|
545
588
|
// A store written by 0.10.x has no `schema` field at all; it still has to load,
|
|
546
589
|
// because that is the exact file everyone upgrading from 0.10.x has on disk.
|
|
@@ -625,8 +668,7 @@ check('non-local Host refused', foreignResponse.status === 403, String(foreignRe
|
|
|
625
668
|
// ---- GET /state is per-Session: two agents never leak into each other -------
|
|
626
669
|
// The header chip asks for one conversation; answering from whichever agent was
|
|
627
670
|
// touched last is the bug this covers.
|
|
628
|
-
const secondAgent = makeAgent()
|
|
629
|
-
ctx.sessions.set('session-2', secondAgent)
|
|
671
|
+
const secondAgent = makeAgent([...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOOLS], { id: 'session-2' })
|
|
630
672
|
await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'coding' })
|
|
631
673
|
await post({ action: 'applyBase', sessionId: 'session-2', base: 'kit', kit: 'research' })
|
|
632
674
|
|
|
@@ -654,6 +696,99 @@ check(
|
|
|
654
696
|
JSON.stringify(ghostView.body.selection),
|
|
655
697
|
)
|
|
656
698
|
|
|
699
|
+
// ---- v2: one persisted binding per conversation ----------------------------
|
|
700
|
+
// The plan's core promise: a conversation carries its own choice, so changing
|
|
701
|
+
// the default later never reaches back into it, and an explicit switch survives
|
|
702
|
+
// a restart. Everything here reads the store file, not the in-memory state.
|
|
703
|
+
const v2Home = join(tmpdir(), `loadout-v2-${process.pid}`)
|
|
704
|
+
rmSync(v2Home, { recursive: true, force: true })
|
|
705
|
+
mkdirSync(join(v2Home, '.dsh', 'sessions', 'ws', 'session-old'), { recursive: true })
|
|
706
|
+
const savedHomeV2 = process.env.USERPROFILE
|
|
707
|
+
const savedHomeV2b = process.env.HOME
|
|
708
|
+
process.env.USERPROFILE = v2Home
|
|
709
|
+
process.env.HOME = v2Home
|
|
710
|
+
writeFileSync(join(v2Home, '.dsh', 'loadout.json'), JSON.stringify({
|
|
711
|
+
schema: 1,
|
|
712
|
+
kits: [{ name: 'mine', title: '我的', builtins: ['read', 'write'], plugins: [] }],
|
|
713
|
+
defaultKit: '',
|
|
714
|
+
}), 'utf8')
|
|
715
|
+
const v2 = await import('../index.js?v2=1')
|
|
716
|
+
const ctxV2 = makeCtx()
|
|
717
|
+
v2.apply(ctxV2, { defaultKit: '', kits: config.kits })
|
|
718
|
+
const fireCreated = (agent) => {
|
|
719
|
+
for (const handler of ctxV2.handlers.get('agent/created') ?? []) handler(agent)
|
|
720
|
+
}
|
|
721
|
+
const storeOf = () => JSON.parse(readFileSync(join(v2Home, '.dsh', 'loadout.json'), 'utf8'))
|
|
722
|
+
const backups = () => readdirSync(join(v2Home, '.dsh')).filter((entry) => entry.startsWith('loadout.json.v1.bak-'))
|
|
723
|
+
|
|
724
|
+
check('migration stamps schema 2', storeOf().schema === 2, String(storeOf().schema))
|
|
725
|
+
check('migration freezes existing conversations on 全部', storeOf().assignments['session-old'] === null,
|
|
726
|
+
JSON.stringify(storeOf().assignments))
|
|
727
|
+
check('migration keeps the schema-1 file aside', backups().length === 1, backups().join(','))
|
|
728
|
+
|
|
729
|
+
const agentA = makeAgent(undefined, { id: 'session-a' })
|
|
730
|
+
fireCreated(agentA)
|
|
731
|
+
check('a new conversation records 全部', storeOf().assignments['session-a'] === null, JSON.stringify(storeOf().assignments))
|
|
732
|
+
check('a new conversation starts unmasked', activeFilters(agentA).length === 0, String(activeFilters(agentA).length))
|
|
733
|
+
|
|
734
|
+
await ctxV2.registered.get('kit_set_default').execute({ name: 'coding' }, exec(agentA))
|
|
735
|
+
check('changing the default leaves an existing conversation alone',
|
|
736
|
+
activeFilters(agentA).length === 0 && keptOf(agentA).includes('web_search'),
|
|
737
|
+
JSON.stringify({ filters: activeFilters(agentA).length, kept: keptOf(agentA) }))
|
|
738
|
+
|
|
739
|
+
const agentB = makeAgent(undefined, { id: 'session-b' })
|
|
740
|
+
fireCreated(agentB)
|
|
741
|
+
check('a later conversation starts on the saved default',
|
|
742
|
+
!keptOf(agentB).includes('web_search') && keptOf(agentB).includes('read'), keptOf(agentB).join(','))
|
|
743
|
+
check('a later conversation records the default', storeOf().assignments['session-b'] === 'coding',
|
|
744
|
+
JSON.stringify(storeOf().assignments))
|
|
745
|
+
|
|
746
|
+
const agentFork = makeAgent(undefined, { id: 'session-fork', parentSession: 'session-b' })
|
|
747
|
+
fireCreated(agentFork)
|
|
748
|
+
check('a fork inherits its parent binding', storeOf().assignments['session-fork'] === 'coding',
|
|
749
|
+
JSON.stringify(storeOf().assignments))
|
|
750
|
+
|
|
751
|
+
const agentChild = makeAgent(undefined, { id: 'sub-1', origin: 'subagent' })
|
|
752
|
+
fireCreated(agentChild)
|
|
753
|
+
check('a subagent gets no binding', storeOf().assignments['sub-1'] === undefined,
|
|
754
|
+
JSON.stringify(storeOf().assignments))
|
|
755
|
+
|
|
756
|
+
const assigned = await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-b', kit: 'mine' }, exec(agentB))
|
|
757
|
+
check('kit_assign pins the conversation', assigned.ok !== false && storeOf().assignments['session-b'] === 'mine',
|
|
758
|
+
JSON.stringify({ assigned, assignments: storeOf().assignments }))
|
|
759
|
+
|
|
760
|
+
const dryDelete = await ctxV2.registered.get('kit_delete').execute({ name: 'mine', dryRun: true }, exec(agentB))
|
|
761
|
+
check('kit_delete --dryRun counts without deleting',
|
|
762
|
+
dryDelete.affectedSessions === 1 && storeOf().kits.some((kit) => kit.name === 'mine'),
|
|
763
|
+
JSON.stringify(dryDelete))
|
|
764
|
+
|
|
765
|
+
const deleted = await ctxV2.registered.get('kit_delete').execute({ name: 'mine' }, exec(agentB))
|
|
766
|
+
check('kit_delete clears the bindings it owned',
|
|
767
|
+
deleted.deleted === true && storeOf().assignments['session-b'] === null,
|
|
768
|
+
JSON.stringify({ deleted, assignments: storeOf().assignments }))
|
|
769
|
+
|
|
770
|
+
const createdV2 = await ctxV2.registered.get('kit_create').execute(
|
|
771
|
+
{ name: 'k-one', title: '一号', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
772
|
+
const renamed = await ctxV2.registered.get('kit_rename').execute({ name: 'k-one', title: '改过的一号' }, exec(agentB))
|
|
773
|
+
const listedV2 = await ctxV2.registered.get('kit_list').execute({}, exec(agentB))
|
|
774
|
+
check('kit_rename changes the title and keeps the id',
|
|
775
|
+
renamed.ok !== false && listedV2.kits.some((kit) => kit.name === 'k-one' && kit.title === '改过的一号'),
|
|
776
|
+
JSON.stringify({ createdV2, renamed, kits: listedV2.kits.map((kit) => [kit.name, kit.title]) }))
|
|
777
|
+
check('kit_list reports the binding', listedV2.binding === null || listedV2.binding === '',
|
|
778
|
+
JSON.stringify(listedV2.binding))
|
|
779
|
+
|
|
780
|
+
let reservedError
|
|
781
|
+
try {
|
|
782
|
+
await ctxV2.registered.get('kit_create').execute({ name: '全部', title: '占位', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
783
|
+
} catch (error) {
|
|
784
|
+
reservedError = String(error?.message ?? error)
|
|
785
|
+
}
|
|
786
|
+
check('a reserved name is refused', /全部/u.test(reservedError ?? ''), reservedError)
|
|
787
|
+
|
|
788
|
+
process.env.USERPROFILE = savedHomeV2
|
|
789
|
+
process.env.HOME = savedHomeV2b
|
|
790
|
+
rmSync(v2Home, { recursive: true, force: true })
|
|
791
|
+
|
|
657
792
|
// ---- legacy store: plugin-kits.json is read once ---------------------------
|
|
658
793
|
const legacyHome = join(tmpdir(), `loadout-legacy-${process.pid}`)
|
|
659
794
|
rmSync(legacyHome, { recursive: true, force: true })
|
|
@@ -670,7 +805,10 @@ const legacyModule = await import('../index.js?legacy=1')
|
|
|
670
805
|
const ctx3 = makeCtx()
|
|
671
806
|
legacyModule.apply(ctx3, config)
|
|
672
807
|
const legacyListed = await ctx3.registered.get('kit_list').execute({}, exec(makeAgent()))
|
|
673
|
-
|
|
808
|
+
// Migration copies the legacy kits into `loadout.json` and saves immediately, so
|
|
809
|
+
// the file the user's kits live in is the new one from the first boot. What must
|
|
810
|
+
// survive is the data (kits + default), not the old path string.
|
|
811
|
+
check('legacy store is migrated', legacyListed.source === join(legacyHome, '.dsh', 'loadout.json'), legacyListed.source)
|
|
674
812
|
check('legacy kit survives', legacyListed.kits.some((kit) => kit.name === 'old'), JSON.stringify(legacyListed.kits.map((kit) => kit.name)))
|
|
675
813
|
check('legacy default survives', legacyListed.default === 'old', legacyListed.default)
|
|
676
814
|
await ctx3.registered.get('kit_set_default').execute({ name: 'coding' }, exec(makeAgent()))
|