dsh-workspace-memories 1.1.3 → 1.2.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.md +10 -4
- package/README.zh.md +7 -3
- package/client.js +26 -9
- package/index.js +130 -47
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,19 +16,23 @@ This plugin closes that gap with a small, explicit memory loop whose default sto
|
|
|
16
16
|
|
|
17
17
|
1. **Every workspace gets a memory directory** — by default under the DSH data directory (`~/.dsh/workspace-memory/<workspace>/`), never inside the repo. Plain Markdown you can read, diff, and back up. (An opt-in `workspace` storage mode keeps memories in `<workspace>/.dsh/memory/` for teams who *want* them versioned alongside the code.)
|
|
18
18
|
2. **Memories are injected automatically** — each session created in a workspace carries that workspace's memory files in its system prompt, alongside `AGENTS.md`, so a fresh session starts already "remembering".
|
|
19
|
-
3. **The agent can write memories itself** — a global `save_workspace_memory` tool lets any session persist a decision, server detail, convention, or preference the moment you say "remember this".
|
|
19
|
+
3. **The agent can write memories itself** — a global `save_workspace_memory` tool lets any session persist a decision, server detail, convention, or preference the moment you say "remember this" (pass `scope: "global"` for cross-project preferences).
|
|
20
20
|
4. **You stay in control** — a sidebar panel lists, creates, edits, and deletes memory files per workspace, so memory stays inspectable and correctable instead of hidden in a database.
|
|
21
21
|
|
|
22
22
|
## Features
|
|
23
23
|
|
|
24
24
|
| Capability | Where |
|
|
25
25
|
|---|---|
|
|
26
|
-
| Browse / create / edit / delete memory files | **Workspace Memory** sidebar panel (switch workspaces via the selector) |
|
|
26
|
+
| Browse / create / edit / delete memory files | **Workspace Memory** sidebar panel (switch workspaces or the global scope via the selector) |
|
|
27
27
|
| Ask the agent to remember something | Any session: "remember: …" → the agent calls `save_workspace_memory` |
|
|
28
|
-
| Automatic recall | Every session
|
|
28
|
+
| Automatic recall | Every session gets the **global** memory (all workspaces) followed by its **workspace** memory injected into its system prompt |
|
|
29
29
|
|
|
30
30
|
Works in every built-in agent preset (Standard, PTC, Minimal, Creator): the tool is registered in the root composition, and global tools are visible to all presets unless a custom preset opts out with a `tools.restrict` allow list — add `save_workspace_memory` to that list if so.
|
|
31
31
|
|
|
32
|
+
### Global memory
|
|
33
|
+
|
|
34
|
+
The panel selector's top entry, **🌐 Global — every workspace**, addresses one cross-project memory directory at `~/.dsh/workspace-memory/_global/`. It is seeded once with a blank `preferences.md` template (persona, style, communication, formatting) and never overwritten after that. Global memory sorts **before** workspace memory in the prompt, so project notes read as overrides of personal preferences.
|
|
35
|
+
|
|
32
36
|
## Installation
|
|
33
37
|
|
|
34
38
|
The package is plain JavaScript with no build step, so every install channel works:
|
|
@@ -73,7 +77,9 @@ Two storage modes, chosen by the plugin's `storage` config:
|
|
|
73
77
|
| `workspace` | `<workspace>/.dsh/memory/` — inside the project | Team-shared memories meant to be committed alongside the code |
|
|
74
78
|
|
|
75
79
|
```
|
|
76
|
-
~/.dsh/workspace-memory/ # default memory store
|
|
80
|
+
~/.dsh/workspace-memory/ # default memory store
|
|
81
|
+
├── <workspace-key>/ # one subdirectory per workspace
|
|
82
|
+
└── _global/ # the cross-workspace global memory (panel: 🌐 Global)
|
|
77
83
|
dsh-workspace-memories/ # this plugin
|
|
78
84
|
├── package.json # bundle manifest (dsh.bundle.patch + dsh.client)
|
|
79
85
|
├── cordis.patch.yml # plugin row declaration
|
package/README.zh.md
CHANGED
|
@@ -16,19 +16,23 @@ DSH 的 Agent 跨会话是无状态的:一个会话"学到"的东西——项
|
|
|
16
16
|
|
|
17
17
|
1. **每个工作区一个记忆目录**——默认在 DSH 数据目录下(`~/.dsh/workspace-memory/<工作区>/`),不进仓库;纯 Markdown,可读、可 diff、可备份。(另提供 `workspace` 存储模式,把记忆放在 `<工作区>/.dsh/memory/`,适合想随代码一起版本化的团队共享记忆。)
|
|
18
18
|
2. **记忆自动注入**——在该工作区创建的每个会话,系统提示都会携带这些记忆文件(与 `AGENTS.md` 并存),新会话一开始就"记得"。
|
|
19
|
-
3. **Agent 可以自己记**——全局工具 `save_workspace_memory` 让任意会话在你说"记住这个"
|
|
19
|
+
3. **Agent 可以自己记**——全局工具 `save_workspace_memory` 让任意会话在你说"记住这个"的当下就把决策、服务器细节、约定、偏好落成文件(跨项目的个人偏好可传 `scope: "global"`)。
|
|
20
20
|
4. **你始终可控**——侧栏面板按工作区列出、新建、编辑、删除记忆文件,记忆摊在阳光下,而不是藏进数据库。
|
|
21
21
|
|
|
22
22
|
## 功能
|
|
23
23
|
|
|
24
24
|
| 能力 | 入口 |
|
|
25
25
|
|---|---|
|
|
26
|
-
| 浏览 / 新建 / 编辑 / 删除记忆文件 |
|
|
26
|
+
| 浏览 / 新建 / 编辑 / 删除记忆文件 | 侧栏「工作区记忆」面板(可切换工作区或全局范围) |
|
|
27
27
|
| 让 Agent 记住事情 | 任意会话直接说「记住:…」→ Agent 调用 `save_workspace_memory` |
|
|
28
|
-
| 记忆自动生效 |
|
|
28
|
+
| 记忆自动生效 | 每个会话的系统提示先携带**全局**记忆(所有工作区通用),再携带**工作区**记忆 |
|
|
29
29
|
|
|
30
30
|
所有内置 Agent 预设(标准、PTC、极简、创造)均可用:工具注册在根组合,全局工具默认对所有预设可见。唯一例外是自定义预设使用 `tools.restrict` 白名单(`allow: [...]`)的情况——把 `save_workspace_memory` 加进白名单即可。
|
|
31
31
|
|
|
32
|
+
### 全局记忆
|
|
33
|
+
|
|
34
|
+
面板下拉里第一项「🌐 全局(所有工作区通用)」对应跨项目记忆目录 `~/.dsh/workspace-memory/_global/`。首次使用时自动种入一份空白模板 `preferences.md`(人格、风格、沟通、格式四节,带注释提示),此后绝不覆盖用户修改。注入顺序上全局记忆排在工作区记忆**前面**,工作区里的项目笔记即是对个人偏好的覆盖。
|
|
35
|
+
|
|
32
36
|
## 安装
|
|
33
37
|
|
|
34
38
|
本包为纯 JavaScript、无构建步骤,所有安装渠道都可用:
|
package/client.js
CHANGED
|
@@ -16,12 +16,15 @@ window.__ModuleLoader__.load({
|
|
|
16
16
|
|
|
17
17
|
const NS = 'workspaceMemory'
|
|
18
18
|
const PANEL_ID = 'workspace-memory'
|
|
19
|
+
/** Selector value that addresses the cross-workspace global scope. */
|
|
20
|
+
const GLOBAL_ID = '__global__'
|
|
19
21
|
|
|
20
22
|
const en = {
|
|
21
23
|
'panel.title': 'Workspace Memory',
|
|
22
24
|
'panel.subtitle': 'Per-workspace memory files, stored in the DSH data directory by default — outside the project folder, so server configs and other machine-local notes never get committed. Injected into every session of the workspace.',
|
|
23
|
-
'workspace.label': '
|
|
25
|
+
'workspace.label': 'Memory scope',
|
|
24
26
|
'workspace.none': 'No workspace is registered yet.',
|
|
27
|
+
'workspace.global': '🌐 Global — every workspace',
|
|
25
28
|
'action.refresh': 'Refresh',
|
|
26
29
|
'action.new': 'New file',
|
|
27
30
|
'action.save': 'Save',
|
|
@@ -49,8 +52,9 @@ window.__ModuleLoader__.load({
|
|
|
49
52
|
const zh = {
|
|
50
53
|
'panel.title': '工作区记忆',
|
|
51
54
|
'panel.subtitle': '每个工作区独立的记忆文件,默认保存在 DSH 数据目录、不进入项目文件夹——服务器配置等本机敏感信息不会被误提交。自动注入该工作区每个会话的上下文。',
|
|
52
|
-
'workspace.label': '
|
|
55
|
+
'workspace.label': '记忆范围',
|
|
53
56
|
'workspace.none': '尚未注册任何工作区。',
|
|
57
|
+
'workspace.global': '🌐 全局(所有工作区通用)',
|
|
54
58
|
'action.refresh': '刷新',
|
|
55
59
|
'action.new': '新建文件',
|
|
56
60
|
'action.save': '保存',
|
|
@@ -256,8 +260,10 @@ window.__ModuleLoader__.load({
|
|
|
256
260
|
const [busy, setBusy] = useState(false)
|
|
257
261
|
|
|
258
262
|
// Default the selector to the Workspace of the Session the main view
|
|
259
|
-
// retains, falling back to the first listed Workspace.
|
|
263
|
+
// retains, falling back to the first listed Workspace. The reserved
|
|
264
|
+
// global scope always stays selected once chosen.
|
|
260
265
|
useEffect(() => {
|
|
266
|
+
if (workspaceId === GLOBAL_ID) return
|
|
261
267
|
if (workspaces.length === 0) {
|
|
262
268
|
if (workspaceId !== null) setWorkspaceId(null)
|
|
263
269
|
return
|
|
@@ -284,7 +290,9 @@ window.__ModuleLoader__.load({
|
|
|
284
290
|
}
|
|
285
291
|
if (!options.quiet) setFilesState((previous) => ({ ...previous, status: 'loading' }))
|
|
286
292
|
try {
|
|
287
|
-
const
|
|
293
|
+
const query = { workspaceId: id }
|
|
294
|
+
if (id === GLOBAL_ID) query.scope = 'global'
|
|
295
|
+
const value = await api('list', { query })
|
|
288
296
|
const files = Array.isArray(value.files) ? value.files : []
|
|
289
297
|
setFilesState({ status: 'ready', files })
|
|
290
298
|
return files
|
|
@@ -300,7 +308,9 @@ window.__ModuleLoader__.load({
|
|
|
300
308
|
setNotice(null)
|
|
301
309
|
setConfirmingDelete(false)
|
|
302
310
|
try {
|
|
303
|
-
const
|
|
311
|
+
const query = { workspaceId, name }
|
|
312
|
+
if (workspaceId === GLOBAL_ID) query.scope = 'global'
|
|
313
|
+
const value = await api('read', { query })
|
|
304
314
|
setSelected(name)
|
|
305
315
|
setContent(value.content)
|
|
306
316
|
setSavedContent(value.content)
|
|
@@ -330,7 +340,9 @@ window.__ModuleLoader__.load({
|
|
|
330
340
|
setBusy(true)
|
|
331
341
|
setNotice(null)
|
|
332
342
|
try {
|
|
333
|
-
|
|
343
|
+
const body = { workspaceId, name: selected, content }
|
|
344
|
+
if (workspaceId === GLOBAL_ID) body.scope = 'global'
|
|
345
|
+
await api('write', { body })
|
|
334
346
|
setSavedContent(content)
|
|
335
347
|
setNotice({ kind: 'ok', text: t('notice.saved') })
|
|
336
348
|
await refresh(workspaceId, { quiet: true })
|
|
@@ -352,7 +364,9 @@ window.__ModuleLoader__.load({
|
|
|
352
364
|
setBusy(true)
|
|
353
365
|
setNotice(null)
|
|
354
366
|
try {
|
|
355
|
-
|
|
367
|
+
const body = { workspaceId, name, content: '' }
|
|
368
|
+
if (workspaceId === GLOBAL_ID) body.scope = 'global'
|
|
369
|
+
await api('write', { body })
|
|
356
370
|
setCreating(false)
|
|
357
371
|
setNewName('')
|
|
358
372
|
setNotice({ kind: 'ok', text: t('notice.created') })
|
|
@@ -374,7 +388,9 @@ window.__ModuleLoader__.load({
|
|
|
374
388
|
setBusy(true)
|
|
375
389
|
setNotice(null)
|
|
376
390
|
try {
|
|
377
|
-
|
|
391
|
+
const body = { workspaceId, name: selected }
|
|
392
|
+
if (workspaceId === GLOBAL_ID) body.scope = 'global'
|
|
393
|
+
await api('delete', { body })
|
|
378
394
|
setSelected(null)
|
|
379
395
|
setContent('')
|
|
380
396
|
setSavedContent('')
|
|
@@ -493,9 +509,10 @@ window.__ModuleLoader__.load({
|
|
|
493
509
|
id: 'dwm-workspace',
|
|
494
510
|
className: 'dwm-select',
|
|
495
511
|
value: workspaceId || '',
|
|
496
|
-
disabled: workspaces.length === 0,
|
|
512
|
+
disabled: workspaces.length === 0 && workspaceId !== GLOBAL_ID,
|
|
497
513
|
onChange: (event) => setWorkspaceId(event.target.value || null),
|
|
498
514
|
},
|
|
515
|
+
h('option', { value: GLOBAL_ID }, t('workspace.global')),
|
|
499
516
|
workspaces.length === 0 && h('option', { value: '' }, t('workspace.none')),
|
|
500
517
|
workspaces.map((item) => h('option', { key: item.workspaceId, value: item.workspaceId },
|
|
501
518
|
item.title && item.title.trim() !== '' ? `${item.title} — ${item.path}` : item.path)),
|
package/index.js
CHANGED
|
@@ -14,16 +14,18 @@
|
|
|
14
14
|
* Three contributions make the memory real:
|
|
15
15
|
*
|
|
16
16
|
* 1. Panel API — exact Fetch routes on Connection's shared `/api` channel so
|
|
17
|
-
* the browser panel can list, read, create, update, and delete the files
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
17
|
+
* the browser panel can list, read, create, update, and delete the files.
|
|
18
|
+
* Every route accepts an optional `scope=global` field addressing the
|
|
19
|
+
* cross-workspace memory directory (`<storageDir>/_global/`):
|
|
20
|
+
* GET /api/workspace-memory/list?workspaceId=<id>[&scope=global]
|
|
21
|
+
* GET /api/workspace-memory/read?workspaceId=<id>[&scope=global]&name=<file>
|
|
22
|
+
* POST /api/workspace-memory/write { workspaceId, [scope], name, content }
|
|
23
|
+
* POST /api/workspace-memory/delete { workspaceId, [scope], name }
|
|
24
|
+
* 2. Prompt injection — an `agent/created` listener registers two per-agent
|
|
25
|
+
* system-prompt sections: `global-memory` (every workspace) followed by
|
|
26
|
+
* `workspace-memory` (this directory).
|
|
25
27
|
* 3. Agent tool — `save_workspace_memory` lets the model persist a memory
|
|
26
|
-
* file itself
|
|
28
|
+
* file itself, scoped to the workspace or globally.
|
|
27
29
|
*
|
|
28
30
|
* Route responses use one envelope: `{ ok: true, value }` or
|
|
29
31
|
* `{ ok: false, error: { code, message } }`. The panel addresses memory only
|
|
@@ -47,6 +49,29 @@ const MAX_CONTENT_BYTES = 1024 * 1024
|
|
|
47
49
|
const PROMPT_MAX_FILES = 20
|
|
48
50
|
const PROMPT_MAX_FILE_CHARS = 32768
|
|
49
51
|
const PROMPT_SECTION_ORDER = 9800
|
|
52
|
+
const GLOBAL_SECTION_ORDER = 9700
|
|
53
|
+
/** Selector value the panel uses for the cross-workspace scope. */
|
|
54
|
+
const GLOBAL_WORKSPACE_ID = '__global__'
|
|
55
|
+
/** Reserved directory name under the storage root; workspace keys always carry a `-<hash>` suffix, so this cannot collide. */
|
|
56
|
+
const GLOBAL_DIR_NAME = '_global'
|
|
57
|
+
const GLOBAL_SEED_FILE = 'preferences.md'
|
|
58
|
+
/** Starter template for the global scope: persona, style, communication, and formatting hints in one file. */
|
|
59
|
+
export const GLOBAL_SEED_CONTENT = `# 全局偏好 / Global Preferences
|
|
60
|
+
<!-- 全局记忆对每一个工作区生效,适合放人格、风格、沟通、格式这类跨项目的个人偏好。
|
|
61
|
+
工作区记忆优先于本文件;只填你在乎的项,其余留空即可。 -->
|
|
62
|
+
|
|
63
|
+
## 人格 / Persona
|
|
64
|
+
<!-- 你是谁、希望 AI 以什么身份/视角与你交流(例如:资深搭档、严格评审、耐心的老师) -->
|
|
65
|
+
|
|
66
|
+
## 风格 / Style
|
|
67
|
+
<!-- 语言风格:正式还是口语、中文还是英文优先、技术深度、是否允许玩笑 -->
|
|
68
|
+
|
|
69
|
+
## 沟通 / Communication
|
|
70
|
+
<!-- 亲切程度、热情程度、emoji 使用、回复详细还是简洁 -->
|
|
71
|
+
|
|
72
|
+
## 格式 / Formatting
|
|
73
|
+
<!-- 回答格式:要不要标题和小标题、列表、表格、代码块、可视化/图示 -->
|
|
74
|
+
`
|
|
50
75
|
const INVALID_NAME_CHARS = /[\s/\\:*?"<>|]/
|
|
51
76
|
const CONTROL_CHARS = /[\x00-\x1f\x7f]/
|
|
52
77
|
|
|
@@ -81,6 +106,30 @@ export function apply(ctx, config = {}) {
|
|
|
81
106
|
? path.resolve(config.storageDir.trim())
|
|
82
107
|
: path.join(os.homedir(), '.dsh', 'workspace-memory')
|
|
83
108
|
|
|
109
|
+
/** Absolute directory holding the cross-workspace global memory files. */
|
|
110
|
+
const globalDir = path.join(storageDir, GLOBAL_DIR_NAME)
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Seed the global memory directory with one starter template. Runs only
|
|
114
|
+
* while the directory does not exist yet; an existing directory (even an
|
|
115
|
+
* emptied one) is never rewritten, so user edits always win. Concurrent
|
|
116
|
+
* callers share one attempt, so an activation-time seed and a first list
|
|
117
|
+
* cannot race each other into a half-seeded state.
|
|
118
|
+
*/
|
|
119
|
+
let globalSeed = null
|
|
120
|
+
async function seedGlobalMemory() {
|
|
121
|
+
globalSeed ??= (async () => {
|
|
122
|
+
try {
|
|
123
|
+
await fs.mkdir(globalDir, { recursive: false })
|
|
124
|
+
} catch {
|
|
125
|
+
return // already exists — seeding is one-shot
|
|
126
|
+
}
|
|
127
|
+
await fs.writeFile(path.join(globalDir, GLOBAL_SEED_FILE), GLOBAL_SEED_CONTENT, 'utf8')
|
|
128
|
+
})().catch(() => { /* seeding must never break the caller */ })
|
|
129
|
+
return globalSeed
|
|
130
|
+
}
|
|
131
|
+
void seedGlobalMemory()
|
|
132
|
+
|
|
84
133
|
// ---------------------------------------------------------------------------
|
|
85
134
|
// Shared memory-file helpers
|
|
86
135
|
// ---------------------------------------------------------------------------
|
|
@@ -199,15 +248,15 @@ export function apply(ctx, config = {}) {
|
|
|
199
248
|
const memoryCaches = new Map()
|
|
200
249
|
|
|
201
250
|
/**
|
|
202
|
-
* Render
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* negligible per model step.
|
|
251
|
+
* Render one memory section for a memory directory, re-reading file contents
|
|
252
|
+
* only when the directory signature (names, sizes, mtimes) changed. Runs
|
|
253
|
+
* synchronously because prompt-section providers are synchronous; the stat
|
|
254
|
+
* round-trip over a handful of small files is negligible per model step.
|
|
207
255
|
* @param {string} dir - absolute memory directory.
|
|
256
|
+
* @param {'workspace' | 'global'} kind - which scope this section renders.
|
|
208
257
|
* @returns {string} section text, empty when no memory exists.
|
|
209
258
|
*/
|
|
210
|
-
function buildMemoryText(dir) {
|
|
259
|
+
function buildMemoryText(dir, kind) {
|
|
211
260
|
let signature = 'missing'
|
|
212
261
|
const files = []
|
|
213
262
|
try {
|
|
@@ -224,14 +273,11 @@ export function apply(ctx, config = {}) {
|
|
|
224
273
|
|
|
225
274
|
let text = ''
|
|
226
275
|
if (files.length > 0) {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
''
|
|
230
|
-
'This workspace has durable memory files, one Markdown file per topic, managed from the "Workspace Memory" sidebar panel. '
|
|
231
|
-
|
|
232
|
-
+ 'Treat them as trusted project memory alongside AGENTS.md. When the user asks you to remember a project decision, convention, server detail, or preference, persist it with the save_workspace_memory tool.',
|
|
233
|
-
'',
|
|
234
|
-
]
|
|
276
|
+
const heading = kind === 'global' ? '## Global Memory' : '## Workspace Memory'
|
|
277
|
+
const intro = kind === 'global'
|
|
278
|
+
? 'These global memory files apply to EVERY workspace — cross-project persona, style, and preferences. They are managed from the "Workspace Memory" sidebar panel (the 全局/Global scope) and stored in the DSH data directory. Workspace memory, when present, takes precedence over these global notes for project-specific matters. When the user asks you to remember a personal preference that should follow them across projects, persist it here with the save_workspace_memory tool and scope "global".'
|
|
279
|
+
: 'This workspace has durable memory files, one Markdown file per topic, managed from the "Workspace Memory" sidebar panel. By default they live in the DSH data directory OUTSIDE the project tree, so machine-local details (server configurations, environment quirks, personal conventions) never land in the repository. Treat them as trusted project memory alongside AGENTS.md. When the user asks you to remember a project decision, convention, server detail, or preference, persist it with the save_workspace_memory tool.'
|
|
280
|
+
const parts = [heading, '', intro, '']
|
|
235
281
|
for (const file of files.slice(0, PROMPT_MAX_FILES)) {
|
|
236
282
|
let content
|
|
237
283
|
try {
|
|
@@ -258,11 +304,17 @@ export function apply(ctx, config = {}) {
|
|
|
258
304
|
// ---------------------------------------------------------------------------
|
|
259
305
|
|
|
260
306
|
/**
|
|
261
|
-
* Resolve the fenced memory directory for one
|
|
262
|
-
* @param {string | null} workspaceId - registry workspace id from the client
|
|
263
|
-
*
|
|
307
|
+
* Resolve the fenced memory directory for one scope.
|
|
308
|
+
* @param {string | null} workspaceId - registry workspace id from the client
|
|
309
|
+
* (ignored for the global scope).
|
|
310
|
+
* @param {unknown} scope - 'global' for the cross-workspace scope; anything
|
|
311
|
+
* else (including undefined) keeps the original per-workspace behavior.
|
|
312
|
+
* @returns {{ dir: string, display: string, scope: 'workspace' | 'global' } | { error: Response }}
|
|
264
313
|
*/
|
|
265
|
-
function resolveMemoryDir(workspaceId) {
|
|
314
|
+
function resolveMemoryDir(workspaceId, scope) {
|
|
315
|
+
if (scope === 'global' || workspaceId === GLOBAL_WORKSPACE_ID) {
|
|
316
|
+
return { dir: globalDir, display: globalDir, scope: 'global' }
|
|
317
|
+
}
|
|
266
318
|
if (typeof workspaceId !== 'string' || workspaceId === '') {
|
|
267
319
|
return { error: fail(400, 'workspace-memory/workspace-required', 'A workspaceId query or body field is required.') }
|
|
268
320
|
}
|
|
@@ -270,19 +322,22 @@ export function apply(ctx, config = {}) {
|
|
|
270
322
|
if (workspace === undefined) {
|
|
271
323
|
return { error: fail(404, 'workspace-memory/unknown-workspace', `Unknown workspace: ${workspaceId}`) }
|
|
272
324
|
}
|
|
273
|
-
|
|
325
|
+
const resolved = memoryDirFor(path.resolve(workspace.path))
|
|
326
|
+
if (resolved.error) return resolved
|
|
327
|
+
return { ...resolved, scope: 'workspace' }
|
|
274
328
|
}
|
|
275
329
|
|
|
276
330
|
async function handleList(request) {
|
|
277
|
-
const
|
|
278
|
-
const resolved = resolveMemoryDir(workspaceId)
|
|
331
|
+
const params = new URL(request.url).searchParams
|
|
332
|
+
const resolved = resolveMemoryDir(params.get('workspaceId'), params.get('scope'))
|
|
279
333
|
if (resolved.error) return resolved.error
|
|
280
|
-
|
|
334
|
+
if (resolved.scope === 'global') await seedGlobalMemory().catch(() => {})
|
|
335
|
+
return ok({ directory: resolved.display, storage, scope: resolved.scope, files: await listFiles(resolved.dir) })
|
|
281
336
|
}
|
|
282
337
|
|
|
283
338
|
async function handleRead(request) {
|
|
284
339
|
const params = new URL(request.url).searchParams
|
|
285
|
-
const resolved = resolveMemoryDir(params.get('workspaceId'))
|
|
340
|
+
const resolved = resolveMemoryDir(params.get('workspaceId'), params.get('scope'))
|
|
286
341
|
if (resolved.error) return resolved.error
|
|
287
342
|
const named = validateName(params.get('name'))
|
|
288
343
|
if (named.error) return named.error
|
|
@@ -303,7 +358,7 @@ export function apply(ctx, config = {}) {
|
|
|
303
358
|
if (typeof body !== 'object' || body === null) {
|
|
304
359
|
return fail(400, 'workspace-memory/bad-request', 'A JSON body is required.')
|
|
305
360
|
}
|
|
306
|
-
const resolved = resolveMemoryDir(body.workspaceId)
|
|
361
|
+
const resolved = resolveMemoryDir(body.workspaceId, body.scope)
|
|
307
362
|
if (resolved.error) return resolved.error
|
|
308
363
|
const named = validateName(body.name)
|
|
309
364
|
if (named.error) return named.error
|
|
@@ -324,7 +379,7 @@ export function apply(ctx, config = {}) {
|
|
|
324
379
|
if (typeof body !== 'object' || body === null) {
|
|
325
380
|
return fail(400, 'workspace-memory/bad-request', 'A JSON body is required.')
|
|
326
381
|
}
|
|
327
|
-
const resolved = resolveMemoryDir(body.workspaceId)
|
|
382
|
+
const resolved = resolveMemoryDir(body.workspaceId, body.scope)
|
|
328
383
|
if (resolved.error) return resolved.error
|
|
329
384
|
const named = validateName(body.name)
|
|
330
385
|
if (named.error) return named.error
|
|
@@ -386,6 +441,18 @@ export function apply(ctx, config = {}) {
|
|
|
386
441
|
|
|
387
442
|
ctx.on('agent/created', ({ agent }) => {
|
|
388
443
|
// This listener runs inside agent creation; it must never reject.
|
|
444
|
+
try {
|
|
445
|
+
// Global memory follows the user into every workspace and sorts before
|
|
446
|
+
// the workspace section, so project-specific notes read as overrides.
|
|
447
|
+
const globalDispose = agent.ctx.systemPrompt.section({
|
|
448
|
+
name: 'global-memory',
|
|
449
|
+
order: GLOBAL_SECTION_ORDER,
|
|
450
|
+
// Same rationale as the workspace section: user prose stays literal.
|
|
451
|
+
interpolate: false,
|
|
452
|
+
text: () => buildMemoryText(globalDir, 'global'),
|
|
453
|
+
})
|
|
454
|
+
agentRegistrations.add(globalDispose)
|
|
455
|
+
} catch { /* the agent may already be disposed; its context rejects registration */ }
|
|
389
456
|
try {
|
|
390
457
|
const cwd = agentCwd(agent)
|
|
391
458
|
if (typeof cwd !== 'string' || cwd === '') return
|
|
@@ -402,7 +469,7 @@ export function apply(ctx, config = {}) {
|
|
|
402
469
|
// literal text, not a prompt variable reference. Opting this section out of
|
|
403
470
|
// interpolation keeps such notes from failing prompt assembly.
|
|
404
471
|
interpolate: false,
|
|
405
|
-
text: () => buildMemoryText(dir),
|
|
472
|
+
text: () => buildMemoryText(dir, 'workspace'),
|
|
406
473
|
})
|
|
407
474
|
agentRegistrations.add(dispose)
|
|
408
475
|
} catch { /* the agent may already be disposed; its context rejects registration */ }
|
|
@@ -417,10 +484,11 @@ export function apply(ctx, config = {}) {
|
|
|
417
484
|
ctx.effect(() => ctx.tools.register({
|
|
418
485
|
name: 'save_workspace_memory',
|
|
419
486
|
description:
|
|
420
|
-
'Save durable memory
|
|
487
|
+
'Save durable memory as a Markdown file. '
|
|
488
|
+
+ 'By default the memory is scoped to this workspace and injected into every session working in it; with scope "global" it applies to every workspace instead (persona, style, and cross-project preferences belong there). '
|
|
421
489
|
+ 'Memories are stored outside the project directory by default (in the DSH data directory), so machine-local details such as server configurations never enter the repository. '
|
|
422
490
|
+ 'Use it when the user asks you to remember a project decision, convention, server detail, preference, or fact across sessions. '
|
|
423
|
-
+ 'Memory files
|
|
491
|
+
+ 'Memory files can be reviewed in the Workspace Memory sidebar panel.',
|
|
424
492
|
parameters: {
|
|
425
493
|
type: 'object',
|
|
426
494
|
properties: {
|
|
@@ -432,6 +500,11 @@ export function apply(ctx, config = {}) {
|
|
|
432
500
|
type: 'string',
|
|
433
501
|
description: 'Complete Markdown content to store; overwrites the existing file of the same name.',
|
|
434
502
|
},
|
|
503
|
+
scope: {
|
|
504
|
+
type: 'string',
|
|
505
|
+
enum: ['workspace', 'global'],
|
|
506
|
+
description: 'Which memory scope to write: "workspace" (default, this workspace only) or "global" (every workspace).',
|
|
507
|
+
},
|
|
435
508
|
},
|
|
436
509
|
required: ['name', 'content'],
|
|
437
510
|
additionalProperties: false,
|
|
@@ -441,10 +514,7 @@ export function apply(ctx, config = {}) {
|
|
|
441
514
|
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
442
515
|
},
|
|
443
516
|
async execute(args, exec) {
|
|
444
|
-
const
|
|
445
|
-
if (typeof cwd !== 'string' || cwd === '') {
|
|
446
|
-
throw new Error('This session has no working directory, so there is no workspace to remember for.')
|
|
447
|
-
}
|
|
517
|
+
const scope = args?.scope === 'global' ? 'global' : 'workspace'
|
|
448
518
|
const name = normalizeToolName(args?.name)
|
|
449
519
|
if (name === null) {
|
|
450
520
|
throw new Error(`Invalid memory file name ${JSON.stringify(args?.name)}: use letters, digits, dot, underscore or hyphen.`)
|
|
@@ -455,12 +525,25 @@ export function apply(ctx, config = {}) {
|
|
|
455
525
|
if (Buffer.byteLength(args.content, 'utf8') > MAX_CONTENT_BYTES) {
|
|
456
526
|
throw new Error(`Memory content exceeds ${MAX_CONTENT_BYTES} bytes.`)
|
|
457
527
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
528
|
+
let dir
|
|
529
|
+
if (scope === 'global') {
|
|
530
|
+
await seedGlobalMemory().catch(() => {})
|
|
531
|
+
dir = globalDir
|
|
532
|
+
} else {
|
|
533
|
+
const cwd = agentCwd(exec.agent)
|
|
534
|
+
if (typeof cwd !== 'string' || cwd === '') {
|
|
535
|
+
throw new Error('This session has no working directory, so there is no workspace to remember for.')
|
|
536
|
+
}
|
|
537
|
+
const root = await resolveRoot(cwd)
|
|
538
|
+
const memory = memoryDirFor(root)
|
|
539
|
+
if (memory.error) throw new Error('The configured memory directory escapes the workspace.')
|
|
540
|
+
dir = memory.dir
|
|
541
|
+
}
|
|
542
|
+
await fs.mkdir(dir, { recursive: true })
|
|
543
|
+
await fs.writeFile(path.join(dir, name), args.content, 'utf8')
|
|
544
|
+
return scope === 'global'
|
|
545
|
+
? `Saved global memory "${name}"; every session in every workspace now sees it.`
|
|
546
|
+
: `Saved workspace memory "${name}"; every session in this workspace now sees it.`
|
|
464
547
|
},
|
|
465
548
|
}), 'workspace-memory: save tool')
|
|
466
549
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-workspace-memories",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Per-workspace persistent memory for DeepSeek Harness: a sidebar panel to manage Markdown memory files, automatic injection into every session of the workspace, and an agent tool to save new memories",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|