@max-null/dsh-memory 0.5.0 → 0.6.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/dist/index.js CHANGED
@@ -9,9 +9,10 @@ import { MemoryEngine } from "./engine.js";
9
9
  import { MemoryGateway } from "./remote.js";
10
10
  import { mountMemoryApi } from "./routes.js";
11
11
  import { SELF_DESCRIPTION } from "./self.js";
12
+ import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, renderInjection } from "./injection.js";
12
13
  export { MemoryEngine } from "./engine.js";
13
14
  export { MemoryId } from "./engine.js";
14
- export { bm25Scores, tokenize } from "./bm25.js";
15
+ export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from "./bm25.js";
15
16
  export const name = 'dsh-memory';
16
17
  export const inject = ['storage', 'systemPrompt', 'tools', 'webServer', 'webRuntime'];
17
18
  const RECORD_SCHEMA = {
@@ -26,6 +27,7 @@ const RECORD_SCHEMA = {
26
27
  keywords: { type: 'array', required: true, items: { type: 'string' } },
27
28
  createdAt: { type: 'number', required: true },
28
29
  updatedAt: { type: 'number', required: true },
30
+ lastUsedAt: { type: 'number' },
29
31
  },
30
32
  };
31
33
  const HIT_SCHEMA = {
@@ -36,6 +38,21 @@ const HIT_SCHEMA = {
36
38
  score: { type: 'number', required: true },
37
39
  },
38
40
  };
