dsh-prime-memory 0.21.0 → 0.22.2

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.
Files changed (63) hide show
  1. package/dist/client.js +1581 -1150
  2. package/dist/config.d.ts +149 -0
  3. package/dist/config.js +35 -0
  4. package/dist/conflict-service.d.ts +8 -0
  5. package/dist/conflict-service.js +19 -0
  6. package/dist/contract.d.ts +131 -0
  7. package/dist/domain-gate.d.ts +8 -0
  8. package/dist/domain-gate.js +30 -0
  9. package/dist/graph/constraints.d.ts +4 -2
  10. package/dist/graph/constraints.js +4 -2
  11. package/dist/hooks/recall.d.ts +2 -0
  12. package/dist/hooks/recall.js +21 -2
  13. package/dist/hooks/skill-recall.d.ts +25 -0
  14. package/dist/hooks/skill-recall.js +72 -0
  15. package/dist/index.d.ts +98 -0
  16. package/dist/index.js +26 -1
  17. package/dist/memport/format.d.ts +70 -0
  18. package/dist/memport/format.js +305 -0
  19. package/dist/memport/prefilter.d.ts +62 -0
  20. package/dist/memport/prefilter.js +132 -0
  21. package/dist/memport/scan.d.ts +13 -0
  22. package/dist/memport/scan.js +100 -0
  23. package/dist/metadata-validators.d.ts +22 -0
  24. package/dist/metadata-validators.js +45 -0
  25. package/dist/pipeline/l1.js +16 -2
  26. package/dist/pipeline/ruminate.d.ts +3 -0
  27. package/dist/pipeline/ruminate.js +50 -1
  28. package/dist/pipeline/slot-migrate.d.ts +45 -0
  29. package/dist/pipeline/slot-migrate.js +45 -0
  30. package/dist/projection/recall.d.ts +74 -0
  31. package/dist/projection/recall.js +148 -0
  32. package/dist/projection/slots.d.ts +5 -0
  33. package/dist/projection/slots.js +9 -2
  34. package/dist/prompts/graph-projection.js +3 -0
  35. package/dist/prompts/l1-extraction.d.ts +4 -1
  36. package/dist/prompts/l1-extraction.js +27 -4
  37. package/dist/settings.js +106 -11
  38. package/dist/skills/crystallize.d.ts +41 -0
  39. package/dist/skills/crystallize.js +75 -0
  40. package/dist/stats.d.ts +18 -1
  41. package/dist/stats.js +184 -5
  42. package/dist/store/file-versions.d.ts +2 -0
  43. package/dist/store/file-versions.js +2 -0
  44. package/dist/store/l1.d.ts +2 -0
  45. package/dist/store/l1.js +4 -0
  46. package/dist/store/memory-sources.d.ts +59 -0
  47. package/dist/store/memory-sources.js +214 -0
  48. package/dist/store/session-recalled.d.ts +25 -0
  49. package/dist/store/session-recalled.js +163 -0
  50. package/dist/store/skill-store.d.ts +99 -0
  51. package/dist/store/skill-store.js +330 -0
  52. package/dist/store/sqlite.d.ts +5 -0
  53. package/dist/store/sqlite.js +30 -4
  54. package/dist/tools/index.d.ts +0 -13
  55. package/dist/tools/index.js +58 -7
  56. package/dist/tools/skills.d.ts +13 -0
  57. package/dist/tools/skills.js +130 -0
  58. package/dist/types.d.ts +6 -0
  59. package/dist/util/maturity.d.ts +27 -0
  60. package/dist/util/maturity.js +26 -0
  61. package/dist/wing-backfill.js +7 -4
  62. package/dsh.plugin.json +2 -2
  63. package/package.json +14 -14
@@ -10,6 +10,7 @@
10
10
  * 历史上 Wing 侧零校验,非法值先入库、再由下游剥离,形成无谓的写入-剥离循环。
11
11
  */
12
12
  import { WING_CATALOG, WING_FALLBACK } from './types.js';
13
+ import { isCognitiveHall, COG_HALL_METADATA_KEY } from './cognitive-hall.js';
13
14
  /** Wing 词表(8 域 + general 兜底):与 relabel 机械校验、提示词候选集同源。 */
