@max-null/dsh-memory 0.2.1 → 0.3.7

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.
@@ -0,0 +1,2 @@
1
+ export declare const inject: string[];
2
+ export declare function apply(ctx: unknown): void;
@@ -0,0 +1,283 @@
1
+ /**
2
+ * @max-null/dsh-memory browser half (0.3.3): the memory panel, moved home
3
+ * from dsh-ssid-panels (2026-08-19 用户: 记忆面板应跟随 dsh-memory)。
4
+ * Data goes straight through the Typert `remote.memory` surface
5
+ * (list/search/confirm/forget/setInjected/reload) — no /ssid/api bridge,
6
+ * so any profile with dsh-memory gets the panel. Hosted on better-sidebar
7
+ * as an optional peer (same pattern dsh-ssid-panels used); without it the
8
+ * client half registers nothing.
9
+ */
10
+ import { createElement, useEffect, useState } from 'react';
11
+ export const inject = ['slots', 'locale', 'remote', 'remote.memory'];
12
+ const STRINGS = {
13
+ zh: {
14
+ tabMemory: '记忆',
15
+ memorySearch: '搜索记忆…',
16
+ empty: '黑暗中未见灵光',
17
+ confirm: '确认',
18
+ forget: '删除',
19
+ groupPending: '待审核',
20
+ groupOnDemand: '已审核 · 按需',
21
+ groupInjected: '常驻注入',
22
+ injectSwitch: '常驻注入',
23
+ approveFirst: '审核通过后可常驻注入',
24
+ allNamespaces: '全部',
25
+ nsGlobal: '全局',
26
+ nsProject: '项目',
27
+ organizeMemory: '整理记忆',
28
+ confirmAll: '全部确认',
29
+ suggested: '待审核',
30
+ approved: '已审核',
31
+ refresh: '刷新',
32
+ },
33
+ en: {
34
+ tabMemory: 'Memory',
35
+ memorySearch: 'Search memory…',
36
+ empty: 'No spark in the dark',
37
+ confirm: 'Confirm',
38
+ forget: 'Forget',
39
+ groupPending: 'Pending review',
40
+ groupOnDemand: 'Approved · on demand',
41
+ groupInjected: 'Always injected',
42
+ injectSwitch: 'Inject every turn',
43
+ approveFirst: 'Approve to enable injection',
44
+ allNamespaces: 'All',
45
+ nsGlobal: 'Global',
46
+ nsProject: 'Project',
47
+ organizeMemory: 'Organize memory',
48
+ confirmAll: 'Approve all',
49
+ suggested: 'Suggested',
50
+ approved: 'Approved',
51
+ refresh: 'Refresh',
52
+ },
53
+ };
54
+ let localeId = 'zh';
55
+ const localeListeners = new Set();
56
+ function adoptLocale(id) {
57
+ const next = id === 'en' ? 'en' : 'zh';
58
+ if (next === localeId)
59
+ return;
60
+ localeId = next;
61
+ localeListeners.forEach(l => l());
62
+ }
63
+ function fmt(tpl, vars = {}) {
64
+ return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
65
+ }
66
+ function useT() {
67
+ const [id, setId] = useState(localeId);
68
+ useEffect(() => {
69
+ const l = () => { setId(localeId); };
70
+ localeListeners.add(l);
71
+ return () => { localeListeners.delete(l); };
72
+ }, []);
73
+ return (key, vars) => fmt(STRINGS[id][key] ?? STRINGS.zh[key], vars);
74
+ }
75
+ // ---- inline-styled primitives (no CSS build step) ----
76
+ const ssid = {
77
+ accent: '#4FC3F7',
78
+ wrap: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', overflowY: 'auto', height: '100%', boxSizing: 'border-box' },
79
+ card: {
80
+ background: 'var(--dsw-alias-bg-layer-1, #131a26)',
81
+ border: '1px solid var(--dsw-alias-border-l2, #1e2836)',
82
+ borderRadius: 10,
83
+ padding: '10px 12px',
84
+ },
85
+ title: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #67748a)', marginBottom: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
86
+ text: { fontSize: 12.5, color: 'var(--dsw-alias-label-primary, #d8e0ea)', lineHeight: 1.55 },
87
+ muted: { fontSize: 11, color: 'var(--dsw-alias-label-secondary, #67748a)' },
88
+ empty: { padding: '28px 12px', textAlign: 'center', fontSize: 12.5, color: 'var(--dsw-alias-label-secondary, #67748a)' },
89
+ btn: {
90
+ padding: '3px 12px', fontSize: 11.5, background: 'none',
91
+ border: '1px solid var(--dsw-alias-border-l2, #1e2836)', borderRadius: 6,
92
+ color: 'var(--dsw-alias-label-primary, #d8e0ea)', cursor: 'pointer',
93
+ },
94
+ };
95
+ // 预填指令(0.3.1+):过时内容用 memory_update 修正;工具面自查法。
96
+ const ORGANIZE_PROMPT = '请整理我的记忆库:用 memory_list 查看全部记忆,合并重复或可归并的条目,精简冗长内容,为每条补充或修正 keywords;对过时、错误或已变化的内容用 memory_update 修正(会重置为待审核),需要删除的用 memory_forget,需要新增的用 memory_save。判断内容是否过时的方法:把记忆里提到的工具名/数量与你当前实际可用的记忆工具对照——你当前可用:memory_save / memory_list / memory_search / memory_confirm / memory_forget / memory_update(共 6 个);若记忆中的工具列表、数量、流程与此不符即为过时,用 memory_update 修正。改动全部落在 suggested 等待审核(不要调用 memory_confirm),完成后用一句话汇报整理结果。';
97
+ function MemoryView(props) {
98
+ const t = useT();
99
+ const [records, setRecords] = useState([]);
100
+ const [query, setQuery] = useState('');
101
+ const [namespace, setNamespace] = useState(null);
102
+ const [refreshing, setRefreshing] = useState(false);
103
+ const [organizing, setOrganizing] = useState(false);
104
+ const reload = async () => {
105
+ try {
106
+ setRecords(await props.remote.list());
107
+ }
108
+ catch {
109
+ setRecords([]);
110
+ }
111
+ };
112
+ // 强制重读存储文件(JsonStorageBackend 无 watch——外部编辑后必须 reload)
113
+ const refreshFromDisk = async () => {
114
+ setRefreshing(true);
115
+ try {
116
+ const value = await props.remote.reload();
117
+ setRecords(value);
118
+ }
119
+ catch {
120
+ await reload();
121
+ }
122
+ finally {
123
+ setRefreshing(false);
124
+ }
125
+ };
126
+ useEffect(() => { if (props.visible)
127
+ void reload(); }, [props.visible]);
128
+ const toggleInjected = async (record) => {
129
+ if (record.status !== 'approved')
130
+ return;
131
+ try {
132
+ await props.remote.setInjected(record.id, !record.injected);
133
+ }
134
+ catch {
135
+ /* 失败保持原状态 */
136
+ }
137
+ await reload();
138
+ };
139
+ const confirmAll = async () => {
140
+ const pending = records.filter(record => record.status === 'suggested');
141
+ if (pending.length === 0)
142
+ return;
143
+ await Promise.all(pending.map(record => props.remote.confirm(record.id).catch(() => null)));
144
+ await reload();
145
+ };
146
+ // 一点即发整理:sessions.create → open → input 就绪(轮询 ≤5s)→ setDraft → submit。
147
+ // 机制实证自 dsh-better-sidebar conversation-draft.ts;sessions/conversation 软获取。
148
+ const organize = async () => {
149
+ if (organizing)
150
+ return;
151
+ setOrganizing(true);
152
+ try {
153
+ const sessions = props.ctx.get?.('sessions');
154
+ const conversation = props.ctx.get?.('conversation');
155
+ if (sessions === undefined || conversation?.input?.for === undefined) {
156
+ throw new Error('sessions/conversation unavailable');
157
+ }
158
+ const sessionId = await sessions.create({});
159
+ sessions.open(sessionId);
160
+ let input;
161
+ for (let i = 0; i < 50; i++) {
162
+ try {
163
+ const actx = sessions.scope(sessionId);
164
+ if (actx !== undefined) {
165
+ input = conversation.input.for(actx);
166
+ if (input !== undefined)
167
+ break;
168
+ }
169
+ }
170
+ catch {
171
+ /* not ready yet */
172
+ }
173
+ await new Promise((resolve) => setTimeout(resolve, 100));
174
+ }
175
+ if (input === undefined)
176
+ throw new Error('composer input not ready');
177
+ input.setDraft(ORGANIZE_PROMPT);
178
+ input.submit();
179
+ }
180
+ catch (error) {
181
+ console.warn('[dsh-memory] organize memory failed:', error);
182
+ }
183
+ finally {
184
+ setOrganizing(false);
185
+ }
186
+ };
187
+ const q = query.trim().toLowerCase();
188
+ const byNs = namespace === null ? records : records.filter(record => record.namespace === namespace);
189
+ const filtered = byNs.filter(record => q === '' || record.content.toLowerCase().includes(q));
190
+ const groups = [
191
+ { key: 'pending', label: t('groupPending'), items: filtered.filter(record => record.status === 'suggested') },
192
+ { key: 'ondemand', label: t('groupOnDemand'), items: filtered.filter(record => record.status === 'approved' && !record.injected) },
193
+ { key: 'injected', label: t('groupInjected'), items: filtered.filter(record => record.status === 'approved' && record.injected) },
194
+ ].filter(group => group.items.length > 0);
195
+ return createElement('div', { style: ssid.wrap }, createElement('div', { style: { display: 'flex', gap: 6 } }, createElement('button', {
196
+ type: 'button',
197
+ title: t('organizeMemory'),
198
+ onClick: () => { void organize(); },
199
+ disabled: organizing,
200
+ style: { ...ssid.btn, color: ssid.accent, borderColor: ssid.accent },
201
+ }, organizing ? '…' : t('organizeMemory')), createElement('input', {
202
+ value: query,
203
+ onChange: (event) => { setQuery(event.target.value); },
204
+ placeholder: t('memorySearch'),
205
+ style: {
206
+ flex: 1, padding: '6px 10px', fontSize: 12.5, boxSizing: 'border-box',
207
+ background: 'var(--dsw-alias-bg-layer-1, #0f141d)',
208
+ border: '1px solid var(--dsw-alias-border-l2, #1e2836)', borderRadius: 8,
209
+ color: 'var(--dsw-alias-label-primary, #d8e0ea)', outline: 'none',
210
+ },
211
+ }), createElement('button', {
212
+ type: 'button',
213
+ title: t('refresh'),
214
+ onClick: () => { void refreshFromDisk(); },
215
+ disabled: refreshing,
216
+ style: ssid.btn,
217
+ }, refreshing ? '…' : '↻')), createElement('div', { style: { display: 'flex', gap: 4 } }, [null, 'global', 'project'].map(ns => createElement('button', {
218
+ key: ns ?? 'all',
219
+ onClick: () => { setNamespace(ns); },
220
+ style: { flex: 1, ...ssid.btn, ...(namespace === ns ? { color: ssid.accent, borderColor: ssid.accent } : {}) },
221
+ }, ns === null ? t('allNamespaces') : ns === 'global' ? t('nsGlobal') : t('nsProject')))), groups.length === 0
222
+ ? createElement('div', { style: ssid.empty }, t('empty'))
223
+ : groups.map(group => createElement('div', { key: group.key, style: { display: 'flex', flexDirection: 'column', gap: 6 } }, createElement('div', { style: ssid.title }, createElement('span', null, group.label), createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } }, group.key === 'pending' && group.items.length > 0
224
+ ? createElement('button', {
225
+ type: 'button',
226
+ title: t('confirmAll'),
227
+ onClick: () => { void confirmAll(); },
228
+ style: { ...ssid.btn, padding: '1px 8px', fontSize: 10.5 },
229
+ }, t('confirmAll'))
230
+ : null, createElement('span', null, `${group.items.length}`))), group.items.map(record => createElement('div', { key: record.id, style: ssid.card }, createElement('div', { style: ssid.text }, record.content), createElement('div', { style: { ...ssid.muted, marginTop: 6 } }, `${record.namespace} · ${record.status === 'approved' ? t('approved') : t('suggested')}${record.injected ? ` · ${t('groupInjected')}` : ''}`), createElement('div', { style: { display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' } }, createElement('button', {
231
+ type: 'button',
232
+ title: record.status === 'approved' ? t('injectSwitch') : t('approveFirst'),
233
+ disabled: record.status !== 'approved',
234
+ onClick: () => { void toggleInjected(record); },
235
+ style: {
236
+ ...ssid.btn,
237
+ ...(record.injected ? { color: ssid.accent, borderColor: ssid.accent } : {}),
238
+ opacity: record.status !== 'approved' ? 0.4 : 1,
239
+ cursor: record.status !== 'approved' ? 'not-allowed' : 'pointer',
240
+ },
241
+ }, record.injected ? `✓ ${t('injectSwitch')}` : t('injectSwitch')), record.status === 'suggested'
242
+ ? createElement('button', {
243
+ style: ssid.btn,
244
+ onClick: () => { void props.remote.confirm(record.id).then(() => reload()); },
245
+ }, t('confirm'))
246
+ : null, createElement('button', {
247
+ style: ssid.btn,
248
+ onClick: () => { void props.remote.forget(record.id).then(() => reload()); },
249
+ }, t('forget'))))))));
250
+ }
251
+ // ---- client plugin body ----
252
+ export function apply(ctx) {
253
+ const face = ctx;
254
+ const locale = face.get?.('locale');
255
+ const initial = locale?.getLocale?.()?.active;
256
+ if (typeof initial === 'string')
257
+ adoptLocale(initial);
258
+ face.on?.('locale/change', (snap) => { adoptLocale(snap?.active); });
259
+ // better-sidebar 可选软依赖:无则面板不注册(host 侧无影响)。
260
+ const root = ctx;
261
+ if (root.inject === undefined)
262
+ return;
263
+ root.inject(['betterSidebar'], (sidebarCtx) => {
264
+ const service = sidebarCtx.betterSidebar;
265
+ if (service?.registerTab === undefined)
266
+ return;
267
+ const memory = ctx.remote?.memory;
268
+ if (memory === undefined)
269
+ return;
270
+ const tabCtx = ctx;
271
+ service.registerTab({
272
+ id: '@max-null/dsh-memory:memory',
273
+ title: () => STRINGS[localeId].tabMemory,
274
+ order: 60,
275
+ single: true,
276
+ component: ({ visible }) => createElement(MemoryView, {
277
+ visible,
278
+ remote: memory,
279
+ ctx: tabCtx,
280
+ }),
281
+ });
282
+ });
283
+ }
package/dist/engine.d.ts CHANGED
@@ -13,12 +13,17 @@ export type MemoryId = string & {
13
13
  /** Brand a string as a {@link MemoryId} (compile-time only). */
14
14
  export declare function MemoryId(id: string): MemoryId;
15
15
  export type MemoryNamespace = 'global' | 'project';
16
- /** `suggested` is model-written; only a human confirmation promotes it. */
17
- export type MemoryStatus = 'suggested' | 'auto' | 'suggest';
16
+ /**
17
+ * 审核维度(0.3.0):`suggested` 待审核(模型写入);`approved` 已人工
18
+ * 审核通过。注入与否由独立维度 `injected` 控制(见 MemoryRecord)。
19
+ */
20
+ export type MemoryStatus = 'suggested' | 'approved';
18
21
  export interface MemoryRecord {
19
22
  id: MemoryId;
20
23
  namespace: MemoryNamespace;
21
24
  status: MemoryStatus;
25
+ /** 注入维度:true = 每轮全量注入 system prompt(常驻);false = 仅检索。 */
26
+ injected: boolean;
22
27
  content: string;
23
28
  keywords: string[];
24
29
  createdAt: number;
@@ -32,6 +37,7 @@ export interface MemoryWrite {
32
37
  export interface MemoryFilter {
33
38
  namespace?: MemoryNamespace;
34
39
  status?: MemoryStatus;
40
+ injected?: boolean;
35
41
  }
36
42
  export interface MemoryHit {
37
43
  record: MemoryRecord;
@@ -48,6 +54,10 @@ export type MemoryChange = {
48
54
  operation: 'status';
49
55
  id: MemoryId;
50
56
  status: MemoryStatus;
57
+ } | {
58
+ operation: 'injected';
59
+ id: MemoryId;
60
+ injected: boolean;
51
61
  };
52
62
  declare module '@deepseek-ai/cordis' {
53
63
  interface Context {
@@ -67,30 +77,65 @@ export interface MemoryConfig {
67
77
  }
68
78
  /**
69
79
  * Cross-session plaintext memory over the storage hub, split by namespace:
70
- * `global` lives in the harness home, `project` in the project folder.
80
+ * `global` lives in the harness home, `project` in the session's workspace
81
+ * folder (`<workspace>/.dsh/storages`, follows the repository) — 0.3.4:
82
+ * project memory routes by the CALLER's workspace cwd (工具/面板按当前会话
83
+ * 工作区路由), not the process launch dir.
71
84
  */
72
85
  export declare class MemoryEngine extends Service {
73
86
  private readonly config;
74
87
  static inject: string[];
75
88
  private globalTable?;
76
- private projectTable?;
89
+ /** project 域按工作区 cwd 懒打开 + 缓存(多会话并发各工作区独立)。 */
90
+ private projectTables;
91
+ private projectFacilities;
92
+ /** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
93
+ private registeredProjectBackends;
77
94
  private facility?;
78
95
  constructor(ctx: import('@deepseek-ai/cordis').Context, config?: MemoryConfig);
79
96
  protected [Service.init](): Promise<void>;
80
- /** 打开两个存储域(init 与 reload 共用)。 */
81
- private openDomains;
97
+ /** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
98
+ private openGlobalFacility;
99
+ /** 工作区 project 存储根(<workspace>/.dsh/storages,随 git 分享)。 */
100
+ private projectRootFor;
101
+ /** 稳定 backend 名(registry 重名抛错,按 cwd hash 唯一化;只允许 [a-z0-9_],domain 名校验)。 */
102
+ private projectBackendName;
103
+ /**
104
+ * 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
105
+ * 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
106
+ */
107
+ private projectTableFor;
82
108
  /**
83
- * 强制重载存储:关闭两域后重开,放弃内存缓存重读文件。
84
- * 供外部编辑记忆文件后刷新(JsonStorageBackend 打开时加载一次,
85
- * 无 watch——2026-08-19 用户实测面板不感知外部编辑)。
109
+ * 强制重载存储:关闭全局域与全部已开工作区域后重开(unit 释放后
110
+ * backend 重读文件),放弃内存缓存。供外部编辑记忆文件后刷新
111
+ * (JsonStorageBackend 打开时加载一次,无 watch——2026-08-19 实测)。
86
112
  */
87
113
  reload(): Promise<void>;
88
114
  /** Create one record in `suggested` status — never self-promoting. */
89
- remember(input: MemoryWrite): Promise<MemoryRecord>;
90
- list(filter?: MemoryFilter): MemoryRecord[];
91
- search(query: string, filter?: MemoryFilter): MemoryHit[];
92
- forget(id: MemoryId): Promise<boolean>;
93
- setStatus(id: MemoryId, status: MemoryStatus): Promise<MemoryRecord>;
115
+ remember(input: MemoryWrite, projectCwd?: string): Promise<MemoryRecord>;
116
+ list(filter?: MemoryFilter, projectCwd?: string): Promise<MemoryRecord[]>;
117
+ search(query: string, filter?: MemoryFilter, projectCwd?: string): Promise<MemoryHit[]>;
118
+ forget(id: MemoryId, projectCwd?: string): Promise<boolean>;
119
+ setStatus(id: MemoryId, status: MemoryStatus, projectCwd?: string): Promise<MemoryRecord>;
120
+ /**
121
+ * 注入维度开关(0.3.0):只改 injected,不动审核状态。供 UI 面板
122
+ * 「常驻注入」开关调用(remote.setInjected)。
123
+ */
124
+ setInjected(id: MemoryId, injected: boolean, projectCwd?: string): Promise<MemoryRecord>;
125
+ /**
126
+ * 修改一条记忆的内容/关键词(0.3.1 整理记忆用)。内容被模型改动后
127
+ * 必须重新人工审核:status 重置为 suggested(自然停止注入——注入仅对
128
+ * approved 生效);injected 保留原值(审核通过后注入开关原样恢复)。
129
+ */
130
+ update(id: MemoryId, patch: {
131
+ content?: string;
132
+ keywords?: string[];
133
+ }, projectCwd?: string): Promise<MemoryRecord>;
134
+ /**
135
+ * 注入专用(0.3.4):systemPrompt context 是同步回调、无会话 cwd 可及,
136
+ * 只返回 global 的 approved+injected(工作区记忆走检索/面板)。
137
+ */
138
+ recallRecords(): MemoryRecord[];
94
139
  private allRecords;
95
140
  private recordsOf;
96
141
  private tableFor;