dsh-loci 2.3.1 → 2.4.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/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html) from 1.0.0.
6
6
 
7
+ ## [2.4.0] — 2026-11-15
8
+
9
+ Three items from `docs/PRIOR-ART.md` — the systematic pass over what the field does
10
+ better, judged against this plugin's four invariants rather than by popularity.
11
+
12
+ ### Added
13
+
14
+ - **Lesson shape (`pattern` / `fix` / `prevents` / `whenCondition`)**, schema v3. A
15
+ lesson stored as free text is unreadable six months later: you remember the rule but
16
+ not why it exists. `prevents` is where that answer lives, and it is the one clause
17
+ appended to the index line for `procedural` entries — a rule without its consequence
18
+ gets ignored. Ordinary facts render byte-identically to before, and the fields never
19
+ raise trust.
20
+
21
+ - **`conflicts` / `extends` relations.** `memory_edge` has always had a `relation`
22
+ column but only ever held `supersedes`, so every correction was a full replacement.
23
+ Real corrections are partial — "the rollback window is 45 minutes now, but database
24
+ changes still get 30" contradicts one clause and confirms another. Now a caller can
25
+ declare a weaker relation, both entries stay readable, and `loci_get` shows the link.
26
+
27
+ - **Touched files in the episodic summary.** "Where were we last time" was a question
28
+ this project had to answer by hand. File paths are now extracted from tool-call
29
+ arguments and recorded on the session summary, which is never injected — so it costs
30
+ the prompt nothing and only helps `loci_search`.
31
+
32
+ ### Migration
33
+
34
+ Schema v2 → v3 adds four nullable columns; no row can be lost and every existing entry
35
+ simply has no lesson shape yet. The integration test now walks the whole shipped chain
36
+ v1 → v3 rather than a single step.
37
+
38
+ ### Not done, on purpose
39
+
40
+ Auto-triggered injection (`when` matching decides what gets injected) stays rejected:
41
+ it breaks prefix stability and lets memory content decide its own admission. The
42
+ condition is **stored and shown, never used to select**. Recorded with the rest of the
43
+ rejections in `docs/PRIOR-ART.md` §2.
44
+
7
45
  ## [2.3.1] — 2026-11-15
8
46
 
9
47
  ### Fixed