14
15
  const WING_ID_SET = new Set([
15
16
  ...WING_CATALOG.map((w) => w.id),
@@ -66,3 +67,47 @@ export function normTags(v, max = 3) {
66
67
  return out;
67
68
  }
68
69
  export { isCognitiveHall, COGNITIVE_HALLS } from './cognitive-hall.js';
70
+ /** usageScenario 单条最大长度(“何时该浮现”的短语,非 slug,故不校 isTag 字符集)。 */
71
+ const USAGE_SCENARIO_MAX_CHARS = 60;
72
+ /**
73
+ * usageScenario 归一 + 去重 + 上限。**所有写回点必须过这里**。
74
+ *
75
+ * 与 normTags 的区别:usageScenario 是自然语言短语(如“修改 profile 配置时”),
76
+ * 不是 slug,所以不做字符集校验,只做 trim/去重/长度钳制/上限。
77
+ * 非数组或空→ `[]`(缺字段中性,不抛——防单条抽取失败波及整切片重试)。
78
+ */
79
+ export function normUsageScenario(v, max = 3) {
80
+ if (!Array.isArray(v) || max <= 0)
81
+ return [];
82
+ const out = [];
83
+ for (const raw of v) {
84
+ if (typeof raw !== 'string')
85
+ continue;
86
+ const t = raw.trim();
87
+ if (!t || out.includes(t))
88
+ continue;
89
+ out.push(t.slice(0, USAGE_SCENARIO_MAX_CHARS));
90
+ if (out.length >= max)
91
+ break;
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * 读 **Wing(生活域)**。⚠️ 磁盘键是 `metadata.hall`——历史别名,存的其实是 **wing 值**。
97
+ * 只认词表内的合法 wing(8 角 + general),否则 undefined。
98
+ *
99
+ * 用它取代裸读 `metadata.hall`:从入口上把“wing 轴”与“认知 hall 轴”分开,
100
+ * 防止把认知 hall 值(facts/events/…)误当 wing 读——两轴不串。
101
+ */
102
+ export function readWing(metadata) {
103
+ const v = metadata?.hall;
104
+ return typeof v === 'string' && isWingId(v) ? v : undefined;
105
+ }
106
+ /**
107
+ * 读 **认知 hall(大类)**。磁盘键 `metadata.cogHall`,与 wing 的磁盘键 `hall` 正交。
108
+ * 只认五个枚举值,否则 undefined(wing 值不当认知 hall 读)。
109
+ */
110
+ export function readCognitiveHall(metadata) {
111
+ const v = metadata?.[COG_HALL_METADATA_KEY];
112
+ return isCognitiveHall(v) ? v : undefined;
113
+ }
@@ -12,6 +12,7 @@ import { callLLM, parseJsonLogged, resolveLayerTokens } from '../llm.js';
12
12
  import { buildReceipts, newRunId, persistReceiptsSafely } from '../store/receipts.js';
13
13
  import { buildConflictPair, conflictRejectId, DEFER_MAX, normalizeClaimKey, normalizeConflictType, occupiesConflictQuota, pendingHardTotal, validateConflictPair, } from '../store/conflicts.js';
14
14
  import { formatExtractionPrompt, getExtractMemoriesSystemPrompt } from '../prompts/l1-extraction.js';
15
+ import { normUsageScenario } from '../metadata-validators.js';
15
16
  import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
16
17
  import { resolveSourceAnchors, withSourceAnchors } from './anchors.js';
17
18
  import { familyForType, normPersistence, normScope, resolveRecordFamily, resolveRecordScope, WING_FALLBACK } from '../types.js';
@@ -58,6 +59,19 @@ function mergeTemporal(self, targets) {
58
59
  * 第 4 个参数是 R7 的锚点映射(`L0 消息 id → 会话坐标`)。**传 undefined 时
59
60
  * 行为与改动前逐字一致**——老调用方与没有锚点的会话走这条分支。
60
61
  */
62
+ /**
63
+ * 把抽取侧的 `metadata.usageScenario` 归一到命名空间保留键 `dsh_usage_scenario`
64
+ *(与 dsh_source_anchors 同范式)。**无该键时原样返回**——功能关时 LLM 不产出该键,
65
+ * 记录 metadata 与改动前逐字一致(零漂移)。只随新蒸馏写,不回填存量。
66
+ */
67
+ function withUsageScenario(meta) {
68
+ if (!meta || !('usageScenario' in meta))
69
+ return meta;
70
+ const out = { ...meta };
71
+ out.dsh_usage_scenario = normUsageScenario(out.usageScenario);
72
+ delete out.usageScenario;
73
+ return out;
74
+ }
61
75
  function toStoreRecord(m, now, ts, anchorMap) {
62
76
  return {
63
77
  id: m.record_id,
@@ -72,7 +86,7 @@ function toStoreRecord(m, now, ts, anchorMap) {
72
86
  source_message_ids: m.source_message_ids ?? [],
73
87
  // R7:锚点与 source_message_ids 同源解析;解析不到就**不写键**(不是空数组),
74
88
  // 使无锚点记录与改动前的 metadata 逐字一致。
75
- metadata: withSourceAnchors(m.metadata, anchorMap === undefined ? undefined : resolveSourceAnchors(m.source_message_ids, anchorMap)),
89
+ metadata: withSourceAnchors(withUsageScenario(m.metadata), anchorMap === undefined ? undefined : resolveSourceAnchors(m.source_message_ids, anchorMap)),
76
90
  family: m.family,
77
91
  scope: m.scope,
78
92
  workspaceId: m.workspaceId,
@@ -195,7 +209,7 @@ anchorMap) {
195
209
  halls,
196
210
  });
197
211
  const raw = await callLLM(ctx, cfg, {
198
- system: getExtractMemoriesSystemPrompt(mode),
212
+ system: getExtractMemoriesSystemPrompt(mode, { usageScenario: cfg.usageScenario?.enabled === true, narrative: cfg.narrativeTemplates?.enabled === true }),
199
213
  user: userPrompt,
200
214
  maxTokens: resolveLayerTokens(cfg, 'extract'),
201
215
  layer: 'l1-extract',
@@ -21,6 +21,7 @@ import type { SceneStore } from '../store/scenes.js';
21
21
  import type { StateStore } from '../store/state.js';
22
22
  import type { LiveSettingsHandle } from '../settings.js';
23
23
  import type { MemoryFamily, MemoryLogger } from '../types.js';
24
+ import { type SlotMigrateStoreFace } from './slot-migrate.js';
24
25
  import { type RelabelStats } from './relabel.js';
25
26
  import type { MemoryRunner } from './runner.js';
26
27
  export interface RuminateStatus {
@@ -77,6 +78,8 @@ export declare class RuminateController {
77
78
  scenes: Record<MemoryFamily, SceneStore>;
78
79
  persona: Record<MemoryFamily, PersonaStore>;
79
80
  state: StateStore;
81
+ /** 常驻槽位面(仅 cfg.slots.migrateLegacy 开启时读/写,缺省不碰)。 */
82
+ slots?: SlotMigrateStoreFace;
80
83
  }, logger: MemoryLogger, live: LiveSettingsHandle, pendingFile: string,
81
84
  /** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
82
85
  roomRegistry?: {
@@ -2,6 +2,7 @@ import { InProcMemoryBackend } from '../store/memory-backend.js';
2
2
  import { groupPendingBySession, loadPending, } from '../store/pending.js';
3
3
  import { errDetail } from '../util/filelog.js';
4
4
  import { runSceneConsolidation } from './l2.js';
5
+ import { migrateLegacySlots } from './slot-migrate.js';
5
6
  import { relabelPass } from './relabel.js';
6
7
  import { processSceneReclusterJobs } from './recluster.js';
7
8
  import { annotateOrphanCandidates } from '../room-review.js';
@@ -148,7 +149,11 @@ export class RuminateController {
148
149
  // 原先 setImmediate 连发会把全部会话一次性塞进 runner 队列(无界堆积),
149
150
  // LLM e2e 延迟波动时既拖慢 live 任务也让取消语义变钝;链式入队后
150
151
  // 进度(done)真实逐会话推进,取消即刻停止入队。
151
- this.runner.enqueue(session.sessionId, session.messages, session.mode, {
152
+ // 传空消息(与 enqueueSessionSlices 同一语义):让 runTurn 不回灌缓冲,
153
+ // 由 distillSession 直接处理该会话在内存缓冲里的既有切片。
154
+ // (旧代码传 session.messages——那是从 pending.json 读出的、已在缓冲中的消息,
155
+ // 经 runTurn 的 `bucket.push(...messages)` 会重复入桶 → 反刍后待处理计数“还原/变多”。)
156
+ this.runner.enqueue(session.sessionId, [], session.mode, {
152
157
  force: true,
153
158
  onTurnDone: (records) => {
154
159
  this.totalL1 += records;
@@ -199,7 +204,18 @@ export class RuminateController {
199
204
  if (scenes.length === 0)
200
205
  continue;
201
206
  const fstate = this.runner.states?.[family];
207
+ // 强制 L3:runPersona 自带触发判定(memoriesSinceL3>=interval 才跑),反刍是用户
208
+ // 显式整理动作,需绕过阈值真跑——置 personaRequestedReason 走“主动请求”分支。
209
+ if (fstate)
210
+ fstate.personaRequestedReason = '反刍收尾';
202
211
  await runPersona(this.ctx, this.cfg, this.stores.scenes[family], this.stores.persona[family], fstate || undefined, this.logger, family);
212
+ // 与 L2 块对称:无论 runPersona 是否因“无变化场景”早退,反刍收尾都清零 L3 计数,
213
+ // 否则“待 L3 新记忆”会永远卡在阈值下(本 bug 根因)。runPersona 成功时已自清,这里幂等兜底。
214
+ if (fstate) {
215
+ fstate.memoriesSinceL3 = 0;
216
+ fstate.lastL3At = Date.now();
217
+ fstate.personaRequestedReason = undefined;
218
+ }
203
219
  this.logger.info(`[memory] 反刍 L3 完成(family=${family})`);
204
220
  }
205
221
  catch (err) {
@@ -207,6 +223,15 @@ export class RuminateController {
207
223
  }
208
224
  }
209
225
  }
226
+ // 反刍收尾改了 L2/L3 计数(runner.states 是 StateStore 的活引用,内存态)——
227
+ // 必须显式落盘,否则重启从 state.json 读回旧值,表现为“待处理计数反复”。
228
+ // (正常蒸馏路径由 runTurn 末尾 save;反刍 finalize 是独立路径,需自己 save。)
229
+ await this.stores.state.save();
230
+ // 旧 slot 语义迁移(cfg.slots.migrateLegacy 默认关 → 直接 0 动作):
231
+ // 把“钉成常驻、内容已由活 L1 记忆承载”的 rule 槽位软退场。
232
+ if (this.stores.slots !== undefined) {
233
+ await migrateLegacySlots(this.stores.slots, this.stores.l1, this.cfg.slots.migrateLegacy === true, this.logger);
234
+ }
210
235
  // 标注校验/重标定:Wing 合法性 + 认知 hall 映射 + 未打标补标 + 涌现标签
211
236
  // 批次进度经 onProgress 写入 detail + sub 子进度(非会话阶段的进度由重标批次决定)
212
237
  this.status.phase = 'relabeling';
@@ -333,7 +358,18 @@ export class RuminateController {
333
358
  this.status.detail = `L3 画像更新(${family})`;
334
359
  const fstate = this.runner.states?.[family];
335
360
  try {
361
+ // 强制 L3:runPersona 自带触发判定(memoriesSinceL3>=interval 才跑),反刍是显式整理动作,
362
+ // 需绕过阈值真跑——置 personaRequestedReason 走“主动请求”分支。
363
+ if (fstate)
364
+ fstate.personaRequestedReason = '反刍轻量刷新';
336
365
  await runPersona(this.ctx, this.cfg, this.stores.scenes[family], this.stores.persona[family], fstate, this.logger, family);
366
+ // 与 L2 循环对称:无论 runPersona 是否因“无变化场景”早退,都清零 L3 计数,
367
+ // 否则“待 L3 新记忆”永远卡在阈值下。runPersona 成功时已自清,这里幂等兜底。
368
+ if (fstate) {
369
+ fstate.memoriesSinceL3 = 0;
370
+ fstate.lastL3At = Date.now();
371
+ fstate.personaRequestedReason = undefined;
372
+ }
337
373
  }
338
374
  catch (err) {
339
375
  this.logger.warn(`[memory] 反刍轻量刷新 L3 失败(family=${family}): ${errDetail(err)}`);
@@ -361,6 +397,19 @@ export class RuminateController {
361
397
  this.status.sub = null;
362
398
  this.status.done++;
363
399
  }
400
+ // 轻量刷新改了 L2/L3 计数(runner.states 是 StateStore 活引用)——必须显式落盘,
401
+ // 否则重启从 state.json 读回旧值,表现为“待 L2/L3 计数反复”。
402
+ try {
403
+ await this.stores.state.save();
404
+ }
405
+ catch (err) {
406
+ this.logger.warn(`[memory] 反刍轻量刷新 state 落盘失败(计数可能不持久): ${errDetail(err)}`);
407
+ }
408
+ // 旧 slot 语义迁移:无 pending 缓冲时走的是本路径(“待 L3 12/20”那类场景),
409
+ // 与 finalize 同一动作、同样默认关;两处幂等(退场后不再命中 open)。
410
+ if (this.stores.slots !== undefined) {
411
+ await migrateLegacySlots(this.stores.slots, this.stores.l1, this.cfg.slots.migrateLegacy === true, this.logger);
412
+ }
364
413
  this.status.running = false;
365
414
  this.status.detail = null;
366
415
  this.status.phase = this.cancelRequested ? 'cancelled' : 'done';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 旧 slot 语义迁移(`cfg.slots.migrateLegacy`,默认关)。
3
+ *
4
+ * 背景:常驻槽位是"每轮硬注入"的 opt-in 约定。早期用法里有一类槽位其实是
5
+ * **把一条已经存在的 L1 记忆钉成常驻**(`refs` 直指那条记录)——内容载体还在,
6
+ * 却占了固定 token 预算、还在概要里显示成常驻清单。新取向是"记忆按相关性隐式
7
+ * 浮现,除非手动提醒激活",所以这类槽位应当退场,让那条记忆回到召回。
8
+ *
9
+ * 判据(四条同时满足,宁缺勿滥):
10
+ * 1. 开关显式开启(动的是用户手动建的约定,不自动生效);
11
+ * 2. `status='open'` 且 `pinned=true` 且 `kind='rule'`(todo/anchor/pointer 各有语义,一律不碰);
12
+ * 3. `refs` 至少一条解析到**未退场**的 L1 记录(退场不丢信息:正文仍在记忆里);
13
+ * 4. 记录本身可被召回(未退场即可检索,不再另设条件)。
14
+ *
15
+ * 动作:`close(id, 'dropped')` —— **软退场**,不删数据;记忆仍在,需要时可
16
+ * 重新 `memory_slot_write`。逐条 info 日志,便于事后核对。
17
+ */
18
+ import type { Slot } from '../store/slots.js';
19
+ import type { MemoryLogger } from '../types.js';
20
+ /** 迁移需要的槽位面。 */
21
+ export interface SlotMigrateStoreFace {
22
+ list(): Slot[];
23
+ close(id: string, status: 'done' | 'dropped'): Promise<boolean>;
24
+ }
25
+ /** 迁移需要的 L1 只读面(validTo 非空 = 已退场(epoch ms),不作为信息载体)。 */
26
+ export interface SlotMigrateL1Face {
27
+ getByIds(ids: string[]): Array<{
28
+ id: string;
29
+ validTo?: number | null;
30
+ }>;
31
+ }
32
+ /** 一条可迁移槽位(预览用,执行前即可展示)。 */
33
+ export interface MigrateCandidate {
34
+ id: string;
35
+ title: string;
36
+ /** 承载内容的活记录 id。 */
37
+ carrier: string;
38
+ }
39
+ /** 判定 + 挑选可迁移槽位(纯读,无副作用;面板预览与反刍执行共用)。 */
40
+ export declare function pickMigratableSlots(slots: readonly Slot[], l1: SlotMigrateL1Face | undefined): MigrateCandidate[];
41
+ /**
42
+ * 执行迁移:软退场命中的槽位。
43
+ * @returns 实际退场条数(close 返回 false 的不计)。
44
+ */
45
+ export declare function migrateLegacySlots(slots: SlotMigrateStoreFace, l1: SlotMigrateL1Face | undefined, enabled: boolean, logger?: MemoryLogger): Promise<number>;
@@ -0,0 +1,45 @@
1
+ import { errDetail } from '../util/filelog.js';
2
+ /** 判定 + 挑选可迁移槽位(纯读,无副作用;面板预览与反刍执行共用)。 */
3
+ export function pickMigratableSlots(slots, l1) {
4
+ if (l1 === undefined)
5
+ return [];
6
+ const open = slots.filter((s) => s.status === 'open' && s.pinned === true && s.kind === 'rule');
7
+ if (open.length === 0)
8
+ return [];
9
+ // 一次批量取全部 refs,避免 N 次查询。
10
+ const allRefs = [...new Set(open.flatMap((s) => s.refs ?? []))];
11
+ const alive = new Set(l1.getByIds(allRefs).filter((r) => r.validTo === undefined || r.validTo === null).map((r) => r.id));
12
+ const out = [];
13
+ for (const s of open) {
14
+ const carrier = (s.refs ?? []).find((r) => alive.has(r));
15
+ if (carrier !== undefined)
16
+ out.push({ id: s.id, title: s.title, carrier });
17
+ }
18
+ return out;
19
+ }
20
+ /**
21
+ * 执行迁移:软退场命中的槽位。
22
+ * @returns 实际退场条数(close 返回 false 的不计)。
23
+ */
24
+ export async function migrateLegacySlots(slots, l1, enabled, logger) {
25
+ if (enabled !== true)
26
+ return 0;
27
+ const candidates = pickMigratableSlots(slots.list(), l1);
28
+ if (candidates.length === 0)
29
+ return 0;
30
+ let done = 0;
31
+ for (const c of candidates) {
32
+ try {
33
+ if (await slots.close(c.id, 'dropped')) {
34
+ done++;
35
+ logger?.info(`[memory] 旧槽位迁移:「${c.title}」常驻退场,内容改由召回承载(${c.carrier})`);
36
+ }
37
+ }
38
+ catch (err) {
39
+ logger?.warn(`[memory] 旧槽位迁移失败(${c.id}): ${errDetail(err)}`);
40
+ }
41
+ }
42
+ if (done > 0)
43
+ logger?.info(`[memory] 旧槽位迁移完成: 常驻约定退场 ${done} 条`);
44
+ return done;
45
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 会话「活召回集」服务端投影(memoryRecall)。
3
+ *
4
+ * 语义:本会话**已注入、且目前仍在上下文里**的记忆(= 模型此刻真正看到的几条)。
5
+ * 上下文被压缩/清空时注入内容离开模型,集合随之清空——数据源是
6
+ * `SessionRecalledStore`(sessionKey 键、写穿持久化),所以**旧会话重开也读得到**。
7
+ *
8
+ * 与 memorySlots 的分工(两条轴,互不替代):
9
+ * - `memorySlots` = 用户手动 pin 的**常驻约定**(每轮硬注入,注册表语义);
10
+ * - `memoryRecall` = 按相关性**隐式浮现**的召回结果(非常驻,本投影)。
11
+ * 召回记录绝不灌进 memorySlots:那会把"非常驻"伪装成槽位注册表。
12
+ *
13
+ * 会话感知靠 `init(header)`:宿主在 `session/created` 与冷加载 restore 两处都传
14
+ * `session.header`(`dsh-session/lib/index.js` 实证形状 `{ version, id, createdAt, … }`),
15
+ * `header.id` 即会话 id,与召回记账的 sessionKey(`agent.session?.id ?? agent.id`)同源。
16
+ * state 里带上 sessionId,apply 无 session 参数也能按本会话重读 store。
17
+ *
18
+ * 消费方是 dsh-brief-sidebar 的「本会话召回」分区(`useProjectionValue(ctx, sessionId, key)`)。
19
+ */
20
+ import type { Context } from '@deepseek-ai/cordis';
21
+ import type { SessionRecalledStore } from '../store/session-recalled.js';
22
+ export declare const MEMORY_RECALL_KEY = "memoryRecall";
23
+ /** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
24
+ export declare const RECALL_VIEW_CAP = 20;
25
+ /** 一条召回记录在 wire view 上的呈现。 */
26
+ export interface RecallItemView {
27
+ id: string;
28
+ type: string;
29
+ /** 正文首行(截断至 TITLE_MAX);全文由消费方按需走面板读。 */
30
+ title: string;
31
+ priority: number;
32
+ /** 使用场景(metadata.dsh_usage_scenario);无则缺席(附加字段,旧帧没有也整条通过)。 */
33
+ scenario?: string[];
34
+ }
35
+ /** 持久态:本会话的活召回 id 集 + 判脏序号。 */
36
+ export interface RecallProjectionState {
37
+ rev: number;
38
+ sessionId: string;
39
+ /** 注入序(旧→新),由 store 保证。 */
40
+ ids: string[];
41
+ }
42
+ /** wire view:count 是总数,items 是**最近在上**的截断视图。 */
43
+ export interface RecallView {
44
+ rev: number;
45
+ sessionId: string;
46
+ count: number;
47
+ items: RecallItemView[];
48
+ }
49
+ /** 零依赖 schema(同 slots 投影的理由:registry 只调 `parse`,且按 Object.is 比较引用)。 */
50
+ interface SchemaLike<T> {
51
+ parse(value: unknown): T;
52
+ }
53
+ /** state 校验(registry 用它挡掉损坏/过期的 checkpoint 行)。 */
54
+ export declare const stateSchema: SchemaLike<RecallProjectionState>;
55
+ /** view 校验(出网前调用;未知键容忍,同 slots 投影的语义边界)。 */
56
+ export declare const viewSchema: SchemaLike<RecallView>;
57
+ /** 投影需要的 L1 只读面(结构化声明,避免跨模块类型耦合)。 */
58
+ export interface RecallL1Face {
59
+ getByIds(ids: string[]): Array<{
60
+ id: string;
61
+ type: string;
62
+ content: string;
63
+ priority: number;
64
+ metadata?: Record<string, unknown>;
65
+ }>;
66
+ }
67
+ /**
68
+ * 注册 memoryRecall 投影。
69
+ *
70
+ * 缺席降级同 slots 投影:`ctx.inject` 在服务未提供时不回调;形状不符静默不注册。
71
+ * apply 是 O(1) 判脏(store.revision() 比对),任意已提交事件都会带进来看一眼。
72
+ */
73
+ export declare function registerRecallProjection(ctx: Context, store: SessionRecalledStore, l1?: RecallL1Face): void;
74
+ export {};
@@ -0,0 +1,148 @@
1
+ export const MEMORY_RECALL_KEY = 'memoryRecall';
2
+ /** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
3
+ export const RECALL_VIEW_CAP = 20;
4
+ /** 单条标题(正文首行)最大长度。 */
5
+ const TITLE_MAX = 160;
6
+ function invalid(what, detail) {
7
+ throw new Error(`${what}: ${detail}`);
8
+ }
9
+ function requireRecord(value, what) {
10
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
11
+ invalid(what, '期望普通对象');
12
+ }
13
+ return value;
14
+ }
15
+ function requireCounter(value, what, field) {
16
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
17
+ invalid(what, `${field} 必须是非负整数`);
18
+ }
19
+ }
20
+ function requireString(value, what, field) {
21
+ if (typeof value !== 'string')
22
+ invalid(what, `${field} 必须是字符串`);
23
+ }
24
+ /** state 校验(registry 用它挡掉损坏/过期的 checkpoint 行)。 */
25
+ export const stateSchema = {
26
+ parse: (value) => {
27
+ const what = 'memoryRecall.state';
28
+ const root = requireRecord(value, what);
29
+ requireCounter(root.rev, what, 'rev');
30
+ requireString(root.sessionId, what, 'sessionId');
31
+ if (!Array.isArray(root.ids))
32
+ invalid(what, 'ids 必须是字符串数组');
33
+ root.ids.forEach((id, i) => requireString(id, `${what}.ids[${i}]`, 'id'));
34
+ return value;
35
+ },
36
+ };
37
+ /** view 校验(出网前调用;未知键容忍,同 slots 投影的语义边界)。 */
38
+ export const viewSchema = {
39
+ parse: (value) => {
40
+ const what = 'memoryRecall.view';
41
+ const root = requireRecord(value, what);
42
+ requireCounter(root.rev, what, 'rev');
43
+ requireString(root.sessionId, what, 'sessionId');
44
+ requireCounter(root.count, what, 'count');
45
+ if (!Array.isArray(root.items))
46
+ invalid(what, 'items 必须是数组');
47
+ root.items.forEach((raw, i) => {
48
+ const at = `${what}.items[${i}]`;
49
+ const item = requireRecord(raw, at);
50
+ requireString(item.id, at, 'id');
51
+ requireString(item.type, at, 'type');
52
+ requireString(item.title, at, 'title');
53
+ requireCounter(item.priority, at, 'priority');
54
+ if (item.scenario !== undefined) {
55
+ if (!Array.isArray(item.scenario))
56
+ invalid(at, 'scenario 必须是字符串数组');
57
+ item.scenario.forEach((s, j) => requireString(s, `${at}.scenario[${j}]`, 'scenario'));
58
+ }
59
+ });
60
+ return value;
61
+ },
62
+ };
63
+ /** 正文首行 → 标题(投影内不引 client 的 format 工具,保持服务端自足)。 */
64
+ function firstLine(content) {
65
+ const line = content.split('\n').find((l) => l.trim().length > 0) ?? content;
66
+ const trimmed = line.trim();
67
+ return trimmed.length > TITLE_MAX ? trimmed.slice(0, TITLE_MAX) : trimmed;
68
+ }
69
+ /** 读 store 建态;ids 与旧态一致时返回旧引用(避免无谓 republish)。 */
70
+ function buildState(sessionId, store, prev) {
71
+ const ids = store.list(sessionId);
72
+ if (prev !== undefined && prev.rev === store.revision() && sameIds(prev.ids, ids))
73
+ return prev;
74
+ const next = { rev: store.revision(), sessionId, ids };
75
+ // rev 变了但本会话集合没变(别的会话写了)→ 仍复用旧引用,防跨会话 thrash。
76
+ if (prev !== undefined && prev.sessionId === sessionId && sameIds(prev.ids, ids))
77
+ return prev;
78
+ return next;
79
+ }
80
+ function sameIds(a, b) {
81
+ if (a.length !== b.length)
82
+ return false;
83
+ for (let i = 0; i < a.length; i++)
84
+ if (a[i] !== b[i])
85
+ return false;
86
+ return true;
87
+ }
88
+ /** view 引用稳定:同一 state 恒返回同一对象(registry 按 Object.is 判发布)。 */
89
+ const viewMemo = new WeakMap();
90
+ function makeView(state, l1) {
91
+ const memoized = viewMemo.get(state);
92
+ if (memoized !== undefined)
93
+ return memoized;
94
+ const recentFirst = [...state.ids].reverse();
95
+ const picked = recentFirst.slice(0, RECALL_VIEW_CAP);
96
+ const byId = new Map((l1?.getByIds(picked) ?? []).map((r) => [r.id, r]));
97
+ const items = [];
98
+ for (const id of picked) {
99
+ const r = byId.get(id);
100
+ if (r === undefined)
101
+ continue; // 记录已退场/被删:诚实跳过,不编造条目
102
+ const scenario = Array.isArray(r.metadata?.dsh_usage_scenario)
103
+ ? r.metadata?.dsh_usage_scenario
104
+ : undefined;
105
+ items.push({
106
+ id: r.id,
107
+ type: r.type,
108
+ title: firstLine(r.content),
109
+ priority: r.priority,
110
+ ...(scenario !== undefined && scenario.length > 0 ? { scenario } : {}),
111
+ });
112
+ }
113
+ const next = { rev: state.rev, sessionId: state.sessionId, count: state.ids.length, items };
114
+ viewMemo.set(state, next);
115
+ return next;
116
+ }
117
+ /** 从宿主 header 取会话 id;形状不符 → 空串(读不到任何桶,view 自然为空,不抛)。 */
118
+ function sessionIdOf(header) {
119
+ if (typeof header !== 'object' || header === null)
120
+ return '';
121
+ const id = header.id;
122
+ return typeof id === 'string' ? id : '';
123
+ }
124
+ /**
125
+ * 注册 memoryRecall 投影。
126
+ *
127
+ * 缺席降级同 slots 投影:`ctx.inject` 在服务未提供时不回调;形状不符静默不注册。
128
+ * apply 是 O(1) 判脏(store.revision() 比对),任意已提交事件都会带进来看一眼。
129
+ */
130
+ export function registerRecallProjection(ctx, store, l1) {
131
+ ctx.inject(['sessionProjections'], (injected) => {
132
+ const registry = injected.sessionProjections;
133
+ if (registry === undefined || typeof registry.register !== 'function')
134
+ return;
135
+ // 双代平面(同 slots 投影):0.1.0-rc.8 的 registry 走平面 schema/view,0.1.1+ 走 wire。
136
+ const definition = {
137
+ key: MEMORY_RECALL_KEY,
138
+ stateSchema,
139
+ init: (header) => buildState(sessionIdOf(header), store),
140
+ apply: (state) => buildState(state.sessionId, store, state),
141
+ wire: { viewSchema, view: (state) => makeView(state, l1) },
142
+ schema: viewSchema,
143
+ view: (state) => makeView(state, l1),
144
+ stateVersion: 0,
145
+ };
146
+ registry.register(definition);
147
+ });
148
+ }
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * 激活槽位服务端投影(memorySlots)。
3
3
  *
4
+ * 展示契约:本 key 由 **dsh-brief-sidebar** 消费(「记忆槽位分区」)。宿主契约说明
5
+ * 不注册时 key 会从快照消失、客户端读作 capability absence(降级优雅,不致加载失败),
6
+ * 所以摘注册不会弄坏会话,但会让旧版消费方默默丢分区——要隐藏那张卡请走它的开关。
7
+ * 另:会话召回的展示走 `memoryRecall` 投影与 client 浮层(SessionRecalledArea),不得灌进本投影。
8
+ *
4
9
  * 只注册服务端单元,绝不做 client——前端展示由未来 brief-sidebar 消费(下一轮)。
5
10
  * 注册经 ctx.inject(['sessionProjections'], …):服务缺席时静默不注册、不抛错
6
11
  * (注册表里没有 key 时客户端读 faceOf(key) 恒为 undefined,侧边栏栏位自然隐藏)。
@@ -156,13 +156,20 @@ export function registerSlotsProjection(ctx, store, l1) {
156
156
  .sessionProjections;
157
157
  if (registry === undefined || typeof registry.register !== 'function')
158
158
  return;
159
- registry.register({
159
+ // 双代平面(TL beta.29 同款判别式):0.1.0-rc.8 的 registry 是唯一的平面契约
160
+ // (def.schema.parse(def.view(state))),0.1.1+ 消费 wire:{viewSchema,view}。
161
+ // wire 形状镜像成平面键挂上,两代 registry 各取所需;一个缺 schema 的注册
162
+ // 单元会毒化旧 registry 的全部冷会话历史加载。
163
+ const definition = {
160
164
  key: MEMORY_SLOTS_KEY,
161
165
  stateSchema,
162
166
  init: () => buildState(store, l1),
163
167
  apply: (state, event) => applySlotsEvent(state, event, store, l1),
164
168
  wire: { viewSchema, view },
169
+ schema: viewSchema,
170
+ view,
165
171
  stateVersion: 0,
166
- });
172
+ };
173
+ registry.register(definition);
167
174
  });
168
175
  }
@@ -82,6 +82,9 @@ ${GRAPH_NODE_TYPES.join(' | ')}
82
82
 
83
83
  ${GRAPH_RELATION_WORDS.join('、')}(也可用其他贴切的中文动词短语,≤${GRAPH_CAP.relation} 字)
84
84
 
85
+ 当记录**显式陈述了因果**时,优先用因果边表达(如「X 导致 Y」「X 修复了 Y」「X 因为 Y」「A 取代 B」),
86
+ 这能让前因后果跨记录成链。因果边只写在**记录里有明确依据**的关系上,不做常识推断(见铁律)。
87
+
85
88
  ## 字段上限
86
89
 
87
90
  - name ≤${GRAPH_CAP.name} 字;aliases ≤${GRAPH_CAP.aliases} 条;tags ≤${GRAPH_CAP.tags} 个