dsh-workspace-plus 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/cordis.patch.yml +16 -0
- package/icon.svg +5 -0
- package/lib/client.js +1412 -0
- package/lib/index.js +707 -0
- package/package.json +65 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-workspace-plus — host half.
|
|
3
|
+
*
|
|
4
|
+
* Feature 2: a DSH workspace keeps ONE directory (`SessionHeader.cwd`), but a
|
|
5
|
+
* real project often spans several roots (a frontend repo, a backend repo, a
|
|
6
|
+
* docs folder). This half lets one workspace carry N extra directories, each
|
|
7
|
+
* with a short **label**, and makes those labels usable in conversation:
|
|
8
|
+
*
|
|
9
|
+
* 1. a durable, plugin-owned store: workspace path -> [{ label, path, note }]
|
|
10
|
+
* 2. a per-request runtime-context contribution (`workspaceplus:directories`)
|
|
11
|
+
* that renders the label table for the calling Session's workspace, so the
|
|
12
|
+
* agent sees the mapping without spending a tool call
|
|
13
|
+
* 3. a `workspace_dirs` Tool the agent uses to inspect or edit the mapping
|
|
14
|
+
* when the user asks in natural language
|
|
15
|
+
* 4. a small JSON HTTP route the browser half uses to manage the same store
|
|
16
|
+
*
|
|
17
|
+
* The whole point is routing by label instead of by guesswork: the injected
|
|
18
|
+
* table gives absolute paths, so a similar-looking directory name elsewhere on
|
|
19
|
+
* disk cannot capture the edit.
|
|
20
|
+
*
|
|
21
|
+
* The module deliberately imports nothing but Node builtins: an externally
|
|
22
|
+
* installed bundle cannot rely on resolving `@deepseek-ai/*` from its own
|
|
23
|
+
* location, and the two contracts it needs (`systemPrompt.context({...})` and
|
|
24
|
+
* the registry-ready tool definition shape) are plain data.
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-workspace-plus
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
30
|
+
import { homedir } from 'node:os'
|
|
31
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
32
|
+
|
|
33
|
+
/** Plugin name shown by the loader and in host logs. */
|
|
34
|
+
export const name = 'dsh-workspace-plus'
|
|
35
|
+
|
|
36
|
+
/** Services the two contributions require; both ship in the base composition. */
|
|
37
|
+
export const inject = ['tools', 'systemPrompt']
|
|
38
|
+
|
|
39
|
+
/** Runtime-context entry name (stable: it is also the contribution's identity). */
|
|
40
|
+
export const CONTEXT_NAME = 'workspaceplus:directories'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runtime-context order. The shipped runtime contexts sit in the 110-120 band
|
|
44
|
+
* (sandbox policy, approval policy, subagent delegation); 130 places the
|
|
45
|
+
* directory table right after them, where it reads as part of the same
|
|
46
|
+
* "where am I allowed to work" block.
|
|
47
|
+
*/
|
|
48
|
+
export const CONTEXT_ORDER = 130
|
|
49
|
+
|
|
50
|
+
/** On-disk state format version. */
|
|
51
|
+
export const STATE_VERSION = 1
|
|
52
|
+
|
|
53
|
+
/** Tool name exposed to the model. */
|
|
54
|
+
export const TOOL_NAME = 'workspace_dirs'
|
|
55
|
+
|
|
56
|
+
/** HTTP route the browser half reads and writes. */
|
|
57
|
+
export const API_PATH = '/workspaceplus/api'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Plugin settings and their defaults. `defaultWorkspaceEnabled` is deliberately
|
|
61
|
+
* `false`: the default (first-use) Workspace belongs to the product, not to this
|
|
62
|
+
* plugin, so label routing stays inert there until the user turns it on from the
|
|
63
|
+
* `workspace+` settings page.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_SETTINGS = { defaultWorkspaceEnabled: false }
|
|
66
|
+
|
|
67
|
+
/** Settings key of the default-workspace switch. */
|
|
68
|
+
export const DEFAULT_WORKSPACE_SETTING = 'defaultWorkspaceEnabled'
|
|
69
|
+
|
|
70
|
+
/** Label grammar: short, shell-friendly, unambiguous in prose. */
|
|
71
|
+
const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/
|
|
72
|
+
|
|
73
|
+
/** Labels that would read as a path or a pronoun in prose and only cause confusion. */
|
|
74
|
+
const RESERVED_LABELS = new Set(['.', '..', 'cwd', 'root', 'this', 'workspace'])
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Pure helpers (exported for tools/verify.mjs)
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Locate the Harness home the same way the rest of the harness does: the
|
|
82
|
+
* `DSH_HOME` environment variable when the host process carries it, otherwise
|
|
83
|
+
* the default `~/.dsh`.
|
|
84
|
+
*
|
|
85
|
+
* @param env - environment snapshot (injectable for tests).
|
|
86
|
+
* @returns the absolute Harness home directory.
|
|
87
|
+
*/
|
|
88
|
+
export function dshHome(env = process.env) {
|
|
89
|
+
const fromEnv = env.DSH_HOME
|
|
90
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim())
|
|
91
|
+
return join(homedir(), '.dsh')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Absolute path of this plugin's state file.
|
|
96
|
+
*
|
|
97
|
+
* @param env - environment snapshot (injectable for tests).
|
|
98
|
+
* @returns the state file path.
|
|
99
|
+
*/
|
|
100
|
+
export function stateFilePath(env = process.env) {
|
|
101
|
+
return join(dshHome(env), 'workspaceplus', 'directories.json')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Comparison key for a directory path. Windows paths are case-insensitive, so
|
|
106
|
+
* the key folds case there and keeps the platform's own spelling elsewhere.
|
|
107
|
+
*
|
|
108
|
+
* @param path - any absolute or relative path.
|
|
109
|
+
* @returns the stable lookup key.
|
|
110
|
+
*/
|
|
111
|
+
export function keyOf(path) {
|
|
112
|
+
const absolute = resolve(String(path))
|
|
113
|
+
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @returns an empty, valid state object. */
|
|
117
|
+
export function emptyState() {
|
|
118
|
+
return { version: STATE_VERSION, settings: { ...DEFAULT_SETTINGS }, workspaces: {} }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalize the settings block, filling in every default for anything missing or
|
|
123
|
+
* malformed. An older state file without a `settings` key keeps working.
|
|
124
|
+
*
|
|
125
|
+
* @param raw - the parsed `settings` value, or anything else.
|
|
126
|
+
* @returns a complete settings object.
|
|
127
|
+
*/
|
|
128
|
+
export function normalizeSettings(raw) {
|
|
129
|
+
const settings = { ...DEFAULT_SETTINGS }
|
|
130
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return settings
|
|
131
|
+
if (typeof raw[DEFAULT_WORKSPACE_SETTING] === 'boolean') {
|
|
132
|
+
settings[DEFAULT_WORKSPACE_SETTING] = raw[DEFAULT_WORKSPACE_SETTING]
|
|
133
|
+
}
|
|
134
|
+
return settings
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Validate and normalize a parsed state document. Unknown shapes degrade to an
|
|
139
|
+
* empty state rather than throwing, because a corrupt state file must not stop
|
|
140
|
+
* the Harness from booting.
|
|
141
|
+
*
|
|
142
|
+
* @param raw - parsed JSON, or anything else.
|
|
143
|
+
* @returns a normalized state object.
|
|
144
|
+
*/
|
|
145
|
+
export function normalizeState(raw) {
|
|
146
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return emptyState()
|
|
147
|
+
const workspaces = raw.workspaces
|
|
148
|
+
if (workspaces === null || typeof workspaces !== 'object' || Array.isArray(workspaces)) return emptyState()
|
|
149
|
+
const next = emptyState()
|
|
150
|
+
next.settings = normalizeSettings(raw.settings)
|
|
151
|
+
for (const [key, entry] of Object.entries(workspaces)) {
|
|
152
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
|
|
153
|
+
const path = typeof entry.path === 'string' && entry.path !== '' ? entry.path : key
|
|
154
|
+
const dirs = []
|
|
155
|
+
const seen = new Set()
|
|
156
|
+
for (const dir of Array.isArray(entry.dirs) ? entry.dirs : []) {
|
|
157
|
+
if (dir === null || typeof dir !== 'object' || Array.isArray(dir)) continue
|
|
158
|
+
const label = typeof dir.label === 'string' ? dir.label.trim() : ''
|
|
159
|
+
const dirPath = typeof dir.path === 'string' ? dir.path.trim() : ''
|
|
160
|
+
if (!LABEL_PATTERN.test(label) || dirPath === '') continue
|
|
161
|
+
const labelKey = label.toLowerCase()
|
|
162
|
+
if (seen.has(labelKey)) continue
|
|
163
|
+
seen.add(labelKey)
|
|
164
|
+
dirs.push({
|
|
165
|
+
label,
|
|
166
|
+
path: resolve(dirPath),
|
|
167
|
+
...(typeof dir.note === 'string' && dir.note.trim() !== '' ? { note: dir.note.trim() } : {}),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
next.workspaces[keyOf(path)] = { path: resolve(path), dirs }
|
|
171
|
+
}
|
|
172
|
+
return next
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read the state file, tolerating a missing, unreadable, or corrupt file.
|
|
177
|
+
*
|
|
178
|
+
* @param file - state file path.
|
|
179
|
+
* @returns the normalized state.
|
|
180
|
+
*/
|
|
181
|
+
export function readState(file) {
|
|
182
|
+
try {
|
|
183
|
+
return normalizeState(JSON.parse(readFileSync(file, 'utf8')))
|
|
184
|
+
} catch {
|
|
185
|
+
return emptyState()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Persist the state atomically (write a sibling temp file, then rename), so a
|
|
191
|
+
* crash mid-write cannot leave a half-written mapping behind.
|
|
192
|
+
*
|
|
193
|
+
* @param file - state file path.
|
|
194
|
+
* @param state - the state to persist.
|
|
195
|
+
*/
|
|
196
|
+
export function writeState(file, state) {
|
|
197
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
198
|
+
const temporary = `${file}.tmp`
|
|
199
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
200
|
+
renameSync(temporary, file)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether the label routing runs for one workspace.
|
|
205
|
+
*
|
|
206
|
+
* The default (first-use) Workspace is the product's, not this plugin's: it stays
|
|
207
|
+
* inert unless the user enables the switch on the `workspace+` settings page.
|
|
208
|
+
* Every other workspace is enabled by definition.
|
|
209
|
+
*
|
|
210
|
+
* @param options - the settings, the workspace path, and the resolved default path.
|
|
211
|
+
* @returns true when the feature should run for that workspace.
|
|
212
|
+
*/
|
|
213
|
+
export function isFeatureEnabledFor({ settings, workspacePath, defaultWorkspacePath }) {
|
|
214
|
+
if (defaultWorkspacePath === null || defaultWorkspacePath === undefined) return true
|
|
215
|
+
if (keyOf(workspacePath) !== keyOf(defaultWorkspacePath)) return true
|
|
216
|
+
return normalizeSettings(settings)[DEFAULT_WORKSPACE_SETTING] === true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Apply one settings mutation.
|
|
221
|
+
*
|
|
222
|
+
* @param state - normalized state (mutated only when the operation succeeds).
|
|
223
|
+
* @param patch - the requested settings values.
|
|
224
|
+
* @returns `{ ok, message, settings }`.
|
|
225
|
+
*/
|
|
226
|
+
export function applySettings(state, patch) {
|
|
227
|
+
const wanted = patch?.[DEFAULT_WORKSPACE_SETTING]
|
|
228
|
+
if (typeof wanted !== 'boolean') {
|
|
229
|
+
return { ok: false, message: `${DEFAULT_WORKSPACE_SETTING} 必须是布尔值`, settings: state.settings }
|
|
230
|
+
}
|
|
231
|
+
state.settings = { ...normalizeSettings(state.settings), [DEFAULT_WORKSPACE_SETTING]: wanted }
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
settings: state.settings,
|
|
235
|
+
message: wanted ? '已对默认工作区启用 workspace+' : '已对默认工作区停用 workspace+',
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The labeled directories registered for one workspace directory.
|
|
241
|
+
*
|
|
242
|
+
* @param state - normalized state.
|
|
243
|
+
* @param workspacePath - the workspace's directory.
|
|
244
|
+
* @returns its entry, or undefined when nothing is registered.
|
|
245
|
+
*/
|
|
246
|
+
export function entryFor(state, workspacePath) {
|
|
247
|
+
return state.workspaces[keyOf(workspacePath)]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Validate one directory a user wants to register.
|
|
252
|
+
*
|
|
253
|
+
* @param path - the candidate directory.
|
|
254
|
+
* @returns `{ ok: true, path }` or `{ ok: false, message }`.
|
|
255
|
+
*/
|
|
256
|
+
export function validateDirectory(path) {
|
|
257
|
+
const text = String(path ?? '').trim()
|
|
258
|
+
if (text === '') return { ok: false, message: 'path 不能为空' }
|
|
259
|
+
if (!isAbsolute(text)) return { ok: false, message: `path 必须是绝对路径:${text}` }
|
|
260
|
+
const absolute = resolve(text)
|
|
261
|
+
if (!existsSync(absolute)) return { ok: false, message: `目录不存在:${absolute}` }
|
|
262
|
+
try {
|
|
263
|
+
if (!statSync(absolute).isDirectory()) return { ok: false, message: `不是目录:${absolute}` }
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return { ok: false, message: `无法访问 ${absolute}:${String(error?.message ?? error)}` }
|
|
266
|
+
}
|
|
267
|
+
return { ok: true, path: absolute }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Validate a label.
|
|
272
|
+
*
|
|
273
|
+
* @param label - the candidate label.
|
|
274
|
+
* @returns `{ ok: true, label }` or `{ ok: false, message }`.
|
|
275
|
+
*/
|
|
276
|
+
export function validateLabel(label) {
|
|
277
|
+
const text = String(label ?? '').trim()
|
|
278
|
+
if (text === '') return { ok: false, message: 'label 不能为空' }
|
|
279
|
+
if (!LABEL_PATTERN.test(text)) {
|
|
280
|
+
return { ok: false, message: `label 只能是字母/数字/. _ -,且不超过 32 个字符:${text}` }
|
|
281
|
+
}
|
|
282
|
+
if (RESERVED_LABELS.has(text.toLowerCase())) return { ok: false, message: `label 不能使用保留字:${text}` }
|
|
283
|
+
return { ok: true, label: text }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Apply one store operation. Every branch validates first and returns a
|
|
288
|
+
* discriminated result, so both the Tool and the HTTP route share one behavior
|
|
289
|
+
* and one set of messages.
|
|
290
|
+
*
|
|
291
|
+
* @param state - normalized state (mutated only when the operation succeeds).
|
|
292
|
+
* @param operation - `{ op: 'add' | 'remove' | 'update', workspace, label, path, note }`.
|
|
293
|
+
* @returns `{ ok: true, message, entry }` or `{ ok: false, message }`.
|
|
294
|
+
*/
|
|
295
|
+
export function applyOperation(state, operation) {
|
|
296
|
+
const workspaceCheck = validateDirectory(operation.workspace)
|
|
297
|
+
if (!workspaceCheck.ok) return { ok: false, message: `工作区目录无效:${workspaceCheck.message}` }
|
|
298
|
+
const workspacePath = workspaceCheck.path
|
|
299
|
+
const key = keyOf(workspacePath)
|
|
300
|
+
const current = state.workspaces[key] ?? { path: workspacePath, dirs: [] }
|
|
301
|
+
|
|
302
|
+
const labelCheck = validateLabel(operation.label)
|
|
303
|
+
if (!labelCheck.ok) return labelCheck
|
|
304
|
+
const labelKey = labelCheck.label.toLowerCase()
|
|
305
|
+
const at = current.dirs.findIndex((dir) => dir.label.toLowerCase() === labelKey)
|
|
306
|
+
|
|
307
|
+
if (operation.op === 'add') {
|
|
308
|
+
if (at !== -1) {
|
|
309
|
+
return { ok: false, message: `标签 ${current.dirs[at].label} 已存在(指向 ${current.dirs[at].path})。要改路径请用 update。` }
|
|
310
|
+
}
|
|
311
|
+
const directoryCheck = validateDirectory(operation.path)
|
|
312
|
+
if (!directoryCheck.ok) return directoryCheck
|
|
313
|
+
current.dirs.push({
|
|
314
|
+
label: labelCheck.label,
|
|
315
|
+
path: directoryCheck.path,
|
|
316
|
+
...(typeof operation.note === 'string' && operation.note.trim() !== '' ? { note: operation.note.trim() } : {}),
|
|
317
|
+
})
|
|
318
|
+
state.workspaces[key] = current
|
|
319
|
+
return { ok: true, entry: current, message: `已为工作区 ${workspacePath} 登记 ${labelCheck.label} -> ${directoryCheck.path}` }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (operation.op === 'remove') {
|
|
323
|
+
if (at === -1) return { ok: false, message: `工作区 ${workspacePath} 没有标签 ${labelCheck.label}` }
|
|
324
|
+
const [removed] = current.dirs.splice(at, 1)
|
|
325
|
+
if (current.dirs.length === 0) delete state.workspaces[key]
|
|
326
|
+
else state.workspaces[key] = current
|
|
327
|
+
return { ok: true, entry: current.dirs.length === 0 ? { path: workspacePath, dirs: [] } : current, message: `已移除标签 ${removed.label}(${removed.path})` }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (operation.op === 'update') {
|
|
331
|
+
if (at === -1) return { ok: false, message: `工作区 ${workspacePath} 没有标签 ${labelCheck.label};新增请用 add` }
|
|
332
|
+
const target = { ...current.dirs[at] }
|
|
333
|
+
if (operation.path !== undefined && operation.path !== null && String(operation.path).trim() !== '') {
|
|
334
|
+
const directoryCheck = validateDirectory(operation.path)
|
|
335
|
+
if (!directoryCheck.ok) return directoryCheck
|
|
336
|
+
target.path = directoryCheck.path
|
|
337
|
+
}
|
|
338
|
+
if (operation.note !== undefined) {
|
|
339
|
+
const note = String(operation.note).trim()
|
|
340
|
+
if (note === '') delete target.note
|
|
341
|
+
else target.note = note
|
|
342
|
+
}
|
|
343
|
+
current.dirs[at] = target
|
|
344
|
+
state.workspaces[key] = current
|
|
345
|
+
return { ok: true, entry: current, message: `已更新标签 ${target.label} -> ${target.path}` }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { ok: false, message: `未知操作:${String(operation.op)}` }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Render one workspace's mapping for the model. Returns an empty string when
|
|
353
|
+
* the workspace has no registered directories, so an unused feature costs no
|
|
354
|
+
* prompt bytes.
|
|
355
|
+
*
|
|
356
|
+
* @param options - workspace path, its entry, and the effective sandbox facts.
|
|
357
|
+
* @returns the runtime-context text, or `''`.
|
|
358
|
+
*/
|
|
359
|
+
export function renderDirectoryContext({ workspacePath, entry, sandboxMode }) {
|
|
360
|
+
if (entry === undefined || !Array.isArray(entry.dirs) || entry.dirs.length === 0) return ''
|
|
361
|
+
const restricted = sandboxMode !== undefined && sandboxMode !== 'danger-full-access'
|
|
362
|
+
const inside = (dir) => keyOf(dir) === keyOf(workspacePath) || keyOf(dir).startsWith(`${keyOf(workspacePath)}\\`) || keyOf(dir).startsWith(`${keyOf(workspacePath)}/`)
|
|
363
|
+
|
|
364
|
+
const lines = [
|
|
365
|
+
'工作区多目录映射(dsh-workspace-plus 插件维护)',
|
|
366
|
+
'',
|
|
367
|
+
`当前会话的 cwd(工作区根目录):\`${workspacePath}\``,
|
|
368
|
+
'',
|
|
369
|
+
'用户提到下列「标签」时,指的就是表中对应的绝对目录。请直接使用表中的绝对路径,',
|
|
370
|
+
'不要根据目录名相似、命名习惯或当前 cwd 去推测位置:',
|
|
371
|
+
'',
|
|
372
|
+
]
|
|
373
|
+
for (const dir of entry.dirs) {
|
|
374
|
+
const note = dir.note === undefined ? '' : `(${dir.note})`
|
|
375
|
+
const outside = restricted && !inside(dir.path) ? ' ⚠ 在工作区根目录之外' : ''
|
|
376
|
+
lines.push(`- \`${dir.label}\` → \`${dir.path}\`${note}${outside}`)
|
|
377
|
+
}
|
|
378
|
+
lines.push(
|
|
379
|
+
'',
|
|
380
|
+
'规则:',
|
|
381
|
+
'1. 读写、编辑、搜索和命令中的路径都使用上表的绝对路径;不要把它当作相对于 cwd 的相对路径。',
|
|
382
|
+
'2. 标签不区分大小写。',
|
|
383
|
+
`3. 只在上表列出的目录和工作区根目录 \`${workspacePath}\` 内工作。不要扫描 cwd 的同级目录,也不要用 \`**/*\` 这类无范围模式在 cwd 下“先找找看”——要定位文件时,先用标签选定目录,再在该目录内按模式搜索。`,
|
|
384
|
+
'4. 上表没有覆盖的路径,先调用 workspace_dirs 工具确认,不要猜。',
|
|
385
|
+
'5. 用户要求增删改这份映射时,调用 workspace_dirs 工具,不要直接编辑状态文件。',
|
|
386
|
+
)
|
|
387
|
+
if (restricted) {
|
|
388
|
+
lines.push(
|
|
389
|
+
`6. 当前文件策略为 ${sandboxMode}:只有工作区根目录内的文件可直接写;标有 ⚠ 的目录需要工具自身的升级流程` +
|
|
390
|
+
'(如 write/edit 的 sandbox_permissions + justification),按工具返回的提示处理,不要因为它不在工作区内就直接放弃,也不要反复重试同一次写入。',
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
return lines.join('\n')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Render one workspace's mapping for a human/agent tool result.
|
|
398
|
+
*
|
|
399
|
+
* @param workspacePath - the workspace directory.
|
|
400
|
+
* @param entry - its registered directories.
|
|
401
|
+
* @returns a readable multi-line summary.
|
|
402
|
+
*/
|
|
403
|
+
export function renderDirectoryList(workspacePath, entry) {
|
|
404
|
+
if (entry === undefined || entry.dirs.length === 0) {
|
|
405
|
+
return `工作区 ${workspacePath} 还没有登记任何目录。用 workspace_dirs add 添加,例如:\n action=add, workspace=${workspacePath}, label=backend, path=D:\\proj\\backend`
|
|
406
|
+
}
|
|
407
|
+
const lines = [`工作区 ${workspacePath} 已登记 ${entry.dirs.length} 个目录:`]
|
|
408
|
+
for (const dir of entry.dirs) {
|
|
409
|
+
lines.push(`- ${dir.label} -> ${dir.path}${dir.note === undefined ? '' : ` (${dir.note})`}`)
|
|
410
|
+
}
|
|
411
|
+
return lines.join('\n')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The closed action vocabulary of the `workspace_dirs` tool. */
|
|
415
|
+
export const TOOL_ACTIONS = ['list', 'add', 'remove', 'update']
|
|
416
|
+
|
|
417
|
+
/** The model-facing parameter schema of the `workspace_dirs` tool. */
|
|
418
|
+
export const TOOL_PARAMETERS = {
|
|
419
|
+
type: 'object',
|
|
420
|
+
properties: {
|
|
421
|
+
action: {
|
|
422
|
+
type: 'string',
|
|
423
|
+
enum: TOOL_ACTIONS,
|
|
424
|
+
description:
|
|
425
|
+
'list reads the mapping; add registers a new labeled directory; remove drops a label; update changes a label\'s path or note.',
|
|
426
|
+
},
|
|
427
|
+
label: {
|
|
428
|
+
type: 'string',
|
|
429
|
+
description: 'Conversation label for the directory. Required for add/remove/update (letters, digits, ".", "_", "-", max 32).',
|
|
430
|
+
},
|
|
431
|
+
path: {
|
|
432
|
+
type: 'string',
|
|
433
|
+
description: 'Absolute directory path: required for add, optional for update (re-points the label).',
|
|
434
|
+
},
|
|
435
|
+
note: {
|
|
436
|
+
type: 'string',
|
|
437
|
+
description: 'Optional one-line description, shown to the agent and in the GUI.',
|
|
438
|
+
},
|
|
439
|
+
workspace: {
|
|
440
|
+
type: 'string',
|
|
441
|
+
description: 'Absolute path of the target workspace directory. Defaults to the calling session workspace (its cwd).',
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
required: ['action'],
|
|
445
|
+
additionalProperties: false,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The model-facing description of the `workspace_dirs` tool. */
|
|
449
|
+
export const TOOL_DESCRIPTION = [
|
|
450
|
+
'Read or edit the extra labeled directories registered for a DSH workspace.',
|
|
451
|
+
'A workspace has one root (the session cwd); this tool maintains the additional project folders that belong to it,',
|
|
452
|
+
'each addressed in conversation by a short label. The current mapping is already in your context; call this tool to',
|
|
453
|
+
'confirm it, or when the user asks to add, remove or re-point a labeled directory.',
|
|
454
|
+
'Never guess a directory from a similar name — resolve it here first.',
|
|
455
|
+
].join(' ')
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Plugin
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Host-side entry point. Wires the store to the three consumers (prompt
|
|
463
|
+
* context, tool, HTTP route) and lets Cordis dispose all of them on unload.
|
|
464
|
+
*
|
|
465
|
+
* @param ctx - host plugin context.
|
|
466
|
+
*/
|
|
467
|
+
export function apply(ctx) {
|
|
468
|
+
const file = stateFilePath()
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Identity of the first-use (default) Workspace, resolved through the PUBLIC
|
|
472
|
+
* registry API: `initializeDefault` returns the recorded default immediately
|
|
473
|
+
* when one exists, returns `undefined` when automatic creation is ineligible,
|
|
474
|
+
* and only ever calls the directory resolver when it would actually create a
|
|
475
|
+
* workspace — so the probe passes a resolver that refuses, and a brand-new
|
|
476
|
+
* install simply reports "no default workspace" instead of creating one.
|
|
477
|
+
*
|
|
478
|
+
* `resolved` stays false when the registry is not usable yet (its service is
|
|
479
|
+
* registered before `[Service.init]` finishes, so `requireState()` can throw on
|
|
480
|
+
* a cold boot). Every later caller retries, which keeps a failed first attempt
|
|
481
|
+
* from silently enabling the feature on the default Workspace forever.
|
|
482
|
+
*/
|
|
483
|
+
let defaultWorkspace = { resolved: false, id: null, path: null }
|
|
484
|
+
|
|
485
|
+
const resolveDefaultWorkspace = async (registry = ctx.get('workspaceRegistry')) => {
|
|
486
|
+
if (defaultWorkspace.resolved) return defaultWorkspace
|
|
487
|
+
if (registry === undefined || typeof registry.initializeDefault !== 'function') return defaultWorkspace
|
|
488
|
+
try {
|
|
489
|
+
const workspace = await registry.initializeDefault(() => {
|
|
490
|
+
throw new Error('dsh-workspace-plus: declined to create the default workspace')
|
|
491
|
+
})
|
|
492
|
+
defaultWorkspace = {
|
|
493
|
+
resolved: true,
|
|
494
|
+
id: workspace === undefined ? null : String(workspace.id),
|
|
495
|
+
path: workspace === undefined ? null : workspace.path,
|
|
496
|
+
}
|
|
497
|
+
} catch {
|
|
498
|
+
// Registry not started yet, or the probe was declined: retry on the next call.
|
|
499
|
+
}
|
|
500
|
+
return defaultWorkspace
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Warm the cache as soon as the registry service is usable — the synchronous
|
|
505
|
+
* prompt path can then read a settled value from the very first request.
|
|
506
|
+
*/
|
|
507
|
+
ctx.inject(['workspaceRegistry'], (registryScope) => {
|
|
508
|
+
void resolveDefaultWorkspace(registryScope.workspaceRegistry)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
/** Read-modify-write through the single state file, serialized per call. */
|
|
512
|
+
const mutate = (operation) => {
|
|
513
|
+
const state = readState(file)
|
|
514
|
+
const result = applyOperation(state, operation)
|
|
515
|
+
if (result.ok) writeState(file, state)
|
|
516
|
+
return result
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Read-modify-write for the settings block. */
|
|
520
|
+
const mutateSettings = (patch) => {
|
|
521
|
+
const state = readState(file)
|
|
522
|
+
const result = applySettings(state, patch)
|
|
523
|
+
if (result.ok) writeState(file, state)
|
|
524
|
+
return result
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Whether the feature runs for a workspace path, given the current settings and probe. */
|
|
528
|
+
const enabledFor = (workspacePath, settings) =>
|
|
529
|
+
isFeatureEnabledFor({ settings, workspacePath, defaultWorkspacePath: defaultWorkspace.path })
|
|
530
|
+
|
|
531
|
+
const snapshot = () => {
|
|
532
|
+
const registry = ctx.get('workspaceRegistry')
|
|
533
|
+
const state = readState(file)
|
|
534
|
+
const workspaces = registry === undefined ? [] : registry.list()
|
|
535
|
+
return {
|
|
536
|
+
registryAvailable: registry !== undefined,
|
|
537
|
+
settings: { ...state.settings },
|
|
538
|
+
defaultWorkspaceId: defaultWorkspace.id,
|
|
539
|
+
defaultWorkspacePath: defaultWorkspace.path,
|
|
540
|
+
workspaces: workspaces.map((workspace) => {
|
|
541
|
+
const entry = entryFor(state, workspace.path)
|
|
542
|
+
const isDefault = defaultWorkspace.path !== null && keyOf(defaultWorkspace.path) === keyOf(workspace.path)
|
|
543
|
+
return {
|
|
544
|
+
id: String(workspace.id),
|
|
545
|
+
path: workspace.path,
|
|
546
|
+
title: workspace.title,
|
|
547
|
+
isDefault,
|
|
548
|
+
enabled: enabledFor(workspace.path, state.settings),
|
|
549
|
+
dirs: entry === undefined ? [] : entry.dirs.map((dir) => ({ ...dir })),
|
|
550
|
+
}
|
|
551
|
+
}),
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// --- 1. Make the label table part of every request in that workspace ------
|
|
556
|
+
// `context()` and `register()` each install their own effect on this plugin's
|
|
557
|
+
// fiber, so both contributions disappear when the plugin unloads.
|
|
558
|
+
ctx.systemPrompt.context({
|
|
559
|
+
name: CONTEXT_NAME,
|
|
560
|
+
order: CONTEXT_ORDER,
|
|
561
|
+
text: (context) => {
|
|
562
|
+
const session = context.agent?.session
|
|
563
|
+
const workspacePath = session?.header?.cwd
|
|
564
|
+
if (typeof workspacePath !== 'string' || workspacePath === '') return ''
|
|
565
|
+
const state = readState(file)
|
|
566
|
+
// The default Workspace stays untouched until the user opts in.
|
|
567
|
+
if (!enabledFor(workspacePath, state.settings)) return ''
|
|
568
|
+
const entry = entryFor(state, workspacePath)
|
|
569
|
+
if (entry === undefined || entry.dirs.length === 0) return ''
|
|
570
|
+
const policy = ctx.get('sandboxPolicy')
|
|
571
|
+
const sandboxMode = session === undefined ? undefined : policy?.resolve({ session })?.mode
|
|
572
|
+
return renderDirectoryContext({ workspacePath, entry, sandboxMode })
|
|
573
|
+
},
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
// --- 2. Let the agent inspect and edit the mapping ------------------------
|
|
577
|
+
ctx.tools.register({
|
|
578
|
+
name: TOOL_NAME,
|
|
579
|
+
description: TOOL_DESCRIPTION,
|
|
580
|
+
parameters: TOOL_PARAMETERS,
|
|
581
|
+
output: {
|
|
582
|
+
schema: { type: 'string' },
|
|
583
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
584
|
+
},
|
|
585
|
+
async execute(args, exec) {
|
|
586
|
+
const session = exec.agent?.session
|
|
587
|
+
const action = args?.action
|
|
588
|
+
if (!TOOL_ACTIONS.includes(action)) {
|
|
589
|
+
return `Error: action 必须是 ${TOOL_ACTIONS.join(' / ')} 之一,收到 ${JSON.stringify(action ?? null)}`
|
|
590
|
+
}
|
|
591
|
+
const workspace =
|
|
592
|
+
typeof args.workspace === 'string' && args.workspace.trim() !== '' ? args.workspace : session?.header?.cwd
|
|
593
|
+
if (typeof workspace !== 'string' || workspace === '') {
|
|
594
|
+
return 'Error: 没有可用的工作区目录。请通过 workspace 参数显式指定一个绝对路径。'
|
|
595
|
+
}
|
|
596
|
+
// The tool can afford a fresh probe, so its gate is never decided on a
|
|
597
|
+
// cache that a cold boot could not fill.
|
|
598
|
+
await resolveDefaultWorkspace()
|
|
599
|
+
const state = readState(file)
|
|
600
|
+
if (!enabledFor(workspace, state.settings)) {
|
|
601
|
+
return (
|
|
602
|
+
`Error: ${resolve(workspace)} 是默认工作区,workspace+ 默认不对它启用。` +
|
|
603
|
+
'请在 GUI 的 设置 → workspace+ 打开「对默认工作区启用」,或改用其它工作区。'
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
if (action === 'list') {
|
|
607
|
+
return renderDirectoryList(resolve(workspace), entryFor(state, workspace))
|
|
608
|
+
}
|
|
609
|
+
const result = mutate({
|
|
610
|
+
op: action,
|
|
611
|
+
workspace,
|
|
612
|
+
label: args.label,
|
|
613
|
+
path: args.path,
|
|
614
|
+
note: args.note,
|
|
615
|
+
})
|
|
616
|
+
if (!result.ok) return `Error: ${result.message}`
|
|
617
|
+
return `${result.message}\n\n${renderDirectoryList(resolve(workspace), result.entry)}`
|
|
618
|
+
},
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
// --- 3. Give the browser half a same-origin JSON endpoint -----------------
|
|
622
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
623
|
+
webCtx.effect(
|
|
624
|
+
() =>
|
|
625
|
+
webCtx.webServer.register({
|
|
626
|
+
kind: 'exact',
|
|
627
|
+
path: API_PATH,
|
|
628
|
+
handler: async (req, res) => {
|
|
629
|
+
const connection = ctx.get('connection')
|
|
630
|
+
const admission = connection?.admit?.(req)
|
|
631
|
+
if (admission !== undefined && 'rejection' in admission) {
|
|
632
|
+
res.writeHead(admission.rejection)
|
|
633
|
+
res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
const send = (status, payload) => {
|
|
637
|
+
const body = JSON.stringify(payload)
|
|
638
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
639
|
+
res.end(body)
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
// The settings page must see the default-workspace identity even on
|
|
643
|
+
// the very first request after boot, so the probe is awaited here.
|
|
644
|
+
await resolveDefaultWorkspace()
|
|
645
|
+
if (req.method === 'GET') {
|
|
646
|
+
send(200, { ok: true, ...snapshot() })
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
if (req.method !== 'POST') {
|
|
650
|
+
send(405, { ok: false, message: 'method not allowed' })
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
const chunks = []
|
|
654
|
+
let bytes = 0
|
|
655
|
+
for await (const chunk of req) {
|
|
656
|
+
bytes += chunk.length
|
|
657
|
+
if (bytes > 64 * 1024) {
|
|
658
|
+
send(413, { ok: false, message: 'request body too large' })
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
chunks.push(chunk)
|
|
662
|
+
}
|
|
663
|
+
let payload
|
|
664
|
+
try {
|
|
665
|
+
payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
666
|
+
} catch {
|
|
667
|
+
send(400, { ok: false, message: 'body is not JSON' })
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
if (payload?.op === 'settings') {
|
|
671
|
+
const settingsResult = mutateSettings(payload?.settings)
|
|
672
|
+
if (!settingsResult.ok) {
|
|
673
|
+
send(200, { ok: false, message: settingsResult.message, ...snapshot() })
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
send(200, { ok: true, message: settingsResult.message, ...snapshot() })
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
const operation = {
|
|
680
|
+
op: payload?.op,
|
|
681
|
+
workspace: payload?.workspace,
|
|
682
|
+
label: payload?.label,
|
|
683
|
+
path: payload?.path,
|
|
684
|
+
note: payload?.note,
|
|
685
|
+
}
|
|
686
|
+
if (operation.op === undefined) {
|
|
687
|
+
send(400, { ok: false, message: 'missing op' })
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
const result = mutate(operation)
|
|
691
|
+
if (!result.ok) {
|
|
692
|
+
send(200, { ok: false, message: result.message, ...snapshot() })
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
send(200, { ok: true, message: result.message, ...snapshot() })
|
|
696
|
+
} catch (error) {
|
|
697
|
+
ctx.logger?.warn?.(error)
|
|
698
|
+
send(500, { ok: false, message: String(error?.message ?? error) })
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
}),
|
|
702
|
+
'dsh-workspace-plus: /workspaceplus/api route',
|
|
703
|
+
)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
console.log(`[${name}] host half loaded — workspace label routing is active (${file})`)
|
|
707
|
+
}
|