package/client.js CHANGED
@@ -131,6 +131,12 @@ window.__ModuleLoader__.load({
131
131
  'panel.history': 'History',
132
132
  'panel.evidence': 'Evidence',
133
133
  'panel.source': 'Source',
134
+ 'panel.lesson': 'Lesson',
135
+ 'panel.lesson.pattern': 'pattern',
136
+ 'panel.lesson.fix': 'fix',
137
+ 'panel.lesson.prevents': 'prevents',
138
+ 'panel.lesson.when': 'when',
139
+ 'panel.relations': 'Relations',
134
140
  'panel.candidates': 'Candidates',
135
141
  'panel.disputed': 'Disputed',
136
142
  'panel.noCandidates': 'Nothing waiting for review.',
@@ -254,6 +260,12 @@ window.__ModuleLoader__.load({
254
260
  'panel.history': '历史',
255
261
  'panel.evidence': '证据',
256
262
  'panel.source': '出处',
263
+ 'panel.lesson': '教训',
264
+ 'panel.lesson.pattern': '问题形态',
265
+ 'panel.lesson.fix': '做法',
266
+ 'panel.lesson.prevents': '不做会怎样',
267
+ 'panel.lesson.when': '适用场景',
268
+ 'panel.relations': '关联',
257
269
  'panel.candidates': '候选',
258
270
  'panel.disputed': '争议',
259
271
  'panel.noCandidates': '没有待审条目。',
@@ -742,7 +754,7 @@ window.__ModuleLoader__.load({
742
754
 
743
755
  function Detail({ id, t, onBack, onChange }) {
744
756
  const { data, error, loading, reload } = useJson(`/item?id=${encodeURIComponent(id)}&history=1`);
745
- const entry = data?.entry;
757
+ // \edges\ is a sibling of \entry\ in the API payload; fold it in so the detail view\n // can show declared relations without a second request.\n const entry = data?.entry === undefined ? undefined : { ...data.entry, edges: data.edges ?? [] };
746
758
  const act = async (path, body) => {
747
759
  try {
748
760
  await apiFetch(path, { method: 'POST', body: JSON.stringify(body) });
@@ -810,6 +822,43 @@ window.__ModuleLoader__.load({
810
822
  ),
811
823
  h('div', { className: 'loci-section' }, 'Body'),
812
824
  h('pre', { className: 'loci-pre' }, entry.body),
825
+ // A lesson's shape, and the relations that record a partial correction.
826
+ // Both are read-only here: they come from the write that created the entry.
827
+ ['pattern', 'fix', 'prevents', 'when'].some((key) => typeof entry[key === 'when' ? 'whenCondition' : key] === 'string')
828
+ ? h(
829
+ 'div',
830
+ null,
831
+ h('div', { className: 'loci-section' }, t('panel.lesson')),
832
+ h(
833
+ 'dl',
834
+ { className: 'loci-kv' },
835
+ ...['pattern', 'fix', 'prevents', 'when'].flatMap((key) => {
836
+ const value = entry[key === 'when' ? 'whenCondition' : key];
837
+ return typeof value === 'string' && value.length > 0
838
+ ? [h('dt', { key: `${key}-k` }, t(`panel.lesson.${key}`)), h('dd', { key: `${key}-v` }, value)]
839
+ : [];
840
+ }),
841
+ ),
842
+ )
843
+ : null,
844
+ (entry.edges ?? []).some((edge) => edge.relation === 'conflicts' || edge.relation === 'extends')
845
+ ? h(
846
+ 'div',
847
+ null,
848
+ h('div', { className: 'loci-section' }, t('panel.relations')),
849
+ ...(entry.edges ?? [])
850
+ .filter((edge) => edge.relation === 'conflicts' || edge.relation === 'extends')
851
+ .map((edge) => {
852
+ const other = edge.src_id === entry.id ? edge.dst_id : edge.src_id;
853
+ return h(
854
+ 'div',
855
+ { key: `${edge.relation}-${other}`, className: 'loci-meta' },
856
+ h('span', null, edge.relation),
857
+ h('code', { className: 'loci-id' }, other),
858
+ );
859
+ }),
860
+ )
861
+ : null,
813
862
  entry.sourceRef
814
863
  ? h(
815
864
  'div',
@@ -0,0 +1,121 @@
1
+ # PRIOR ART — 我们从别人那里借鉴什么、拒绝什么,以及为什么
2
+
3
+ - 状态:living(每次评估新插件后更新)
4
+ - 读者:本项目维护者与后续 agent(内部设计文档)
5
+ - 用途:把「看过哪些实现、为什么采纳/拒绝」记在一处,避免重复调研,也避免我凭印象做决定。
6
+
7
+ > 这份文档的存在原因:本项目一度是"用户提一个、我做一个"的反应式开发。
8
+ > 2026-11-15 的一次反问("不要我说一个你就搞一个")之后补上系统性盘点。
9
+
10
+ ## 0. 评估基准(loci 的四条硬不变量)
11
+
12
+ 一条设计值不值得抄,先过这四关。违反任何一条的,无论多流行,都不抄:
13
+
14
+ | # | 不变量 | 含义 |
15
+ |---|---|---|
16
+ | **I1** | 注入闸门 | 只有 `verified` 进提示词;candidate 必须经人核实 |
17
+ | **I2** | 前缀稳定 | 无写入时,注入段字节级不变(保 KV/prefix cache) |
18
+ | **I3** | 默认零网络 | 除两条可选出网路径外不发请求 |
19
+ | **I4** | 作用域显式 | 读永不越界;不存在"猜出来的"作用域 |
20
+
21
+ ## 1. 已采纳(2026-11 三次发布)
22
+
23
+ | 来源 | 借鉴点 | 我们怎么落地 | 版本 |
24
+ |---|---|---|---|
25
+ | Claude Code memory | 索引 + 主题文件两层;正文永不入提示词 | 索引段只放 id/kind/日期/标题,正文靠 `loci_get` | 1.0 |
26
+ | mem0 | 注入字节数作为一等指标 | `request_stat.section_bytes` + 面板注入记录 | 1.0 |
27
+ | mem0 | 截断要吵,不能静默 | `total`/`hasMore`/`cursor` + "还有 N 条" | 1.x |
28
+ | supermemory | 推断事实要人审(approve/decline) | 待审队列 + `verified` 只能人工授予 | 1.3 |
29
+ | dsh-auto-memory | 4 态信任机 + 逐字证据 | candidate/verified/disputed/superseded + quote 闸门 | 1.3 |
30
+ | dsh-ltm | 修订 CAS、迁移前快照 | `expectedRevision` + `VACUUM INTO` | 1.x |
31
+ | Letta / mem0 | 用量是一等事实(不是装饰) | `memory_usage` 表:inject/read 计数 | 2.1 |
32
+ | Claude Code memory | 索引超限要显式告知 | 待审提示行("N 条待审,故意不在此处") | 2.1 |
33
+ | mem0 | 抽取异步、不占用户延迟 | 会话结束一次性 digest,仅写候选 | 2.3 |
34
+
35
+ ## 2. 值得借鉴、尚未做(按性价比排序)
36
+
37
+ ### A 级 — 建议下一版做
38
+
39
+ **A1. 教训的结构化形态(`pattern` / `fix` / `prevents`)**
40
+ - 来源:`dsh-project-memory` 的 `save_lesson`
41
+ - 问题:loci 的 `procedural` 是自由文本。半年后只看到一句话,看不出"不做会怎样"。
42
+ - 落地:schema v3 加可空列 `pattern`/`fix`/`prevents`/`when_condition`;索引行对 procedural 追加 `— prevents: …`(受 `indexLineMaxChars` 约束,默认 120)。
43
+ - 风险:索引行变长 → 触发 I2 退化?不会:只在写入时改变一次,之后字节稳定。
44
+ - 成本:低(一次迁移 + 一处渲染)。
45
+
46
+ **A2. 按活跃度淘汰"注入了但从未读"的条目**
47
+ - 来源:`dsh-memory-tiers`(六层 + "忘"是流程的一步)、`mem0`(decay 只在检索时生效)、`dsh-fish-memory`(逐条衰减)
48
+ - 问题:我们 2.1 装了电表(inject/read 计数),却没有任何策略消费它。
49
+ - 落地:`procedural` 类中 `read_count = 0 且 inject_count ≥ N` 者从索引段降级(仍可 `loci_search`),`doctor` 单列一行。
50
+ - 风险:误伤"读了但没 `loci_get`(索引行已够用)"的条目 → 用 `read_count + 被引用次数` 双阈值,并先只影响 procedural。
51
+ - 成本:中(策略 + 统计口径)。
52
+
53
+ **A3. 冲突的显式表达(而不只是 supersede)**
54
+ - 来源:`memory-eternal` / `supermemory`(`updates`/`extends`/`derives` 边 + `isLatest`)
55
+ - 问题:我们现在**只能**"新条目取代旧条目"。真实场景常是"这条改了一半"——例如"回滚窗口 30 分钟"→"回滚窗口 45 分钟,但数据库变更仍是 30 分钟"。
56
+ - 落地:`memory_edge` 表已有 `relation` 字段,但**全库只用了一个取值** `supersedes`(`store.js:405`);补 `conflicts`/`extends` 两种即可复用现有表与索引,无需迁移。`loci_get` 显示"与 m_yyy 冲突/延伸";注入段不改(仍旧一条),冲突在面板里呈现。
57
+ - 风险:低(只增边,不改注入)。
58
+ - 成本:中。
59
+
60
+ **A4. 会话工作状态的"最小接续面"(TaskBridge 的可借部分)**
61
+ - 来源:`dsh-project-memory` 的 **TaskBridge**(跨会话任务 + 触碰过的文件,切走再续接)
62
+ - 问题:这次会话里我反复手工重建"改过哪些文件、下一步是什么"。loci 现在的 episodic 摘要只存会话文字片段(`capture.js:373-389`),**不记录触碰过的文件**,所以下个会话搜不到"上次改到哪"。
63
+ - 落地(最小版,不引入任务面板):在 episodic 摘要里增加一个结构化字段 `filesTouched`(从本会话工具调用的路径参数提取,去重、限量 30 条),并把它写进摘要正文的一个短列表。episodic **本来就不注入**(`capture.js` 注释:历史不是主张),所以对 **I1/I2 零影响**——只改善"按需检索"。
64
+ - 明确不做:任务面板、todo 同步、跨会话绑定(那是 `dsh-project-memory` 的完整产品面)。
65
+ - 成本:低(一次提取 + 一处写入)。价值:高(解决"接续要重新问一遍")。
66
+
67
+ ### B 级 — 有价值,但需要先想清楚
68
+
69
+ **B1. 报告"记忆缺失"的能力**
70
+ - 来源:`dsh-auto-memory` 的 recall funnel(注入了但从没被读)、`mem0` 的 mean tokens/query
71
+ - 想法:区分"没有这条记忆"和"有但没召回"。我们已经有 `loci_search` 的 `matchedTotal: null`,但没有"这次会话模型查了什么、没查到什么"的视图。
72
+ - 需要想清楚:这属于诊断还是产品?会不会变成又一个用户不看的统计页?
73
+
74
+ **B2. 导入/导出与跨工具互通**
75
+ - 来源:`dsh-cross-memory`(一个库接 DSH/Claude/Codex/Cursor)、Claude Code 的 `@path` 导入
76
+ - 现状:我们只做了 auto-memory 与 Claude memory 的**单向导入**。
77
+ - 需要想清楚:双向同步会引入冲突解决,而冲突解决正是 A3 还没做的。→ 先做 A3,再谈互通。
78
+
79
+ **B3. 常驻"画像"层(persona)**
80
+ - 来源:`dsh-cross-memory` 的 `persona/human.md`(会话开始注入快照,可锁定防后台改写)
81
+ - 想法:把我们散在 `global` scope 的偏好收敛成一个可锁定的小块。
82
+ - 顾虑:与索引段功能重叠;且"画像被改写"是用户最敏感的失败。**倾向不做**,除非有真实需求。
83
+
84
+ ### C 级 — 明确拒绝(并记录理由)
85
+
86
+ | 拒绝项 | 来源 | 理由 |
87
+ |---|---|---|
88
+ | 条件注入(`when`/`guard` 自动触发) | `dsh-project-memory`、`supermemory` | 违反 **I2**:每步都可能改段内容,KV 前缀失效;也违反"可预测"的产品承诺。可选折中:`when_condition` 只存不判,供检索与面板显示 |
89
+ | 写入时自动 cosine 合并(0.9 覆盖) | `dsh-project-memory` | 0.9 相似也可能语义相反("必须做 X" vs "不许做 X")。我们保留手动 `merge` + 乐观锁 |
90
+ | 空闲"做梦"式整合 | `meow-memory`、Letta `dreaming`、`zzerx` | 后台无监督改写记忆,是"我明明没改它却变了"的来源。**必须有人在场** |
91
+ | 自动 scope 晋升(task→project→global) | `dsh-project-memory` 的两/三次晋升 | 违反 **I4**:作用域由使用次数漂移,跨项目泄漏风险不可接受 |
92
+ | 每轮全量抽取 | `zzerx`、`dsh-cross-memory` 后台抽取 | 每轮一次模型调用 + 每轮一条待审,实测会让人放弃审阅(2.3 的 digest 就是为此而生) |
93
+ | 把语料正文当注入触发源 | 多家 | 正文可被污染 → 等于让记忆内容决定自己是否入提示词 |
94
+ | 外部记忆服务(Hindsight / Honcho / supermemory 云) | `dsh-hindsight`、mnemon providers | 违反 **I3**;且我们权衡过:一次网络往返换来的召回提升不值得把记忆送出去 |
95
+ | 常驻大块(resident USER.md/MEMORY.md 全文注入) | `dsh-mnemon` | 提示词成本不可控;索引 + 按需取正文是更省的形状 |
96
+ | **代码/文档索引("读到即索引")** | `dsh-project-memory`、`basic-memory` | 那是**对仓库内容的 RAG**,不是记忆。加进来会多一条检索通道、一套过期语义和一份"代码的第二份副本";代码与 AGENTS.md 已是 SSOT,`grep` 比索引更可信 |
97
+ | **自动触发式注入(`when` 命中即注入)** | `dsh-project-memory`、`supermemory` | 只保留"存条件"(见 A1 的 `when_condition`),**不保留"由条件决定是否注入"**;理由同样是 **I2** |
98
+
99
+ ## 3. 口径变更记录(为什么不一致,而不是假装一致)
100
+
101
+ 这份文档的第一版与我会话里的口头建议**不一致**,此处记录差异与裁决:
102
+
103
+ | 我先前口头说的 | 文档最初的处置 | 现在的裁决 |
104
+ |---|---|---|
105
+ | 触发式注入"值得借鉴" | 直接进了 C 级拒绝 | **改判为部分采纳**:`when_condition` 存而不判(A1)。口头说"值得借鉴"时我只想到"条件命中更准",没想到它会破坏 I2 与"正文不能决定自己是否注入"这条安全线 |
106
+ | TaskBridge"值得借鉴" | **漏写**(文档里根本没有) | **补为 A4**:只借"会话工作状态的最小接续面"(episodic 里记 `filesTouched`),不做任务面板 |
107
+ | 读到即索引"值得借鉴" | **漏写** | **补为 C 级拒绝**:它是 RAG,不是记忆;与 loci 的产品边界冲突 |
108
+
109
+ 教训:口头列"值得借鉴"很容易把三个不同性质的层次(结构化字段 / 工作状态 / 检索架构)混成一类。
110
+ **判据必须写在前面(本文 §0 的四条不变量),否则"值得"只是感觉。**
111
+
112
+ ## 4. 一条元教训(比上面任何功能都重要)
113
+
114
+ 看过的 8 个 DSH 记忆插件里,**只有 2 个把"未核实不进提示词"做成代码级不变量**(loci 与 auto-memory),其余的都靠 prompt 里的劝说或干脆没有这层。
115
+ 也就是说:这个品类里"功能更多"几乎总是意味着"闸门更松"。**我们不追功能数量,追的是"它出错时最坏能坏到什么程度"。**
116
+
117
+ ## 5. 未覆盖的部分(诚实边界)
118
+
119
+ - 只用第一手材料评估了 7 个 DSH 插件(本地有 README/源码)+ 5 个成熟产品(文档级)。
120
+ - DSH 目录里 memory 类约 473 条,其中 `meow-memory`(~36k 下载)、`dsh-plugin-memory`(~41k)、`dsh-memento`、`dsh-prime-memory`、`dsh-fish-memory` 只从二手报告里见过描述,**未读源码**。
121
+ - 因此本表是"有依据的取舍",不是"穷尽式技术扫描"。新插件出现后按同一张不变量表复评。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loci",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Layered long-term memory for DeepSeek Harness: the prompt sees only a stable index, full text is one lookup away. Bi-temporal facts, strict scope isolation, verified-only injection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -118,6 +118,9 @@ export class Capture {
118
118
  if (buffer === undefined) {
119
119
  buffer = {
120
120
  messages: [],
121
+ // Paths this session actually touched, most recent last. A tool call is the only
122
+ // honest source: it is what the model asked to do, not what it said it did.
123
+ files: [],
121
124
  bytes: 0,
122
125
  captures: 0,
123
126
  timer: undefined,
@@ -141,6 +144,35 @@ export class Capture {
141
144
  }
142
145
  }
143
146
 
147
+ /**
148
+ * Pull file paths out of one tool call.
149
+ *
150
+ * Deliberately narrow: only the argument names that actually carry paths, and only
151
+ * values that still look like a path after trimming. A false positive here would put
152
+ * a sentence into the resumption list, which is worse than missing a file.
153
+ *
154
+ * @param buffer - the session buffer.
155
+ * @param name - tool name (kept for the debug trail).
156
+ * @param rawArguments - the JSON argument string.
157
+ */
158
+ #noteFiles(buffer, name, rawArguments) {
159
+ const KEYS = /"(path|file|file_path|filePath|dir|directory|cwd|target|pattern|notebook_path)"\s*:\s*"([^"]{1,300})"/g;
160
+ let match;
161
+ let added = 0;
162
+ while ((match = KEYS.exec(rawArguments)) !== null && added < 8) {
163
+ const value = match[2].replace(/\\/g, '/').trim();
164
+ if (value.length < 2 || value.length > 300) continue;
165
+ // A glob is not a path someone can open; keep the directory part when it has one.
166
+ const cleaned = value.replace(/\*\*?.*$/, '').replace(/\/$/, '');
167
+ if (cleaned.length < 2) continue;
168
+ if (buffer.files.includes(cleaned)) continue;
169
+ buffer.files.push(cleaned);
170
+ added += 1;
171
+ if (buffer.files.length > 40) buffer.files.shift();
172
+ }
173
+ if (added > 0) this.log?.debug?.(`loci: ${name} touched ${added} path(s)`);
174
+ }
175
+
144
176
  /** Record one event; never throws into the session commit path. */
145
177
  onSessionEvent(session, event) {
146
178
  try {
@@ -164,6 +196,14 @@ export class Capture {
164
196
  this.#trim(buffer);
165
197
  return;
166
198
  }
199
+ if (event.type === 'tool/call') {
200
+ const name = String(event.data?.name ?? '');
201
+ const args = event.data?.arguments;
202
+ if (typeof args === 'string' && Buffer.byteLength(args, 'utf8') <= 8192) {
203
+ this.#noteFiles(buffer, name, args);
204
+ }
205
+ return;
206
+ }
167
207
  if (event.type === 'turn/end') {
168
208
  buffer.turn = Number(event.data?.turn ?? buffer.turn);
169
209
  if (event.data?.reason?.kind === 'completed') {
@@ -382,7 +422,11 @@ export class Capture {
382
422
  scopeKey: this.#workspaceKey(buffer.cwd, sessionId),
383
423
  scopeLabel: this.#workspaceLabel(buffer.cwd),
384
424
  title,
385
- body,
425
+ body: buffer.files.length > 0
426
+ // The episodic entry is history: it is never injected, so this list costs the
427
+ // prompt nothing and only helps `loci_search` answer "where were we".
428
+ ? `${body}\n\nFiles touched this session:\n${buffer.files.slice(-20).map((file) => `- ${file}`).join('\n')}`
429
+ : body,
386
430
  trustSource: 'summary',
387
431
  sourceSession: sessionId,
388
432
  sourceEvent: buffer.messages.at(-1)?.seq,
package/src/host/index.js CHANGED
@@ -23,7 +23,7 @@ import { registerCommands } from './commands.js';
23
23
  import { runDoctor } from './doctor.js';
24
24
 
25
25
  /** Version stamped into the database and diagnostics. */
26
- export const VERSION = '2.3.1';
26
+ export const VERSION = '2.4.0';
27
27
 
28
28
  /** Optional collaborators; without them the plugin still serves tools. */
29
29
  export const inject = ['systemPrompt', 'tools'];
@@ -108,7 +108,16 @@ export function renderIndexBody(options) {
108
108
  // candidates used to render an empty "Index —" heading, which reads as "memory is
109
109
  // empty" right next to the line saying something is waiting.
110
110
  const indexHeader = visible.length > 0 ? `Index — ${renderSafe(indexTitle(scopeLabel, 120))}` : '';
111
- const entryLine = (entry) => `- ${entry.id} · ${entry.kind} · ${formatDate(entry.validFrom)} · ${renderSafe(indexTitle(entry.title, lineChars))}`;
111
+ // A lesson's one-line summary carries what it prevents, because that is the part a
112
+ // reader needs to act on it: the rule without the consequence gets ignored. Only for
113
+ // `procedural` entries that actually have the field, so ordinary facts are byte-identical
114
+ // to before.
115
+ const PREVENTS_CHARS = 60;
116
+ const entryLine = (entry) => {
117
+ const head = `- ${entry.id} · ${entry.kind} · ${formatDate(entry.validFrom)} · ${renderSafe(indexTitle(entry.title, lineChars))}`;
118
+ if (entry.kind !== 'procedural' || typeof entry.prevents !== 'string' || entry.prevents.length === 0) return head;
119
+ return `${head} — prevents: ${renderSafe(indexTitle(entry.prevents, PREVENTS_CHARS))}`;
120
+ };
112
121
 
113
122
  const footerFor = (listed) => {
114
123
  const hidden = Math.max(0, total - listed);
@@ -12,7 +12,7 @@
12
12
  import { copyFileSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { backupTo, tableCounts, withTransaction } from './db.js';
15
- import { MEMORY_USAGE_DDL } from './schema.js';
15
+ import { MEMORY_LESSON_DDL, MEMORY_USAGE_DDL } from './schema.js';
16
16
  import {
17
17
  META_KEYS,
18
18
  SCHEMA_VERSION,
@@ -40,6 +40,15 @@ export const SHIPPED_MIGRATIONS = [
40
40
  db.exec(MEMORY_USAGE_DDL);
41
41
  },
42
42
  },
43
+ {
44
+ from: 2,
45
+ to: 3,
46
+ // Four nullable columns: no row can be lost, and every existing entry simply has
47
+ // no lesson shape yet.
48
+ up(db) {
49
+ db.exec(MEMORY_LESSON_DDL);
50
+ },
51
+ },
43
52
  ];
44
53
 
45
54
  /**
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  /** Schema version this build writes and expects. */
11
- export const SCHEMA_VERSION = 2;
11
+ export const SCHEMA_VERSION = 3;
12
12
 
13
13
  /**
14
14
  * DDL for the v2 per-entry usage table.
@@ -18,6 +18,21 @@ export const SCHEMA_VERSION = 2;
18
18
  * that only exists on new installs is the classic way a feature "works on my
19
19
  * machine".
20
20
  */
21
+ /**
22
+ * DDL for the v3 lesson fields.
23
+ *
24
+ * A lesson stored as free text is unreadable six months later: you remember the rule
25
+ * but not why it exists. `prevents` is the field that answer lives in, `pattern` and
26
+ * `fix` split the problem from the remedy, and `when_condition` records the situation
27
+ * the lesson was written for — stored, shown, never used to decide injection.
28
+ */
29
+ export const MEMORY_LESSON_DDL = `
30
+ ALTER TABLE memory ADD COLUMN pattern TEXT;
31
+ ALTER TABLE memory ADD COLUMN fix TEXT;
32
+ ALTER TABLE memory ADD COLUMN prevents TEXT;
33
+ ALTER TABLE memory ADD COLUMN when_condition TEXT;
34
+ `;
35
+
21
36
  export const MEMORY_USAGE_DDL = `
22
37
  CREATE TABLE IF NOT EXISTS memory_usage (
23
38
  id TEXT PRIMARY KEY,
@@ -67,6 +82,11 @@ CREATE TABLE IF NOT EXISTS memory (
67
82
  tier INTEGER NOT NULL DEFAULT 2,
68
83
  importance INTEGER NOT NULL DEFAULT 3,
69
84
  content_hash TEXT NOT NULL,
85
+ -- v3: a lesson's shape. All nullable; ordinary facts leave them empty.
86
+ pattern TEXT,
87
+ fix TEXT,
88
+ prevents TEXT,
89
+ when_condition TEXT,
70
90
  created_at INTEGER NOT NULL,
71
91
  updated_at INTEGER NOT NULL,
72
92
  deleted_at INTEGER,
@@ -22,7 +22,17 @@ import { migrate } from './migrate.js';
22
22
  import { EmbeddingChannel, vectorToBlob } from './embedding.js';
23
23
  import { META_KEYS, SCHEMA_VERSION, databaseVersion, readMeta, writeMeta } from './schema.js';
24
24
  import { normalizeText, tokenStream, tokenize, buildQueryPlan } from './tokenize.js';
25
- import { normalizeTitle, prepareForStorage } from '../inject/sanitize.js';
25
+ import { cleanText, normalizeTitle, prepareForStorage } from '../inject/sanitize.js';
26
+
27
+ /**
28
+ * Relations a caller may declare between two facts.
29
+ *
30
+ * `supersedes` is written by the store itself (a revision replaces its predecessor).
31
+ * The other two exist because real corrections are partial: `conflicts` says "these
32
+ * disagree and a person must decide", `extends` says "this adds to that and both
33
+ * hold". Neither changes what is injected — an entry still needs its own verification.
34
+ */
35
+ export const RELATIONS = Object.freeze(new Set(['conflicts', 'extends']));
26
36
 
27
37
  /** Ids that were revoked or never existed. */
28
38
  export class MemoryNotFoundError extends Error {
@@ -76,6 +86,11 @@ function mapRow(row) {
76
86
  supersedes: row.supersedes ?? null,
77
87
  supersededBy: row.superseded_by ?? null,
78
88
  evidence: JSON.parse(row.evidence ?? '{}'),
89
+ /** The lesson shape, when this entry is one. Null for ordinary facts. */
90
+ pattern: row.pattern ?? null,
91
+ fix: row.fix ?? null,
92
+ prevents: row.prevents ?? null,
93
+ whenCondition: row.when_condition ?? null,
79
94
  /** Citation back to the code or document the fact came from. */
80
95
  sourceRef: JSON.parse(row.evidence ?? '{}').ref ?? null,
81
96
  sourceSession: row.source_session ?? null,
@@ -211,6 +226,7 @@ export class Store {
211
226
  const evidence = prepared.evidence ?? {};
212
227
  const status = this.#resolveStatus({ input, prepared, evidence, trustSource });
213
228
 
229
+ const lesson = this.#lessonFields(input);
214
230
  const contentHash = createHash('sha256')
215
231
  .update(`${normalizeTitle(prepared.title)}\n${normalizeText(prepared.body)}`, 'utf8')
216
232
  .digest('hex');
@@ -299,7 +315,9 @@ export class Store {
299
315
  now,
300
316
  verifiedBy: status === 'verified' ? (input.verifiedBy ?? trustSource) : null,
301
317
  verifiedAt: status === 'verified' ? now : null,
318
+ ...lesson,
302
319
  });
320
+ this.#linkRelations(id, 1, input.relations, now);
303
321
  this.bumpIndexRev();
304
322
  return inserted;
305
323
  });
@@ -344,6 +362,7 @@ export class Store {
344
362
  throw new RevisionConflictError(id, input.expectedRevision, Number(head.row.revision));
345
363
  }
346
364
  const prepared = input.preparedOverride ?? prepareForStorage(input, { secretPolicy: this.live('secretPolicy') });
365
+ const lesson = this.#lessonFields(input);
347
366
  if (prepared.rejected) {
348
367
  throw new MemoryRejectedError(`memory contains a high-confidence secret (${prepared.redactions.join(', ')})`);
349
368
  }
@@ -394,6 +413,12 @@ export class Store {
394
413
  pinned: input.pinned === true ? 1 : Number(head.row.pinned),
395
414
  importance: Number.isInteger(input.importance) ? input.importance : Number(head.row.importance),
396
415
  contentHash,
416
+ // A revision that does not restate the lesson keeps the previous shape: fixing a
417
+ // typo in the title must not silently erase why the lesson exists.
418
+ pattern: lesson.pattern ?? head.row.pattern ?? null,
419
+ fix: lesson.fix ?? head.row.fix ?? null,
420
+ prevents: lesson.prevents ?? head.row.prevents ?? null,
421
+ whenCondition: lesson.whenCondition ?? head.row.when_condition ?? null,
397
422
  createdAt: Number(head.row.created_at),
398
423
  now,
399
424
  verifiedBy: status === 'verified' ? (input.verifiedBy ?? 'tool') : null,
@@ -405,6 +430,11 @@ export class Store {
405
430
  VALUES (?, ?, ?, ?, 'supersedes', ?)`,
406
431
  )
407
432
  .run(id, revision, id, Number(head.row.revision), now);
433
+ // A revision is not always a full replacement. "The rollback window is 45 minutes
434
+ // now, but database changes still get 30" contradicts the old entry on one clause
435
+ // and agrees on another; superseding it would destroy the part that is still true.
436
+ // Callers can therefore declare a weaker relation and both facts stay readable.
437
+ this.#linkRelations(id, revision, input.relations, now);
408
438
  this.bumpIndexRev();
409
439
  return inserted;
410
440
  });
@@ -789,7 +819,8 @@ export class Store {
789
819
  const placeholders = keys.map(() => '?').join(', ');
790
820
  const rows = this.db
791
821
  .prepare(
792
- `SELECT m.id, m.revision, m.kind, m.scope, m.title, m.valid_from, m.updated_at, m.pinned, m.created_at, m.tier
822
+ `SELECT m.id, m.revision, m.kind, m.scope, m.title, m.valid_from, m.updated_at, m.pinned, m.created_at, m.tier,
823
+ m.prevents
793
824
  FROM memory m
794
825
  WHERE m.status = 'verified'
795
826
  AND m.deleted_at IS NULL
@@ -808,6 +839,8 @@ export class Store {
808
839
  validFrom: Number(row.valid_from),
809
840
  updatedAt: Number(row.updated_at),
810
841
  pinned: Number(row.pinned) === 1,
842
+ // The index needs one clause of context for a lesson; everything else stays out.
843
+ prevents: row.prevents ?? null,
811
844
  }));
812
845
  }
813
846
 
@@ -1525,6 +1558,59 @@ export class Store {
1525
1558
  return verified;
1526
1559
  }
1527
1560
 
1561
+ /**
1562
+ * Sanitize the optional lesson shape.
1563
+ *
1564
+ * These are free text from a model, so they get the same treatment as a title: control
1565
+ * characters and bidi overrides stripped, length capped. Empty input stays null rather
1566
+ * than an empty string, so "no lesson shape" is one value and not two.
1567
+ *
1568
+ * @param input - caller input.
1569
+ * @returns the four fields, all nullable.
1570
+ */
1571
+ #lessonFields(input) {
1572
+ const clean = (value, maxChars) => {
1573
+ const text = cleanText(typeof value === 'string' ? value : '', maxChars);
1574
+ return text.length === 0 ? null : text;
1575
+ };
1576
+ return {
1577
+ pattern: clean(input?.pattern, 400),
1578
+ fix: clean(input?.fix, 800),
1579
+ prevents: clean(input?.prevents, 300),
1580
+ whenCondition: clean(input?.whenCondition, 300),
1581
+ };
1582
+ }
1583
+
1584
+ /**
1585
+ * Record the relations a caller declared for one revision.
1586
+ *
1587
+ * Silently skips anything unusable — an unknown relation name, a self reference, a
1588
+ * target that does not exist or was deleted. A bad link must never fail the write:
1589
+ * the fact itself is still worth keeping, and a missing edge is recoverable while a
1590
+ * lost fact is not.
1591
+ *
1592
+ * @param id - this entry.
1593
+ * @param revision - the revision the relation was declared on.
1594
+ * @param relations - caller-supplied `[{id, relation}]`.
1595
+ * @param now - timestamp.
1596
+ */
1597
+ #linkRelations(id, revision, relations, now) {
1598
+ if (!Array.isArray(relations) || relations.length === 0) return;
1599
+ const link = this.db.prepare(
1600
+ `INSERT OR IGNORE INTO memory_edge (src_id, src_rev, dst_id, dst_rev, relation, created_at)
1601
+ VALUES (?, ?, ?, ?, ?, ?)`,
1602
+ );
1603
+ for (const relation of relations) {
1604
+ if (relation === null || typeof relation !== 'object') continue;
1605
+ const target = typeof relation.id === 'string' ? relation.id : '';
1606
+ const kind = relation.relation;
1607
+ if (target.length === 0 || !RELATIONS.has(kind) || target === id) continue;
1608
+ const other = this.#headRow(target);
1609
+ if (other === undefined || other.status === 'deleted') continue;
1610
+ link.run(id, revision, target, Number(other.row.revision), kind, now);
1611
+ }
1612
+ }
1613
+
1528
1614
  #insertRevision(row) {
1529
1615
  const result = this.db
1530
1616
  .prepare(
@@ -1532,8 +1618,9 @@ export class Store {
1532
1618
  id, revision, kind, scope, scope_key, scope_label, title, title_norm, body, tags, entities,
1533
1619
  status, trust_source, flags, valid_from, valid_to, recorded_at, supersedes, superseded_by,
1534
1620
  evidence, source_session, source_event, verified_at, verified_by, pinned, importance, tier,
1535
- content_hash, created_at, updated_at, deleted_at
1536
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)`,
1621
+ content_hash, created_at, updated_at, deleted_at,
1622
+ pattern, fix, prevents, when_condition
1623
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,?,?,?,?)`,
1537
1624
  )
1538
1625
  .run(
1539
1626
  row.id,
@@ -1566,6 +1653,12 @@ export class Store {
1566
1653
  row.contentHash,
1567
1654
  row.createdAt,
1568
1655
  row.now,
1656
+ // The four lesson columns sit after `deleted_at` in the column list, which is
1657
+ // bound as a literal NULL, so they come last in this argument list.
1658
+ row.pattern ?? null,
1659
+ row.fix ?? null,
1660
+ row.prevents ?? null,
1661
+ row.whenCondition ?? null,
1569
1662
  );
1570
1663
  const ord = Number(result.lastInsertRowid);
1571
1664
  const text = `${row.title} ${row.body} ${(row.tags ?? []).join(' ')}`;
@@ -131,6 +131,38 @@ export function createTools(deps) {
131
131
  type: 'string',
132
132
  description: 'Where this fact lives in the repository, e.g. "src/host/store.js:120". Shown by loci_get so a reader can check it.',
133
133
  },
134
+ pattern: {
135
+ type: 'string',
136
+ description: 'For kind "procedural": the reusable shape of the problem, e.g. "a script edits a file and the quoting silently mangles it".',
137
+ },
138
+ fix: {
139
+ type: 'string',
140
+ description: 'For kind "procedural": what actually worked, concretely enough to repeat.',
141
+ },
142
+ prevents: {
143
+ type: 'string',
144
+ description:
145
+ 'For kind "procedural": what breaks without this lesson. Shown on the index line, because a rule without its consequence gets ignored.',
146
+ },
147
+ whenCondition: {
148
+ type: 'string',
149
+ description:
150
+ 'For kind "procedural": the situation this lesson was written for. Stored and shown, never used to decide whether the entry is injected.',
151
+ },
152
+ relations: {
153
+ type: 'array',
154
+ description:
155
+ 'Other entries this one touches. "conflicts" records that they disagree and a person must decide; "extends" records that this adds to that and both still hold. Neither replaces the other entry the way an update does.',
156
+ items: {
157
+ type: 'object',
158
+ properties: {
159
+ id: { type: 'string' },
160
+ relation: { type: 'string', enum: ['conflicts', 'extends'] },
161
+ },
162
+ required: ['id', 'relation'],
163
+ additionalProperties: false,
164
+ },
165
+ },
134
166
  evidenceNote: { type: 'string' },
135
167
  validFrom: { type: 'string', description: 'ISO time the fact became true (default: now).' },
136
168
  ttlDays: { type: 'integer', description: 'Close the fact after N days (default: no expiry).' },
@@ -205,6 +237,11 @@ export function createTools(deps) {
205
237
  sourceSession: context.sessionId ?? null,
206
238
  sourceEvent: Number.isSafeInteger(lastUserSeq) ? lastUserSeq : undefined,
207
239
  sourceRef: str(args, 'sourceRef', { fallback: undefined }),
240
+ relations: Array.isArray(args?.relations) ? args.relations : undefined,
241
+ pattern: str(args, 'pattern', { fallback: undefined }),
242
+ fix: str(args, 'fix', { fallback: undefined }),
243
+ prevents: str(args, 'prevents', { fallback: undefined }),
244
+ whenCondition: str(args, 'whenCondition', { fallback: undefined }),
208
245
  evidence: {
209
246
  ...(evidenceQuote === undefined ? {} : { quote: evidenceQuote }),
210
247
  ...(str(args, 'evidenceNote', { fallback: undefined }) === undefined
@@ -514,10 +551,33 @@ function renderGet(value) {
514
551
  lines.push(`source: ${renderSafe(entry.sourceRef)}`);
515
552
  }
516
553
  lines.push('', renderSafe(entry.body));
554
+ // A lesson reads as four labelled lines rather than one paragraph: the problem, the
555
+ // remedy, the consequence, and the situation it was written for.
556
+ for (const [label, value] of [
557
+ ['pattern', entry.pattern],
558
+ ['fix', entry.fix],
559
+ ['prevents', entry.prevents],
560
+ ['when', entry.whenCondition],
561
+ ]) {
562
+ if (typeof value === 'string' && value.length > 0) lines.push(`${label}: ${renderSafe(value)}`);
563
+ }
517
564
  const quote = entry.evidence?.quote;
518
565
  if (typeof quote === 'string' && quote.length > 0) {
519
566
  lines.push('', `evidence: “${renderSafe(quote)}”${entry.sourceSession ? ` (session ${entry.sourceSession})` : ''}`);
520
567
  }
568
+ // Declared relations are the only place a partial correction is visible: the entry
569
+ // itself reads as one fact, and "this disagrees with m_yyy on one clause" lives here.
570
+ const relations = (Array.isArray(value.edges) ? value.edges : []).filter(
571
+ (edge) => edge.relation === 'conflicts' || edge.relation === 'extends',
572
+ );
573
+ if (relations.length > 0) {
574
+ lines.push('', 'relations:');
575
+ for (const edge of relations) {
576
+ const other = edge.src_id === entry.id ? edge.dst_id : edge.src_id;
577
+ const direction = edge.src_id === entry.id ? '→' : '←';
578
+ lines.push(`- ${edge.relation} ${direction} ${other}`);
579
+ }
580
+ }
521
581
  const history = Array.isArray(value.history) ? value.history : [];
522
582
  if (history.length > 1) {
523
583
  lines.push('', `history: ${history.length} revisions`);
@@ -36,7 +36,7 @@ export function openFacadeStore(options = {}) {
36
36
  memoryRoot: root,
37
37
  paths,
38
38
  config,
39
- pluginVersion: '2.3.1',
39
+ pluginVersion: '2.4.0',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };