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 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 in the workspace gets its memory files injected into its system prompt |
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, one subdirectory per workspace
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': 'Workspace',
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 value = await api('list', { query: { workspaceId: id } })
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 value = await api('read', { query: { workspaceId, name } })
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
- await api('write', { body: { workspaceId, name: selected, content } })
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
- await api('write', { body: { workspaceId, name, content: '' } })
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
- await api('delete', { body: { workspaceId, name: selected } })
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
- * GET /api/workspace-memory/list?workspaceId=<id>
19
- * GET /api/workspace-memory/read?workspaceId=<id>&name=<file>
20
- * POST /api/workspace-memory/write { workspaceId, name, content }
21
- * POST /api/workspace-memory/delete { workspaceId, name }
22
- * 2. Prompt injection — an `agent/created` listener registers a per-agent
23
- * system-prompt section that renders the workspace's current memory files
24
- * into every session running in that directory.
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 when the user asks it to remember something.
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 the workspace memory section for one memory directory, re-reading
203
- * file contents only when the directory signature (names, sizes, mtimes)
204
- * changed. Runs synchronously because prompt-section providers are
205
- * synchronous; the stat round-trip over a handful of small files is
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 parts = [
228
- '## Workspace Memory',
229
- '',
230
- 'This workspace has durable memory files, one Markdown file per topic, managed from the "Workspace Memory" sidebar panel. '
231
- + '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. '
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 registered workspace.
262
- * @param {string | null} workspaceId - registry workspace id from the client.
263
- * @returns {{ dir: string, display: string } | { error: Response }}
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
- return memoryDirFor(path.resolve(workspace.path))
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 workspaceId = new URL(request.url).searchParams.get('workspaceId')
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
- return ok({ directory: resolved.display, storage, files: await listFiles(resolved.dir) })
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 for this workspace as a Markdown file. '
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 are injected into every session working in this workspace and can be reviewed in the Workspace Memory sidebar panel.',
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 cwd = agentCwd(exec.agent)
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
- const root = await resolveRoot(cwd)
459
- const memory = memoryDirFor(root)
460
- if (memory.error) throw new Error('The configured memory directory escapes the workspace.')
461
- await fs.mkdir(memory.dir, { recursive: true })
462
- await fs.writeFile(path.join(memory.dir, name), args.content, 'utf8')
463
- return `Saved workspace memory "${name}"; every session in this workspace now sees it.`
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.1.3",
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",