dsh-memoir 0.4.3

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/lib/tools.js ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Agent tools for dsh-memoir: memoir_record (persist one work / lesson /
3
+ * action / note entry) and memoir_read (read project / global memory). Both
4
+ * tools resolve the caller's workspace from the executing agent's session cwd
5
+ * and delegate all persistence to the structured MemoirStore.
6
+ *
7
+ * v0.3.1: section headers no longer duplicate "##"; project/global reads are
8
+ * bounded by internal hard caps; descriptions and renders are trimmed.
9
+ * v0.4.0: memoir_read gains limit (default 8, max 30) and detail
10
+ * (compact default / full) so reads are cheap by default.
11
+ */
12
+ import { defineTool } from '@deepseek-ai/dsh-tools';
13
+ import { SECTIONS, SECTION_KEYS, formatTime, projectTitle } from './store.js';
14
+ /** One text content block (the only render shape these tools emit). */
15
+ export function text(value) {
16
+ return [{ type: 'text', text: value }];
17
+ }
18
+ /** Resolve the caller session's workspace cwd (absolute), or undefined. */
19
+ export function resolveWorkspace(exec) {
20
+ const cwd = exec?.agent?.session?.header?.cwd;
21
+ return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
22
+ }
23
+ /** Internal hard caps: bound read output regardless of stored volume. */
24
+ export const READ_GLOBAL_MAX_ENTRIES_PER_PROJECT = 50;
25
+ export const READ_OUTPUT_MAX_CHARS = 16000;
26
+ /** Full-detail entry line (time + label + title + content). */
27
+ export function renderEntryFull(entry) {
28
+ const label = SECTIONS[entry.section]?.label ?? entry.section;
29
+ const when = formatTime(entry.time);
30
+ const head = entry.title !== undefined ? entry.title + ' — ' : '';
31
+ return '- [' + when + '] [' + label + '] ' + head + entry.content;
32
+ }
33
+ /** Compact one-line entry (id + title + collapsed single-line content). */
34
+ export function renderEntryCompact(entry, maxContent = 200) {
35
+ const head = entry.title !== undefined && entry.title !== '' ? entry.title + ' — ' : '';
36
+ const oneLine = entry.content.replace(/\s+/g, ' ').trim();
37
+ const body = oneLine.length > maxContent ? oneLine.slice(0, maxContent) + '…' : oneLine;
38
+ return '- [' + entry.id + '] ' + head + body;
39
+ }
40
+ /** Append a truncation note when the limit clipped the output. */
41
+ function clippedNote(total, shown) {
42
+ return '(共 ' + total + ' 条匹配,仅显示 ' + shown + ' 条,可用 limit 参数调整)';
43
+ }
44
+ /**
45
+ * Incremental output budget (v0.4.2): blocks are appended in RANK order and
46
+ * the budget stops accepting once the char cap is reached. This preserves
47
+ * the highest-ranked head of the result set — the old tail slice kept the
48
+ * bottom of the list and dropped exactly the entries the ranking put first.
49
+ */
50
+ class OutputBudget {
51
+ parts = [];
52
+ used = 0;
53
+ overflow = false;
54
+ max;
55
+ constructor(max) {
56
+ this.max = max;
57
+ }
58
+ /** Add one block; returns false once the budget is exhausted. */
59
+ add(text) {
60
+ if (text === '')
61
+ return true;
62
+ if (this.overflow)
63
+ return false;
64
+ const cost = this.used === 0 ? text.length : text.length + 2; // two-char newline separator
65
+ if (this.used + cost > this.max) {
66
+ this.overflow = true;
67
+ return false;
68
+ }
69
+ this.parts.push(text);
70
+ this.used += cost;
71
+ return true;
72
+ }
73
+ get text() {
74
+ return this.parts.join('\n\n');
75
+ }
76
+ get clipped() {
77
+ return this.overflow;
78
+ }
79
+ }
80
+ /** Append grouped entries ('## section' headers + bullets) into the budget. */
81
+ function appendGrouped(budget, entries, renderEntry) {
82
+ let shown = 0;
83
+ let lastSection = '';
84
+ for (const entry of entries) {
85
+ if (entry.section !== lastSection) {
86
+ lastSection = entry.section;
87
+ if (!budget.add('## ' + (SECTIONS[entry.section]?.label ?? entry.section)))
88
+ break;
89
+ }
90
+ if (!budget.add(renderEntry(entry)))
91
+ break;
92
+ shown++;
93
+ }
94
+ return shown;
95
+ }
96
+ /** The record tool: persist one memory entry. */
97
+ export function memoirRecordTool(store) {
98
+ return defineTool({
99
+ name: 'memoir_record',
100
+ description: '把一条记忆写入项目持久记忆,供未来会话继承。阶段任务收尾时归纳「做了什么(work)/经验教训(lessons)/下一步行动(actions)」分条记录。' +
101
+ 'Triggers: 记录经验教训、沉淀记忆、归纳工作、更新行动指南、总结踩坑。',
102
+ parameters: {
103
+ section: {
104
+ type: 'string',
105
+ required: true,
106
+ enum: [...SECTION_KEYS],
107
+ description: '记忆分类:work 工作记录 / lessons 经验教训 / actions 行动指南 / note 备注。',
108
+ },
109
+ title: {
110
+ type: 'string',
111
+ description: '可选,一句话标题(如「修复 pet 悬停闪退」)。',
112
+ },
113
+ content: {
114
+ type: 'string',
115
+ required: true,
116
+ description: '记忆正文:具体做了什么、结论、教训或下一步怎么做。建议精炼、可执行。',
117
+ },
118
+ },
119
+ output: {
120
+ schema: {
121
+ type: 'object',
122
+ additionalProperties: false,
123
+ properties: {
124
+ section: { type: 'string', required: true },
125
+ id: { type: 'string', required: true },
126
+ title: { type: 'string' },
127
+ projectFile: { type: 'string', required: true },
128
+ globalIndex: { type: 'string', required: true },
129
+ recordedAt: { type: 'string', required: true },
130
+ },
131
+ },
132
+ // Minimal text: the structured fields stay available for debugging, but
133
+ // the agent-facing render is one line (paths/timestamps add no value).
134
+ render: (_args, value) => text('已记录 [' + value.section + '] ' + (value.title !== undefined && value.title !== '' ? value.title + ' ' : '') + '(id: ' + value.id + ')'),
135
+ },
136
+ async execute(args, exec) {
137
+ const cwd = resolveWorkspace(exec);
138
+ if (cwd === undefined) {
139
+ throw new Error('无法确定会话工作区(缺少 agent cwd);请在项目会话内调用 memoir_record');
140
+ }
141
+ const sessionId = exec?.agent?.id ? String(exec.agent.id) : undefined;
142
+ const entry = store.record(cwd, args, sessionId);
143
+ return {
144
+ section: entry.section,
145
+ id: entry.id,
146
+ ...(entry.title !== undefined ? { title: entry.title } : {}),
147
+ // record() already regenerated the project file — never write twice.
148
+ projectFile: store.projectFilePath(cwd),
149
+ globalIndex: store.path,
150
+ recordedAt: formatTime(entry.time),
151
+ };
152
+ },
153
+ });
154
+ }
155
+ /** The read tool: project / global / all memory with optional filters. */
156
+ export function memoirReadTool(store, options, retrieval) {
157
+ const defaultLimit = options?.defaultLimit ?? 8;
158
+ const maxLimit = options?.maxLimit ?? 30;
159
+ return defineTool({
160
+ name: 'memoir_read',
161
+ description: '读取项目持久记忆与经验教训(默认返回最近 ' + defaultLimit + ' 条 compact 摘要)。开始新会话或接手旧项目时先调用。' +
162
+ 'Triggers: 读取记忆、回顾项目历史、查询经验教训、接手项目、查看行动指南。',
163
+ parameters: {
164
+ scope: {
165
+ type: 'string',
166
+ enum: ['project', 'global', 'all'],
167
+ description: '读取范围:project 仅本项目(默认)/ global 全局跨项目 / all 全部。',
168
+ },
169
+ section: {
170
+ type: 'string',
171
+ enum: [...SECTION_KEYS],
172
+ description: '可选,只返回某一分类。',
173
+ },
174
+ query: {
175
+ type: 'string',
176
+ description: '可选,本地相关性检索标题与正文:支持中文短语、英文关键词、代码标识符与路径,并按相关性排序。',
177
+ },
178
+ limit: {
179
+ type: 'number',
180
+ description: '可选,最多返回条数(默认 ' + defaultLimit + ',最大 ' + maxLimit + ')。',
181
+ },
182
+ detail: {
183
+ type: 'string',
184
+ enum: ['compact', 'full'],
185
+ description: '输出形态:compact 单行摘要(默认)/ full 完整正文。',
186
+ },
187
+ },
188
+ output: {
189
+ schema: {
190
+ type: 'object',
191
+ additionalProperties: false,
192
+ properties: {
193
+ text: { type: 'string', required: true },
194
+ },
195
+ },
196
+ render: (_args, value) => text(value.text),
197
+ },
198
+ async execute(args, exec) {
199
+ const scope = args.scope ?? 'project';
200
+ const cwd = resolveWorkspace(exec);
201
+ const detail = args.detail ?? 'compact';
202
+ const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? Math.floor(args.limit) : defaultLimit;
203
+ const limit = Math.min(maxLimit, Math.max(1, rawLimit));
204
+ const renderEntry = detail === 'full' ? renderEntryFull : renderEntryCompact;
205
+ const query = typeof args.query === 'string' ? args.query.toLowerCase() : '';
206
+ const matches = (s) => (query === '' ? true : String(s).toLowerCase().includes(query));
207
+ const filterEntry = (e) => (args.section === undefined || e.section === args.section) && matches((e.title ?? '') + ' ' + e.content);
208
+ // v0.4.2: output is assembled through a rank-order budget — the top of
209
+ // the ranked list always survives truncation.
210
+ const budget = new OutputBudget(READ_OUTPUT_MAX_CHARS);
211
+ if (scope === 'project' || scope === 'all') {
212
+ if (cwd === undefined) {
213
+ budget.add('(无法确定会话工作区,跳过项目记忆)');
214
+ }
215
+ else {
216
+ // v0.4.1: query → ranked recall (BM25 + boosts), no query → newest first.
217
+ const ranked = query !== '' && retrieval !== undefined
218
+ ? retrieval.cachedSearch(query, { section: args.section, cwd, limit, detail })
219
+ : [];
220
+ if (ranked.length > 0) {
221
+ const entries = ranked.slice(0, limit).map((r) => r.entry);
222
+ appendGrouped(budget, entries, renderEntry);
223
+ if (ranked.length > entries.length)
224
+ budget.add(clippedNote(ranked.length, entries.length));
225
+ }
226
+ else {
227
+ const matched = store.entries(cwd).filter(filterEntry);
228
+ if (matched.length === 0) {
229
+ budget.add('本项目(' + cwd + ')暂无' + (query !== '' || args.section !== undefined ? '匹配的' : '') + '持久记忆。可用 memoir_record 沉淀。');
230
+ }
231
+ else {
232
+ const entries = matched.slice(-limit);
233
+ appendGrouped(budget, entries, renderEntry);
234
+ if (matched.length > entries.length)
235
+ budget.add(clippedNote(matched.length, entries.length));
236
+ }
237
+ }
238
+ }
239
+ }
240
+ if (scope === 'global' || scope === 'all') {
241
+ const ranked = query !== '' && retrieval !== undefined
242
+ ? retrieval.cachedSearch(query, { section: args.section, limit, detail })
243
+ : [];
244
+ if (ranked.length > 0) {
245
+ // v0.4.2: the limit is a true global Top-K — slice first, then
246
+ // group by project for rendering (never per-project × limit).
247
+ const top = ranked.slice(0, limit);
248
+ const grouped = new Map();
249
+ for (const result of top) {
250
+ const bucket = grouped.get(result.projectPath) ?? [];
251
+ bucket.push(result.entry);
252
+ grouped.set(result.projectPath, bucket);
253
+ }
254
+ for (const [path, entries] of grouped) {
255
+ if (!budget.add(['### ' + projectTitle(path), 'path: ' + path].join('\n')))
256
+ break;
257
+ for (const entry of entries) {
258
+ if (!budget.add(renderEntry(entry)))
259
+ break;
260
+ }
261
+ }
262
+ if (ranked.length > top.length)
263
+ budget.add(clippedNote(ranked.length, top.length));
264
+ }
265
+ else {
266
+ const projects = store.listProjects();
267
+ for (const project of projects) {
268
+ const matched = store.entries(project.path).filter(filterEntry);
269
+ if (matched.length === 0)
270
+ continue;
271
+ const entries = matched.slice(-Math.min(limit, READ_GLOBAL_MAX_ENTRIES_PER_PROJECT));
272
+ if (!budget.add(['### ' + (project.title || projectTitle(project.path)), 'path: ' + project.path + ' updated: ' + formatTime(project.updatedAt)].join('\n')))
273
+ break;
274
+ for (const entry of entries) {
275
+ if (!budget.add(renderEntry(entry)))
276
+ break;
277
+ }
278
+ if (matched.length > entries.length)
279
+ budget.add(clippedNote(matched.length, entries.length));
280
+ }
281
+ if (budget.text === '')
282
+ budget.add('(全局索引中没有匹配的内容)');
283
+ }
284
+ }
285
+ const text = budget.text;
286
+ if (budget.clipped) {
287
+ return { text: text + '\n\n(输出超过 ' + READ_OUTPUT_MAX_CHARS + ' 字符上限,已保留相关性最高/最新的部分)' };
288
+ }
289
+ return { text };
290
+ },
291
+ });
292
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "dsh-memoir",
3
+ "description": "Cache-aware local project memory for DeepSeek Harness (DSH): agents distill session work, lessons and next actions into a local JSON store (~/.dsh/dsh-memoir.json, source of truth) with a per-project PROJECT_MEMORY.md projection; bounded hot-memory (ranked, session-frozen) is auto-injected into the system prompt while long-tail history is served by ranked BM25 retrieval (memoir_read, no embeddings / no external services); a web-GUI 'Memory' panel offers ranked search, hot-memory inspection and retrieval diagnostics over /api/dsh-memoir routes. TypeScript, mounted via the dsh.bundle.patch manifest, no dsh source changes.",
4
+ "version": "0.4.3",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": "^22.19.0 || >=24.0.0"
8
+ },
9
+ "main": "lib/index.js",
10
+ "types": "lib/index.d.ts",
11
+ "exports": {
12
+ ".": "./lib/index.js",
13
+ "./client": "./lib/client.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "dsh": {
17
+ "bundle": {
18
+ "patch": "./cordis.patch.yml"
19
+ },
20
+ "client": {
21
+ "inject": [
22
+ "@deepseek-ai/dsh-client-runtime"
23
+ ],
24
+ "platform": "web"
25
+ }
26
+ },
27
+ "peerDependencies": {
28
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
29
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
30
+ },
31
+ "devDependencies": {
32
+ "@deepseek-ai/cordis": "^4.0.1",
33
+ "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
34
+ "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
35
+ "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.6",
36
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
37
+ "@deepseek-ai/dsh-session": "0.1.0-rc.6",
38
+ "@deepseek-ai/dsh-system-prompt": "0.1.0-rc.6",
39
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
40
+ "@types/node": "^22.20.1",
41
+ "@types/react": "~18.3.31",
42
+ "@types/react-dom": "^18.3.7",
43
+ "esbuild": "^0.28.2",
44
+ "typescript": "~5.8.3"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.build.json && node build.mjs",
48
+ "typecheck": "tsc -p tsconfig.json",
49
+ "test": "npm run build && node --test \"test/*.test.ts\"",
50
+ "bench": "npm run build && node bench/bench.mjs"
51
+ },
52
+ "pnpm": {
53
+ "onlyBuiltDependencies": [
54
+ "esbuild"
55
+ ]
56
+ },
57
+ "files": [
58
+ "lib/*.js",
59
+ "lib/*.js.map",
60
+ "lib/*.d.ts",
61
+ "cordis.patch.yml",
62
+ "README.md"
63
+ ],
64
+ "license": "Apache-2.0",
65
+ "keywords": [
66
+ "dsh",
67
+ "deepseek-harness",
68
+ "dsh-plugin",
69
+ "memory",
70
+ "lessons",
71
+ "session",
72
+ "agent"
73
+ ],
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "git+https://github.com/Qinling-Melon-Farmers/dsh-memoir.git"
77
+ }
78
+ }