dsh-workspace-memories 1.1.3 → 1.2.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/README.md +10 -4
- package/README.zh.md +7 -3
- package/client.js +26 -9
- package/index.js +131 -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,30 @@ 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
|
+
懒得自己填?在任意会话对 Agent 说「帮我配置全局偏好」,它会逐个问你几个问题并自动写好。 -->
|
|
63
|
+
|
|
64
|
+
## 人格 / Persona
|
|
65
|
+
<!-- 你是谁、希望 AI 以什么身份/视角与你交流(例如:资深搭档、严格评审、耐心的老师) -->
|
|
66
|
+
|
|
67
|
+
## 风格 / Style
|
|
68
|
+
<!-- 语言风格:正式还是口语、中文还是英文优先、技术深度、是否允许玩笑 -->
|
|
69
|
+
|
|
70
|
+
## 沟通 / Communication
|
|
71
|
+
<!-- 亲切程度、热情程度、emoji 使用、回复详细还是简洁 -->
|
|
72
|
+
|
|
73
|
+
## 格式 / Formatting
|
|
74
|
+
<!-- 回答格式:要不要标题和小标题、列表、表格、代码块、可视化/图示 -->
|
|
75
|
+
`
|
|
50
76
|
const INVALID_NAME_CHARS = /[\s/\\:*?"<>|]/
|
|
51
77
|
const CONTROL_CHARS = /[\x00-\x1f\x7f]/
|
|
52
78
|
|
|
@@ -81,6 +107,30 @@ export function apply(ctx, config = {}) {
|
|
|
81
107
|
? path.resolve(config.storageDir.trim())
|
|
82
108
|
: path.join(os.homedir(), '.dsh', 'workspace-memory')
|
|
83
109
|
|
|
110
|
+
/** Absolute directory holding the cross-workspace global memory files. */
|
|
111
|
+
const globalDir = path.join(storageDir, GLOBAL_DIR_NAME)
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Seed the global memory directory with one starter template. Runs only
|
|
115
|
+
* while the directory does not exist yet; an existing directory (even an
|
|
116
|
+
* emptied one) is never rewritten, so user edits always win. Concurrent
|
|
117
|
+
* callers share one attempt, so an activation-time seed and a first list
|
|
118
|
+
* cannot race each other into a half-seeded state.
|
|
119
|
+
*/
|
|
120
|
+
let globalSeed = null
|
|
121
|
+
async function seedGlobalMemory() {
|
|
122
|
+
globalSeed ??= (async () => {
|
|
123
|
+
try {
|
|
124
|
+
await fs.mkdir(globalDir, { recursive: false })
|
|
125
|
+
} catch {
|
|
126
|
+
return // already exists — seeding is one-shot
|
|
127
|
+
}
|
|
128
|
+
await fs.writeFile(path.join(globalDir, GLOBAL_SEED_FILE), GLOBAL_SEED_CONTENT, 'utf8')
|
|
129
|
+
})().catch(() => { /* seeding must never break the caller */ })
|
|
130
|
+
return globalSeed
|
|
131
|
+
}
|
|
132
|
+
void seedGlobalMemory()
|
|
133
|
+
|
|
84
134
|
// ---------------------------------------------------------------------------
|
|
85
135
|
// Shared memory-file helpers
|
|
86
136
|
// ---------------------------------------------------------------------------
|
|
@@ -199,15 +249,15 @@ export function apply(ctx, config = {}) {
|
|
|
199
249
|
const memoryCaches = new Map()
|
|
200
250
|
|
|
201
251
|
/**
|
|
202
|
-
* Render
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* negligible per model step.
|
|
252
|
+
* Render one memory section for a memory directory, re-reading file contents
|
|
253
|
+
* only when the directory signature (names, sizes, mtimes) changed. Runs
|
|
254
|
+
* synchronously because prompt-section providers are synchronous; the stat
|
|
255
|
+
* round-trip over a handful of small files is negligible per model step.
|
|
207
256
|
* @param {string} dir - absolute memory directory.
|
|
257
|
+
* @param {'workspace' | 'global'} kind - which scope this section renders.
|
|
208
258
|
* @returns {string} section text, empty when no memory exists.
|
|
209
259
|
*/
|
|
210
|
-
function buildMemoryText(dir) {
|
|
260
|
+
function buildMemoryText(dir, kind) {
|
|
211
261
|
let signature = 'missing'
|
|
212
262
|
const files = []
|
|
213
263
|
try {
|
|
@@ -224,14 +274,11 @@ export function apply(ctx, config = {}) {
|
|
|
224
274
|
|
|
225
275
|
let text = ''
|
|
226
276
|
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
|
-
]
|
|
277
|
+
const heading = kind === 'global' ? '## Global Memory' : '## Workspace Memory'
|
|
278
|
+
const intro = kind === 'global'
|
|
279
|
+
? '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".'
|
|
280
|
+
: '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.'
|
|
281
|
+
const parts = [heading, '', intro, '']
|
|
235
282
|
for (const file of files.slice(0, PROMPT_MAX_FILES)) {
|
|
236
283
|
let content
|
|
237
284
|
try {
|
|
@@ -258,11 +305,17 @@ export function apply(ctx, config = {}) {
|
|
|
258
305
|
// ---------------------------------------------------------------------------
|
|
259
306
|
|
|
260
307
|
/**
|
|
261
|
-
* Resolve the fenced memory directory for one
|
|
262
|
-
* @param {string | null} workspaceId - registry workspace id from the client
|
|
263
|
-
*
|
|
308
|
+
* Resolve the fenced memory directory for one scope.
|
|
309
|
+
* @param {string | null} workspaceId - registry workspace id from the client
|
|
310
|
+
* (ignored for the global scope).
|
|
311
|
+
* @param {unknown} scope - 'global' for the cross-workspace scope; anything
|
|
312
|
+
* else (including undefined) keeps the original per-workspace behavior.
|
|
313
|
+
* @returns {{ dir: string, display: string, scope: 'workspace' | 'global' } | { error: Response }}
|
|
264
314
|
*/
|
|
265
|
-
function resolveMemoryDir(workspaceId) {
|
|
315
|
+
function resolveMemoryDir(workspaceId, scope) {
|
|
316
|
+
if (scope === 'global' || workspaceId === GLOBAL_WORKSPACE_ID) {
|
|
317
|
+
return { dir: globalDir, display: globalDir, scope: 'global' }
|
|
318
|
+
}
|
|
266
319
|
if (typeof workspaceId !== 'string' || workspaceId === '') {
|
|
267
320
|
return { error: fail(400, 'workspace-memory/workspace-required', 'A workspaceId query or body field is required.') }
|
|
268
321
|
}
|
|
@@ -270,19 +323,22 @@ export function apply(ctx, config = {}) {
|
|
|
270
323
|
if (workspace === undefined) {
|
|
271
324
|
return { error: fail(404, 'workspace-memory/unknown-workspace', `Unknown workspace: ${workspaceId}`) }
|
|
272
325
|
}
|
|
273
|
-
|
|
326
|
+
const resolved = memoryDirFor(path.resolve(workspace.path))
|
|
327
|
+
if (resolved.error) return resolved
|
|
328
|
+
return { ...resolved, scope: 'workspace' }
|
|
274
329
|
}
|
|
275
330
|
|
|
276
331
|
async function handleList(request) {
|
|
277
|
-
const
|
|
278
|
-
const resolved = resolveMemoryDir(workspaceId)
|
|
332
|
+
const params = new URL(request.url).searchParams
|
|
333
|
+
const resolved = resolveMemoryDir(params.get('workspaceId'), params.get('scope'))
|
|
279
334
|
if (resolved.error) return resolved.error
|
|
280
|
-
|
|
335
|
+
if (resolved.scope === 'global') await seedGlobalMemory().catch(() => {})
|
|
336
|
+
return ok({ directory: resolved.display, storage, scope: resolved.scope, files: await listFiles(resolved.dir) })
|
|
281
337
|
}
|
|
282
338
|
|
|
283
339
|
async function handleRead(request) {
|
|
284
340
|
const params = new URL(request.url).searchParams
|
|
285
|
-
const resolved = resolveMemoryDir(params.get('workspaceId'))
|
|
341
|
+
const resolved = resolveMemoryDir(params.get('workspaceId'), params.get('scope'))
|
|
286
342
|
if (resolved.error) return resolved.error
|
|
287
343
|
const named = validateName(params.get('name'))
|
|
288
344
|
if (named.error) return named.error
|
|
@@ -303,7 +359,7 @@ export function apply(ctx, config = {}) {
|
|
|
303
359
|
if (typeof body !== 'object' || body === null) {
|
|
304
360
|
return fail(400, 'workspace-memory/bad-request', 'A JSON body is required.')
|
|
305
361
|
}
|
|
306
|
-
const resolved = resolveMemoryDir(body.workspaceId)
|
|
362
|
+
const resolved = resolveMemoryDir(body.workspaceId, body.scope)
|
|
307
363
|
if (resolved.error) return resolved.error
|
|
308
364
|
const named = validateName(body.name)
|
|
309
365
|
if (named.error) return named.error
|
|
@@ -324,7 +380,7 @@ export function apply(ctx, config = {}) {
|
|
|
324
380
|
if (typeof body !== 'object' || body === null) {
|
|
325
381
|
return fail(400, 'workspace-memory/bad-request', 'A JSON body is required.')
|
|
326
382
|
}
|
|
327
|
-
const resolved = resolveMemoryDir(body.workspaceId)
|
|
383
|
+
const resolved = resolveMemoryDir(body.workspaceId, body.scope)
|
|
328
384
|
if (resolved.error) return resolved.error
|
|
329
385
|
const named = validateName(body.name)
|
|
330
386
|
if (named.error) return named.error
|
|
@@ -386,6 +442,18 @@ export function apply(ctx, config = {}) {
|
|
|
386
442
|
|
|
387
443
|
ctx.on('agent/created', ({ agent }) => {
|
|
388
444
|
// This listener runs inside agent creation; it must never reject.
|
|
445
|
+
try {
|
|
446
|
+
// Global memory follows the user into every workspace and sorts before
|
|
447
|
+
// the workspace section, so project-specific notes read as overrides.
|
|
448
|
+
const globalDispose = agent.ctx.systemPrompt.section({
|
|
449
|
+
name: 'global-memory',
|
|
450
|
+
order: GLOBAL_SECTION_ORDER,
|
|
451
|
+
// Same rationale as the workspace section: user prose stays literal.
|
|
452
|
+
interpolate: false,
|
|
453
|
+
text: () => buildMemoryText(globalDir, 'global'),
|
|
454
|
+
})
|
|
455
|
+
agentRegistrations.add(globalDispose)
|
|
456
|
+
} catch { /* the agent may already be disposed; its context rejects registration */ }
|
|
389
457
|
try {
|
|
390
458
|
const cwd = agentCwd(agent)
|
|
391
459
|
if (typeof cwd !== 'string' || cwd === '') return
|
|
@@ -402,7 +470,7 @@ export function apply(ctx, config = {}) {
|
|
|
402
470
|
// literal text, not a prompt variable reference. Opting this section out of
|
|
403
471
|
// interpolation keeps such notes from failing prompt assembly.
|
|
404
472
|
interpolate: false,
|
|
405
|
-
text: () => buildMemoryText(dir),
|
|
473
|
+
text: () => buildMemoryText(dir, 'workspace'),
|
|
406
474
|
})
|
|
407
475
|
agentRegistrations.add(dispose)
|
|
408
476
|
} catch { /* the agent may already be disposed; its context rejects registration */ }
|
|
@@ -417,10 +485,11 @@ export function apply(ctx, config = {}) {
|
|
|
417
485
|
ctx.effect(() => ctx.tools.register({
|
|
418
486
|
name: 'save_workspace_memory',
|
|
419
487
|
description:
|
|
420
|
-
'Save durable memory
|
|
488
|
+
'Save durable memory as a Markdown file. '
|
|
489
|
+
+ '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
490
|
+ '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
491
|
+ 'Use it when the user asks you to remember a project decision, convention, server detail, preference, or fact across sessions. '
|
|
423
|
-
+ 'Memory files
|
|
492
|
+
+ 'Memory files can be reviewed in the Workspace Memory sidebar panel.',
|
|
424
493
|
parameters: {
|
|
425
494
|
type: 'object',
|
|
426
495
|
properties: {
|
|
@@ -432,6 +501,11 @@ export function apply(ctx, config = {}) {
|
|
|
432
501
|
type: 'string',
|
|
433
502
|
description: 'Complete Markdown content to store; overwrites the existing file of the same name.',
|
|
434
503
|
},
|
|
504
|
+
scope: {
|
|
505
|
+
type: 'string',
|
|
506
|
+
enum: ['workspace', 'global'],
|
|
507
|
+
description: 'Which memory scope to write: "workspace" (default, this workspace only) or "global" (every workspace).',
|
|
508
|
+
},
|
|
435
509
|
},
|
|
436
510
|
required: ['name', 'content'],
|
|
437
511
|
additionalProperties: false,
|
|
@@ -441,10 +515,7 @@ export function apply(ctx, config = {}) {
|
|
|
441
515
|
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
442
516
|
},
|
|
443
517
|
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
|
-
}
|
|
518
|
+
const scope = args?.scope === 'global' ? 'global' : 'workspace'
|
|
448
519
|
const name = normalizeToolName(args?.name)
|
|
449
520
|
if (name === null) {
|
|
450
521
|
throw new Error(`Invalid memory file name ${JSON.stringify(args?.name)}: use letters, digits, dot, underscore or hyphen.`)
|
|
@@ -455,12 +526,25 @@ export function apply(ctx, config = {}) {
|
|
|
455
526
|
if (Buffer.byteLength(args.content, 'utf8') > MAX_CONTENT_BYTES) {
|
|
456
527
|
throw new Error(`Memory content exceeds ${MAX_CONTENT_BYTES} bytes.`)
|
|
457
528
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
529
|
+
let dir
|
|
530
|
+
if (scope === 'global') {
|
|
531
|
+
await seedGlobalMemory().catch(() => {})
|
|
532
|
+
dir = globalDir
|
|
533
|
+
} else {
|
|
534
|
+
const cwd = agentCwd(exec.agent)
|
|
535
|
+
if (typeof cwd !== 'string' || cwd === '') {
|
|
536
|
+
throw new Error('This session has no working directory, so there is no workspace to remember for.')
|
|
537
|
+
}
|
|
538
|
+
const root = await resolveRoot(cwd)
|
|
539
|
+
const memory = memoryDirFor(root)
|
|
540
|
+
if (memory.error) throw new Error('The configured memory directory escapes the workspace.')
|
|
541
|
+
dir = memory.dir
|
|
542
|
+
}
|
|
543
|
+
await fs.mkdir(dir, { recursive: true })
|
|
544
|
+
await fs.writeFile(path.join(dir, name), args.content, 'utf8')
|
|
545
|
+
return scope === 'global'
|
|
546
|
+
? `Saved global memory "${name}"; every session in every workspace now sees it.`
|
|
547
|
+
: `Saved workspace memory "${name}"; every session in this workspace now sees it.`
|
|
464
548
|
},
|
|
465
549
|
}), 'workspace-memory: save tool')
|
|
466
550
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-workspace-memories",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
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",
|