41
+ /** 模板记录(0.6.0):不暴露本地文件路径(meta.path 引擎内部使用)。 */
42
+ const PROMPT_RECORD_SCHEMA = {
43
+ type: 'object',
44
+ additionalProperties: false,
45
+ properties: {
46
+ id: { type: 'string', required: true },
47
+ name: { type: 'string', required: true },
48
+ dimension: { type: 'string' },
49
+ difficulty: { type: 'string' },
50
+ tags: { type: 'array', items: { type: 'string' } },
51
+ summary: { type: 'string', required: true },
52
+ source: { type: 'string', enum: ['user', 'agent'], required: true },
53
+ namespace: { type: 'string', enum: ['global', 'project'], required: true },
54
+ },
55
+ };
39
56
  function recordValue(record) {
40
57
  return {
41
58
  id: String(record.id),
@@ -46,11 +63,25 @@ function recordValue(record) {
46
63
  keywords: record.keywords,
47
64
  createdAt: record.createdAt,
48
65
  updatedAt: record.updatedAt,
66
+ ...record.lastUsedAt === undefined ? {} : { lastUsedAt: record.lastUsedAt },
49
67
  };
50
68
  }
51
69
  function hitValue(hit) {
52
70
  return { record: recordValue(hit.record), score: hit.score };
53
71
  }
72
+ function promptValue(record) {
73
+ const meta = record.meta;
74
+ return {
75
+ id: String(record.id),
76
+ name: meta?.name ?? '',
77
+ ...meta?.dimension === undefined ? {} : { dimension: meta.dimension },
78
+ ...meta?.difficulty === undefined ? {} : { difficulty: meta.difficulty },
79
+ tags: meta?.tags ?? [],
80
+ summary: meta?.summary ?? record.content.slice(0, 200),
81
+ source: meta?.source ?? 'user',
82
+ namespace: record.namespace,
83
+ };
84
+ }
54
85
  // 0.3.0:审核语义双语(host 侧 GUIDANCE 无法跟随 DSH locale 动态切换,
55
86
  // 采用中英双语都写、模型自取——设计文档「风险与注意」)。
56
87
  const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and project conventions. '
@@ -63,16 +94,29 @@ const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and pr
63
94
  + 'When earlier context may be relevant, call memory_search to recall it — reviewable memories '
64
95
  + '(`suggested`) are searchable too. 相关历史上下文可用 memory_search 检索——待审核的记忆也可检索。'
65
96
  + 'Every memory is plaintext and inspectable with memory_list; memory_forget removes one. '
66
- + '所有记忆均为明文,可用 memory_list 查看;memory_forget 删除一条。';
67
- // 0.3.4:注入只注入 global 的 approved+injected(systemPrompt context 是
68
- // 同步回调、无会话 cwd 可及);工作区(project)记忆按会话 cwd 路由,靠
69
- // memory_search / 面板访问。
70
- function recallText(memory) {
71
- const auto = memory.recallRecords();
72
- if (auto.length === 0)
97
+ + '所有记忆均为明文,可用 memory_list 查看;memory_forget 删除一条。'
98
+ + 'Approved + injected memories of the CURRENT session workspace are every-turn injected too. '
99
+ + '已审核 + 常驻注入开关打开的工作区记忆也会每轮注入(按当前会话工作区路由)。';
100
+ // 0.5.1:`memory:recall` provider 可经 AssembleContext.agent 拿到当前会话
101
+ // 的 header.cwd——注入 = global + 当前会话工作区的 approved+injected
102
+ // (0.3.4 起工作区记忆就按会话 cwd 路由;provider 是同步回调,只读已
103
+ // 打开/缓存的表,未打开时本轮工作区部分为空,由 ensureProjectOpen 预热)。
104
+ // 0.5.2:注入经摘要化 + 预算截断(renderInjection,纯函数)防上下文膨胀。
105
+ function recallText(memory, projectCwd, budget, summaryChars) {
106
+ const rendered = renderInjection(memory.recallRecords(projectCwd), budget, summaryChars);
107
+ if (rendered.lines.length === 0)
73
108
  return '';
74
- const lines = auto.map(record => `- [memory:${String(record.id)}] ${record.content}`);
75
- return `Remembered preferences and conventions — apply these:\n${lines.join('\n')}`;
109
+ return `Remembered preferences and conventions — apply these:\n${rendered.lines.join('\n')}`;
110
+ }
111
+ /** 从组装上下文取当前 agent 会话的工作区 cwd(AssembleContext.agent 由 dsh-agent 声明合并提供)。 */
112
+ function assemblyProjectCwd(assembly) {
113
+ return assembly
114
+ ?.agent?.session?.header?.cwd;
115
+ }
116
+ /** 会话 header 的 cwd(session/created 事件载荷),预热 work 区表用。 */
117
+ function sessionProjectCwd(session) {
118
+ const cwd = session?.meta?.cwd;
119
+ return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
76
120
  }
77
121
  /** 从工具执行上下文取调用会话的工作区 cwd(0.3.4 工作区路由)。 */
78
122
  function execProjectCwd(exec) {
@@ -108,7 +152,24 @@ export async function apply(ctx, config) {
108
152
  ctx.systemPrompt.context({
109
153
  name: 'memory:recall',
110
154
  order: 50,
111
- text: () => recallText(memory),
155
+ text: (assembly) => {
156
+ const cwd = assemblyProjectCwd(assembly);
157
+ if (cwd !== undefined)
158
+ void memory.ensureProjectOpen(cwd).catch(() => { });
159
+ const budget = config?.injectionBudget ?? DEFAULT_INJECTION_BUDGET;
160
+ const summaryChars = config?.summaryChars ?? DEFAULT_SUMMARY_CHARS;
161
+ return recallText(memory, cwd, budget, summaryChars);
162
+ },
163
+ });
164
+ // 会话创建即预热对应工作区的 project 表(0.5.1):让首轮模型请求就
165
+ // 能注入工作区常驻记忆(provider 同步只读已打开的表)。
166
+ // session/created 事件由 dsh-session 提供(可选依赖:无 session 服务
167
+ // 的环境不派发事件,预热由 provider 路径兜底)。
168
+ const emitter = ctx;
169
+ emitter.on?.('session/created', (session) => {
170
+ const cwd = sessionProjectCwd(session);
171
+ if (cwd !== undefined)
172
+ void memory.ensureProjectOpen(cwd).catch(() => { });
112
173
  });
113
174
  ctx.tools.register(defineTool({
114
175
  name: 'memory_save',
@@ -154,11 +215,12 @@ export async function apply(ctx, config) {
154
215
  }));
155
216
  ctx.tools.register(defineTool({
156
217
  name: 'memory_search',
157
- description: 'Recall stored memories by keyword. Deterministic literal matching — a miss means no stored term matched the query. 按关键词检索记忆(含待审核条目)。',
218
+ description: 'Recall stored memories by keyword. Deterministic literal matching — a miss means no stored term matched the query. 按关键词检索记忆(中文 2-gram + 关键词加权;配置嵌入时含语义融合;含待审核条目)。',
158
219
  parameters: {
159
220
  query: { type: 'string', required: true, description: 'Keyword query. 关键词查询.' },
160
221
  namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
161
222
  status: { type: 'string', enum: ['suggested', 'approved'], description: 'Restrict to one review status. 限定审核状态.' },
223
+ kind: { type: 'string', enum: ['fact', 'prompt'], description: 'Restrict by record kind — fact (memories, default) or prompt (templates; use prompt_search instead). 按记录类别过滤:fact=记忆(默认)/ prompt=模板(模板请用 prompt_search).' },
162
224
  },
163
225
  output: {
164
226
  schema: { type: 'array', items: HIT_SCHEMA },
@@ -168,10 +230,141 @@ export async function apply(ctx, config) {
168
230
  return memory.search(args.query, {
169
231
  ...args.namespace === undefined ? {} : { namespace: args.namespace },
170
232
  ...args.status === undefined ? {} : { status: args.status },
233
+ ...args.kind === undefined ? {} : { kind: args.kind },
171
234
  }, execProjectCwd(exec)).then(hits => hits.map(hitValue));
172
235
  },
173
236
  presentCall: args => present('Search memory', 'read', args.query),
174
237
  }));
238
+ ctx.tools.register(defineTool({
239
+ name: 'prompt_search',
240
+ description: 'Search prompt templates by keyword plus dimension/difficulty/tag filters. Returns matching summaries; call prompt_get for the full text. 按关键词+维度/难度/标签检索提示词模板,返回匹配摘要;取全文用 prompt_get。',
241
+ parameters: {
242
+ query: { type: 'string', required: true, description: 'Keyword query (name/tag/body). 关键词查询(名称/标签/正文).' },
243
+ dimension: { type: 'string', description: 'Filter by dimension (前端/后端/…). 按维度过滤.' },
244
+ difficulty: { type: 'string', description: 'Filter by difficulty (L1-L5/LX). 按难度过滤.' },
245
+ tags: { type: 'array', items: { type: 'string' }, description: 'Filter by any of these tags. 按任一标签过滤.' },
246
+ namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
247
+ },
248
+ output: {
249
+ schema: { type: 'array', items: PROMPT_RECORD_SCHEMA },
250
+ render: (_args, value) => renderJson(value),
251
+ },
252
+ execute(args, exec) {
253
+ return memory.refreshPromptIndex(execProjectCwd(exec)).then(async () => {
254
+ const all = await memory.list({ kind: 'prompt', ...args.namespace === undefined ? {} : { namespace: args.namespace } }, execProjectCwd(exec));
255
+ const filtered = all.filter(record => {
256
+ const meta = record.meta;
257
+ if (meta === undefined)
258
+ return true;
259
+ if (args.dimension !== undefined && args.dimension !== '' && meta.dimension !== args.dimension)
260
+ return false;
261
+ if (args.difficulty !== undefined && args.difficulty !== '' && meta.difficulty !== args.difficulty)
262
+ return false;
263
+ if (args.tags !== undefined && args.tags.length > 0 && !args.tags.some(tag => meta.tags.includes(tag)))
264
+ return false;
265
+ return true;
266
+ });
267
+ const quoted = JSON.stringify(args.query ?? '');
268
+ const hits = await memory.search(quoted, { kind: 'prompt', ...args.namespace === undefined ? {} : { namespace: args.namespace } }, execProjectCwd(exec));
269
+ const hitIds = new Set(hits.map(hit => String(hit.record.id)));
270
+ // 过滤器命中列表按检索分排序在前,未命中关键词的过滤命中排后(按名称)
271
+ const byQuery = filtered.filter(record => hitIds.has(String(record.id)));
272
+ const rest = filtered
273
+ .filter(record => !hitIds.has(String(record.id)))
274
+ .sort((a, b) => (a.meta?.name ?? '').localeCompare(b.meta?.name ?? ''));
275
+ return [...byQuery, ...rest].map(promptValue);
276
+ });
277
+ },
278
+ presentCall: args => present('Search prompt library', 'read', args.query),
279
+ }));
280
+ ctx.tools.register(defineTool({
281
+ name: 'prompt_get',
282
+ description: 'Fetch a prompt template full text by id or name (name matches the file stem without the seq prefix). 按 id 或名称取模板全文(名称 = 文件名去序号部分)。',
283
+ parameters: {
284
+ nameOrId: { type: 'string', required: true, description: 'Template id or name. 模板 id 或名称.' },
285
+ },
286
+ output: {
287
+ schema: { type: 'object', additionalProperties: false, properties: {
288
+ name: { type: 'string', required: true },
289
+ dimension: { type: 'string' },
290
+ difficulty: { type: 'string' },
291
+ tags: { type: 'array', items: { type: 'string' } },
292
+ body: { type: 'string', required: true },
293
+ fallback: { type: 'string' },
294
+ } },
295
+ render: (_args, value) => renderJson(value),
296
+ },
297
+ execute(args, exec) {
298
+ return memory.promptGet(args.nameOrId, execProjectCwd(exec)).then(file => ({
299
+ name: file.meta.name,
300
+ ...file.meta.dimension === undefined ? {} : { dimension: file.meta.dimension },
301
+ ...file.meta.difficulty === undefined ? {} : { difficulty: file.meta.difficulty },
302
+ tags: file.meta.tags,
303
+ body: file.body,
304
+ ...file.fallback === null ? {} : { fallback: file.fallback },
305
+ }));
306
+ },
307
+ presentCall: args => present('Get prompt template', 'read', args.nameOrId),
308
+ }));
309
+ ctx.tools.register(defineTool({
310
+ name: 'prompt_list',
311
+ description: 'List every prompt template index (no full text), optionally filtered by dimension/difficulty/tag. 列出全部模板索引(不含全文),可按维度/难度/标签过滤。',
312
+ parameters: {
313
+ dimension: { type: 'string', description: '按维度过滤.' },
314
+ difficulty: { type: 'string', description: '按难度过滤.' },
315
+ tags: { type: 'array', items: { type: 'string' }, description: '按任一标签过滤.' },
316
+ namespace: { type: 'string', enum: ['global', 'project'], description: '限定单个命名空间.' },
317
+ },
318
+ output: {
319
+ schema: { type: 'array', items: PROMPT_RECORD_SCHEMA },
320
+ render: (_args, value) => renderJson(value),
321
+ },
322
+ execute(args, exec) {
323
+ return memory.refreshPromptIndex(execProjectCwd(exec)).then(() => memory.list({ kind: 'prompt', ...args.namespace === undefined ? {} : { namespace: args.namespace } }, execProjectCwd(exec))).then(records => records.filter(record => {
324
+ const meta = record.meta;
325
+ if (meta === undefined)
326
+ return false;
327
+ if (args.dimension !== undefined && args.dimension !== '' && meta.dimension !== args.dimension)
328
+ return false;
329
+ if (args.difficulty !== undefined && args.difficulty !== '' && meta.difficulty !== args.difficulty)
330
+ return false;
331
+ if (args.tags !== undefined && args.tags.length > 0 && !args.tags.some(tag => meta.tags.includes(tag)))
332
+ return false;
333
+ return true;
334
+ }).sort((a, b) => (a.meta?.seq ?? 0) - (b.meta?.seq ?? 0)).map(promptValue));
335
+ },
336
+ presentCall: () => present('List prompt templates', 'read'),
337
+ }));
338
+ ctx.tools.register(defineTool({
339
+ name: 'prompt_add',
340
+ description: 'Add one prompt template to the library (writes a metadata-marked md file; source=agent is surfaced in the UI). 新增一条提示词模板(写元数据 md 文件;source=agent 在 UI 角标提示)。',
341
+ parameters: {
342
+ name: { type: 'string', required: true, description: 'Template name (also the file stem). 模板名称(也是文件名).' },
343
+ content: { type: 'string', required: true, description: 'The prompt body. 提示词正文.' },
344
+ dimension: { type: 'string', description: '维度(前端/后端/…).' },
345
+ difficulty: { type: 'string', description: '难度(L1-L5/LX).' },
346
+ tags: { type: 'array', items: { type: 'string' }, description: '检索用标签.' },
347
+ fallback: { type: 'string', description: '可选备用提示词.' },
348
+ namespace: { type: 'string', enum: ['global', 'project'], description: '默认 global.' },
349
+ },
350
+ output: {
351
+ schema: PROMPT_RECORD_SCHEMA,
352
+ render: (_args, value) => renderJson(value),
353
+ },
354
+ execute(args, exec) {
355
+ return memory.promptAdd({
356
+ name: args.name,
357
+ content: args.content,
358
+ ...args.dimension === undefined ? {} : { dimension: args.dimension },
359
+ ...args.difficulty === undefined ? {} : { difficulty: args.difficulty },
360
+ ...args.tags === undefined ? {} : { tags: args.tags },
361
+ ...args.fallback === undefined ? {} : { fallback: args.fallback },
362
+ ...args.namespace === undefined ? {} : { namespace: args.namespace },
363
+ source: 'agent',
364
+ }, execProjectCwd(exec)).then(promptValue);
365
+ },
366
+ presentCall: args => present('Add prompt template', 'other', args.name),
367
+ }));
175
368
  ctx.tools.register(defineTool({
176
369
  name: 'memory_forget',
177
370
  description: 'Delete one stored memory by id. The human owner may remove any memory.',
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 注入渲染(0.5.2):`memory:recall` 与面板「注入预览」共用的确定性
3
+ * 渲染——摘要化 + 预算截断。纯函数、无模型调用:注入行 = 每条记忆的
4
+ * 单行摘要(内容过长取首行截断),超出预算(字符数)时按「最近更新
5
+ * 优先」贪心保留,省略计数返回给 UI 提示。
6
+ */
7
+ import type { MemoryRecord } from './engine.ts';
8
+ /** 摘要截断上限(字符):中英混合场景下 ~40-60 token。 */
9
+ export declare const DEFAULT_SUMMARY_CHARS = 80;
10
+ /** 注入预算(字符):0.5.2 起默认开启,防常驻注入随记忆增长膨胀上下文。 */
11
+ export declare const DEFAULT_INJECTION_BUDGET = 1500;
12
+ /**
13
+ * 确定性摘要:取首行,超过 maxChars 截断加省略号。
14
+ * 空内容/空首行回退为去掉换行后的前 maxChars(保证 any 内容有摘要)。
15
+ */
16
+ export declare function deriveSummary(content: string, maxChars: number): string;
17
+ /** 一条注入行的渲染文本(与面板预览完全一致)。 */
18
+ export declare function injectionLine(record: MemoryRecord, summaryChars: number): string;
19
+ export interface InjectionRender {
20
+ /** 实际注入行(已按预算截断、按更新时间降序)。 */
21
+ lines: string[];
22
+ /** 生效的预算(字符;null = 无限制)。 */
23
+ budget: number | null;
24
+ /** 因预算被省略的候选条数(0 = 全部注入)。 */
25
+ omitted: number;
26
+ /** 实际注入的字符数(摘要 + 标记开销之和)。 */
27
+ chars: number;
28
+ }
29
+ /**
30
+ * 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
31
+ * updatedAt——最近使用/更新的记忆最先),逐条累加开销直到预算用尽;
32
+ * 预算为 null 或非正数时全量注入(不过滤)。
33
+ */
34
+ export declare function renderInjection(records: readonly MemoryRecord[], budget: number | null, summaryChars: number): InjectionRender;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 注入渲染(0.5.2):`memory:recall` 与面板「注入预览」共用的确定性
3
+ * 渲染——摘要化 + 预算截断。纯函数、无模型调用:注入行 = 每条记忆的
4
+ * 单行摘要(内容过长取首行截断),超出预算(字符数)时按「最近更新
5
+ * 优先」贪心保留,省略计数返回给 UI 提示。
6
+ */
7
+ /** 摘要截断上限(字符):中英混合场景下 ~40-60 token。 */
8
+ export const DEFAULT_SUMMARY_CHARS = 80;
9
+ /** 注入预算(字符):0.5.2 起默认开启,防常驻注入随记忆增长膨胀上下文。 */
10
+ export const DEFAULT_INJECTION_BUDGET = 1500;
11
+ /** 单条注入行的固定标记开销(不含摘要本体)。 */
12
+ const LINE_OVERHEAD = 27;
13
+ /**
14
+ * 确定性摘要:取首行,超过 maxChars 截断加省略号。
15
+ * 空内容/空首行回退为去掉换行后的前 maxChars(保证 any 内容有摘要)。
16
+ */
17
+ export function deriveSummary(content, maxChars) {
18
+ const firstLine = content.split('\n')[0].trim();
19
+ if (firstLine !== '') {
20
+ return firstLine.length <= maxChars ? firstLine : `${firstLine.slice(0, maxChars)}…`;
21
+ }
22
+ const collapsed = content.replace(/\s+/g, ' ').trim();
23
+ if (collapsed === '')
24
+ return content;
25
+ return collapsed.length <= maxChars ? collapsed : `${collapsed.slice(0, maxChars)}…`;
26
+ }
27
+ /** 一条注入行的渲染文本(与面板预览完全一致)。 */
28
+ export function injectionLine(record, summaryChars) {
29
+ return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}] ${deriveSummary(record.content, summaryChars)}`;
30
+ }
31
+ /**
32
+ * 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
33
+ * updatedAt——最近使用/更新的记忆最先),逐条累加开销直到预算用尽;
34
+ * 预算为 null 或非正数时全量注入(不过滤)。
35
+ */
36
+ export function renderInjection(records, budget, summaryChars) {
37
+ const ordered = [...records].sort((left, right) => (right.lastUsedAt ?? right.updatedAt) - (left.lastUsedAt ?? left.updatedAt));
38
+ const lines = [];
39
+ let chars = 0;
40
+ let omitted = 0;
41
+ for (const record of ordered) {
42
+ const text = injectionLine(record, summaryChars);
43
+ const cost = text.length + LINE_OVERHEAD;
44
+ if (budget !== null && budget > 0 && chars + cost > budget) {
45
+ omitted++;
46
+ continue;
47
+ }
48
+ lines.push(text);
49
+ chars += cost;
50
+ }
51
+ return { lines, budget: budget ?? null, omitted, chars };
52
+ }
@@ -0,0 +1,77 @@
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
+ export interface PromptMeta {
24
+ seq?: number;
25
+ name: string;
26
+ dimension?: string;
27
+ difficulty?: string;
28
+ tags: string[];
29
+ source: 'user' | 'agent';
30
+ createdAt?: number;
31
+ }
32
+ export interface PromptFile {
33
+ path: string;
34
+ mtime: number;
35
+ meta: PromptMeta;
36
+ /** 主提示词全文。 */
37
+ body: string;
38
+ /** 备用提示词(可选;md 中以独立 `---` 行与正文分隔)。 */
39
+ fallback: string | null;
40
+ /** 索引摘要(正文前 200 字,替换换行)。 */
41
+ summary: string;
42
+ }
43
+ /**
44
+ * 解析一份 md(frontmatter + 正文 + 可选备用段)。纯文本解析,不碰文件系统;
45
+ * mtime 由调用方传入(scan 从 stat 取;纯解析测试传 0)。
46
+ * @returns ok:true 带结构化结果;ok:false 带人读错误(跳过不索引)。
47
+ */
48
+ export declare function parsePromptFile(path: string, text: string, mtime?: number): {
49
+ ok: true;
50
+ file: PromptFile;
51
+ } | {
52
+ ok: false;
53
+ error: string;
54
+ };
55
+ /** 扫描一个目录的全部 *.md(目录不存在 = 空)。坏文件收集 warnings。 */
56
+ export declare function scanPromptDir(dir: string): {
57
+ files: PromptFile[];
58
+ warnings: string[];
59
+ };
60
+ /** 文件名安全化(Windows 保留字符替换;保留中文与常用符号)。 */
61
+ export declare function sanitizeFileName(name: string): string;
62
+ /** 下一个可用序号(现有文件 `\d+_` 前缀 + 1;目录空 = 1)。 */
63
+ export declare function nextSeq(dir: string): number;
64
+ export interface PromptWriteInput {
65
+ name: string;
66
+ dimension?: string;
67
+ difficulty?: string;
68
+ tags?: string[];
69
+ content: string;
70
+ fallback?: string;
71
+ source: 'user' | 'agent';
72
+ }
73
+ /**
74
+ * 写一份模板 md(目录不存在自动创建;序号自动分配;同名防追加后缀)。
75
+ * @returns 写入路径。
76
+ */
77
+ export declare function writePromptFile(dir: string, input: PromptWriteInput): string;
@@ -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/remote.d.ts CHANGED
@@ -29,12 +29,16 @@ export declare class MemoryGateway extends TypertRemoteService {
29
29
  /** Force-reload storage files (external edits; JsonStorageBackend has no watch). */
30
30
  reload(cwd?: string): Promise<MemoryRecord[]>;
31
31
  /**
32
- * 注入预览(0.3.5):开发者查看当前注入到 system prompt 的记忆内容
33
- * ——self 自述 + 实际注入的 global approved+injected 记忆。
32
+ * 注入预览(0.3.5 + 0.5.2):开发者查看当前注入到 system prompt 的记忆
33
+ * 内容——self 自述 + 当前会话工作区(cwd)的 approved+injected 记忆,
34
+ * 经摘要化 + 预算截断(与 memory:recall 同源渲染)。
34
35
  */
35
- injectionPreview(): {
36
+ injectionPreview(cwd?: string, budget?: number | null, summaryChars?: number): Promise<{
36
37
  self: string;
37
- injected: MemoryRecord[];
38
- };
38
+ lines: string[];
39
+ budget: number | null;
40
+ omitted: number;
41
+ chars: number;
42
+ }>;
39
43
  }
40
44
  export default MemoryGateway;