@max-null/dsh-memory 0.5.2 → 0.6.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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * prompt-files.ts — 提示词模板 md 文件解析与目录扫描(纯 Node,零依赖可测)。
3
+ *
4
+ * 文件是模板的唯一事实源(设计文档 §2):用户可直接放入/编辑/删除 md,
5
+ * 索引层按 mtime 惰性刷新。格式:
6
+ *
7
+ * ```md
8
+ * ---
9
+ * seq: 36
10
+ * name: "样例" # 必填;缺 name 的文件跳过(坏文件不崩不索引)
11
+ * dimension: "前端"
12
+ * difficulty: "L3"
13
+ * tags: ["Three.js"]
14
+ * source: user # user | agent(模型新增打 agent 角标)
15
+ * createdAt: 2026-08-26
16
+ * ---
17
+ * (主提示词全文)
18
+ * ---
19
+ * (备用提示词,可选)
20
+ * ```
21
+ * 解析失败/缺 name 的文件记 warnings,不影响整体扫描。
22
+ */
23
+ import { readdirSync, readFileSync, statSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ const SUMMARY_CHARS = 200;
26
+ /** 解析简单 frontmatter 值:字符串(含引号)/数字/布尔/数组。 */
27
+ function parseFrontmatterValue(raw) {
28
+ const value = raw.trim();
29
+ if (value.startsWith('[') && value.endsWith(']')) {
30
+ return value
31
+ .slice(1, -1).split(',')
32
+ .map(part => part.trim().replace(/^["']|["']$/g, ''))
33
+ .filter(part => part !== '');
34
+ }
35
+ if (value.startsWith('"') && value.endsWith('"'))
36
+ return value.slice(1, -1);
37
+ if (value.startsWith("'") && value.endsWith("'"))
38
+ return value.slice(1, -1);
39
+ if (/^\d+$/.test(value))
40
+ return Number(value);
41
+ if (value === 'true')
42
+ return true;
43
+ if (value === 'false')
44
+ return false;
45
+ return value;
46
+ }
47
+ /**
48
+ * 解析一份 md(frontmatter + 正文 + 可选备用段)。纯文本解析,不碰文件系统;
49
+ * mtime 由调用方传入(scan 从 stat 取;纯解析测试传 0)。
50
+ * @returns ok:true 带结构化结果;ok:false 带人读错误(跳过不索引)。
51
+ */
52
+ export function parsePromptFile(path, text, mtime = 0) {
53
+ const lines = text.split(/\r?\n/);
54
+ if (lines[0]?.trim() !== '---') {
55
+ return { ok: false, error: 'missing frontmatter fence (first line must be ---)' };
56
+ }
57
+ let fence = -1;
58
+ for (let i = 1; i < lines.length; i++) {
59
+ if (lines[i].trim() === '---') {
60
+ fence = i;
61
+ break;
62
+ }
63
+ }
64
+ if (fence === -1)
65
+ return { ok: false, error: 'unclosed frontmatter fence' };
66
+ const meta = {};
67
+ for (const line of lines.slice(1, fence)) {
68
+ const m = /^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$/.exec(line);
69
+ if (m === null || m[2] === undefined)
70
+ continue;
71
+ meta[m[1]] = parseFrontmatterValue(m[2]);
72
+ }
73
+ const name = typeof meta.name === 'string' ? meta.name : '';
74
+ if (name === '')
75
+ return { ok: false, error: 'missing required frontmatter field "name"' };
76
+ const source = meta.source === 'agent' ? 'agent' : 'user';
77
+ // 正文:frontmatter 后到第一个独立 `---` 行为止(剩余为备用提示词)
78
+ const bodyLines = [];
79
+ const fallbackLines = [];
80
+ let inFallback = false;
81
+ for (const line of lines.slice(fence + 1)) {
82
+ if (!inFallback && line.trim() === '---') {
83
+ inFallback = true;
84
+ continue;
85
+ }
86
+ ;
87
+ (inFallback ? fallbackLines : bodyLines).push(line);
88
+ }
89
+ const body = bodyLines.join('\n').trim();
90
+ if (body === '')
91
+ return { ok: false, error: 'empty prompt body' };
92
+ // 备用段首行常为「备用提示词2」之类的标题行,跳过(约定:正文与备用用 --- 分隔)
93
+ const fallbackRaw = fallbackLines.join('\n').trim();
94
+ const fallback = fallbackRaw === '' ? null : fallbackRaw.replace(/^\s*(备用提示词\d*|fallback\d*)\s*\n?/i, '');
95
+ const file = {
96
+ path,
97
+ mtime,
98
+ meta: {
99
+ seq: typeof meta.seq === 'number' ? meta.seq : undefined,
100
+ name,
101
+ dimension: typeof meta.dimension === 'string' ? meta.dimension : undefined,
102
+ difficulty: typeof meta.difficulty === 'string' ? meta.difficulty : undefined,
103
+ tags: Array.isArray(meta.tags) ? meta.tags.filter((tag) => typeof tag === 'string') : [],
104
+ source,
105
+ createdAt: typeof meta.createdAt === 'number' ? meta.createdAt : undefined,
106
+ },
107
+ body,
108
+ fallback,
109
+ summary: body.replace(/\s+/g, ' ').trim().slice(0, SUMMARY_CHARS),
110
+ };
111
+ return { ok: true, file };
112
+ }
113
+ /** 扫描一个目录的全部 *.md(目录不存在 = 空)。坏文件收集 warnings。 */
114
+ export function scanPromptDir(dir) {
115
+ const files = [];
116
+ const warnings = [];
117
+ let entries = [];
118
+ try {
119
+ entries = readdirSync(dir);
120
+ }
121
+ catch {
122
+ return { files, warnings }; // 目录不存在:空库
123
+ }
124
+ for (const entry of entries) {
125
+ if (!entry.toLowerCase().endsWith('.md'))
126
+ continue;
127
+ const path = join(dir, entry);
128
+ let text;
129
+ try {
130
+ text = readFileSync(path, 'utf8');
131
+ }
132
+ catch (error) {
133
+ warnings.push(`${path}: read failed (${error instanceof Error ? error.message : String(error)})`);
134
+ continue;
135
+ }
136
+ const parsed = parsePromptFile(path, text, statSync(path).mtimeMs);
137
+ if (!parsed.ok) {
138
+ warnings.push(`${path}: ${parsed.error}`);
139
+ continue;
140
+ }
141
+ files.push(parsed.file);
142
+ }
143
+ return { files, warnings };
144
+ }
145
+ /** 文件名安全化(Windows 保留字符替换;保留中文与常用符号)。 */
146
+ export function sanitizeFileName(name) {
147
+ const safe = name.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim();
148
+ return (safe === '' ? 'untitled' : safe).slice(0, 80);
149
+ }
150
+ /** 下一个可用序号(现有文件 `\d+_` 前缀 + 1;目录空 = 1)。 */
151
+ export function nextSeq(dir) {
152
+ let max = 0;
153
+ try {
154
+ for (const entry of readdirSync(dir)) {
155
+ const m = /^(\d+)_/.exec(entry);
156
+ if (m !== null)
157
+ max = Math.max(max, Number(m[1]));
158
+ }
159
+ }
160
+ catch { /* 目录不存在:从 1 开始 */ }
161
+ return max + 1;
162
+ }
163
+ /**
164
+ * 写一份模板 md(目录不存在自动创建;序号自动分配;同名防追加后缀)。
165
+ * @returns 写入路径。
166
+ */
167
+ export function writePromptFile(dir, input) {
168
+ mkdirSync(dir, { recursive: true });
169
+ const seq = nextSeq(dir);
170
+ const base = sanitizeFileName(input.name);
171
+ let path = join(dir, `${seq}_${base}.md`);
172
+ let attempt = 0;
173
+ while (existsSync(path)) {
174
+ attempt += 1;
175
+ path = join(dir, `${seq}_${base}_${attempt}.md`);
176
+ }
177
+ const lines = ['---', `seq: ${seq}`, `name: ${JSON.stringify(input.name)}`, `source: ${input.source}`];
178
+ if (input.dimension !== undefined && input.dimension !== '')
179
+ lines.push(`dimension: ${JSON.stringify(input.dimension)}`);
180
+ if (input.difficulty !== undefined && input.difficulty !== '')
181
+ lines.push(`difficulty: ${JSON.stringify(input.difficulty)}`);
182
+ if (input.tags !== undefined && input.tags.length > 0)
183
+ lines.push(`tags: [${input.tags.map(tag => JSON.stringify(tag)).join(', ')}]`);
184
+ lines.push(`createdAt: ${new Date().toISOString().slice(0, 10)}`, '---');
185
+ const text = `${lines.join('\n')}\n\n${input.content.trim()}\n${input.fallback === undefined || input.fallback === '' ? '' : `\n---\n\n${input.fallback.trim()}\n`}`;
186
+ writeFileSync(path, text, 'utf8');
187
+ return path;
188
+ }
package/dist/routes.js CHANGED
@@ -98,6 +98,27 @@ function cwdOf(payload) {
98
98
  const value = payload?.cwd;
99
99
  return typeof value === 'string' && value !== '' ? value : undefined;
100
100
  }
101
+ /** 取 memory 服务(未挂载 503 且明确失败)。 */
102
+ function requireMemory(ctx) {
103
+ const memory = ctx.get('memory');
104
+ if (memory === undefined)
105
+ throw new MemoryApiError('service-unavailable', 'memory service unavailable', 503);
106
+ return memory;
107
+ }
108
+ /** 模板记录投影(0.6.0):不暴露本地文件路径(meta.path 引擎内部使用)。 */
109
+ function promptProjection(record) {
110
+ const meta = record.meta;
111
+ return {
112
+ id: String(record.id),
113
+ name: meta?.name ?? '',
114
+ ...meta?.dimension === undefined ? {} : { dimension: meta.dimension },
115
+ ...meta?.difficulty === undefined ? {} : { difficulty: meta.difficulty },
116
+ ...meta === undefined ? {} : { tags: meta.tags },
117
+ summary: meta?.summary ?? record.content.slice(0, 200),
118
+ source: meta?.source ?? 'user',
119
+ namespace: record.namespace,
120
+ };
121
+ }
101
122
  /** 挂载 /memory/api 路由(apply 调用)。 */
102
123
  export function mountMemoryApi(ctx) {
103
124
  // webServer/webRuntime 由 index.ts 的 inject 声明(Context 类型未扩展,断言访问)
@@ -184,6 +205,63 @@ export function mountMemoryApi(ctx) {
184
205
  const summaryChars = typeof record?.summaryChars === 'number' && record.summaryChars > 0 ? record.summaryChars : DEFAULT_SUMMARY_CHARS;
185
206
  return { self: SELF_DESCRIPTION, ...renderInjection(memory.recallRecords(cwd), budget, summaryChars) };
186
207
  },
208
+ // ── 提示词模板库(0.6.0)──
209
+ 'prompt.list': async (payload) => {
210
+ const memory = requireMemory(ctx);
211
+ const record = (payload ?? {});
212
+ const cwd = cwdOf(record);
213
+ await memory.refreshPromptIndex(cwd);
214
+ const namespace = record.namespace === 'project' || record.namespace === 'global' ? record.namespace : undefined;
215
+ const records = await memory.list({ kind: 'prompt', ...namespace === undefined ? {} : { namespace } }, cwd);
216
+ return records.map(promptProjection);
217
+ },
218
+ 'prompt.get': (payload) => {
219
+ const memory = requireMemory(ctx);
220
+ const record = (payload ?? {});
221
+ const lookup = typeof record.name === 'string' ? record.name : typeof record.id === 'string' ? record.id : '';
222
+ if (lookup === '') {
223
+ throw new MemoryApiError('bad-request', 'missing or invalid "id"/"name"');
224
+ }
225
+ return memory.promptGet(lookup, cwdOf(record)).then(file => ({
226
+ name: file.meta.name,
227
+ ...file.meta.dimension === undefined ? {} : { dimension: file.meta.dimension },
228
+ ...file.meta.difficulty === undefined ? {} : { difficulty: file.meta.difficulty },
229
+ tags: file.meta.tags,
230
+ body: file.body,
231
+ ...file.fallback === null ? {} : { fallback: file.fallback },
232
+ }));
233
+ },
234
+ 'prompt.add': (payload) => {
235
+ const memory = requireMemory(ctx);
236
+ const record = (payload ?? {});
237
+ if (typeof record.name !== 'string' || record.name === '')
238
+ throw new MemoryApiError('bad-request', 'missing or invalid "name"');
239
+ if (typeof record.content !== 'string' || record.content === '')
240
+ throw new MemoryApiError('bad-request', 'missing or invalid "content"');
241
+ return memory.promptAdd({
242
+ name: record.name,
243
+ content: record.content,
244
+ ...typeof record.dimension === 'string' ? { dimension: record.dimension } : {},
245
+ ...typeof record.difficulty === 'string' ? { difficulty: record.difficulty } : {},
246
+ ...Array.isArray(record.tags) ? { tags: record.tags.filter((tag) => typeof tag === 'string') } : {},
247
+ ...typeof record.fallback === 'string' ? { fallback: record.fallback } : {},
248
+ ...record.namespace === 'project' ? { namespace: 'project' } : {},
249
+ source: 'user',
250
+ }, cwdOf(record)).then(promptProjection);
251
+ },
252
+ 'prompt.remove': (payload) => {
253
+ const memory = requireMemory(ctx);
254
+ const record = (payload ?? {});
255
+ const lookup = typeof record.name === 'string' ? record.name : typeof record.id === 'string' ? record.id : '';
256
+ if (lookup === '') {
257
+ throw new MemoryApiError('bad-request', 'missing or invalid "id"/"name"');
258
+ }
259
+ return memory.promptRemove(lookup, cwdOf(record));
260
+ },
261
+ 'prompt.refresh': (payload) => {
262
+ const memory = requireMemory(ctx);
263
+ return memory.refreshPromptIndex(cwdOf((payload ?? {})));
264
+ },
187
265
  };
188
266
  const fence = (req) => isTrusted(req, webRuntime.trustedHosts);
189
267
  ctx.effect(() => webServer.register({
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-memory",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, human-owned, no embeddings",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -22,8 +22,7 @@
22
22
  "client.js",
23
23
  "cordis.patch.yml",
24
24
  "README.md",
25
- "LICENSE"
26
- ],
25
+ "LICENSE", "docs/shots"],
27
26
  "dsh": {
28
27
  "bundle": {
29
28
  "patch": "./cordis.patch.yml"