dsh-loci 2.2.0 → 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 +85 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/client.js +84 -2
- package/docs/PRIOR-ART.md +121 -0
- package/package.json +1 -1
- package/src/host/capture/capture.js +88 -2
- package/src/host/config.js +16 -2
- package/src/host/extract/extractor.js +19 -5
- package/src/host/index.js +1 -1
- package/src/host/inject/index-section.js +10 -1
- package/src/host/store/migrate.js +10 -1
- package/src/host/store/schema.js +21 -1
- package/src/host/store/store.js +101 -5
- package/src/host/tools/tools.js +60 -0
- package/src/mcp/store-facade.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,91 @@ 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
|
+
|
|
45
|
+
## [2.3.1] — 2026-11-15
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **A corrupted line in the config schema.** `2.3.0` failed in CI at
|
|
50
|
+
`.number().n .step(1)` — a patch script wrote a literal `.n` where a newline was
|
|
51
|
+
meant. It passed every local gate, because `buildSchema()` only runs when
|
|
52
|
+
`@deepseek-ai/schemastery` resolves, and a checkout without dependencies never
|
|
53
|
+
executes that line.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `test/unit/config-schema.test.mjs` builds the schema whenever the peer dependency
|
|
58
|
+
is present, and constructs it with the shipped defaults. A broken chain, an
|
|
59
|
+
out-of-range default or a wrong union member now fails a test instead of a tag.
|
|
60
|
+
The corruption above was reintroduced on purpose to confirm this test catches it.
|
|
61
|
+
|
|
62
|
+
### Note
|
|
63
|
+
|
|
64
|
+
`2.3.0` was tagged but **never published** (the CI job stopped before the publish
|
|
65
|
+
step). This is the first version carrying the session digest.
|
|
66
|
+
|
|
67
|
+
## [2.3.0] — 2026-11-15
|
|
68
|
+
|
|
69
|
+
The last open item from the blind review, and the one a heavy daily user called the
|
|
70
|
+
biggest product hole: between "deterministic rules" and "ask the model every turn"
|
|
71
|
+
there was no middle. Per-turn extraction asks for one review decision per turn until
|
|
72
|
+
the queue is abandoned; rules alone miss anything stated in passing.
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- **`extractMode: 'session'`** — one digest per session, run when the session ends,
|
|
77
|
+
proposing at most `extractSessionMaxCandidates` (5) items. Same rules as before:
|
|
78
|
+
candidates only, verbatim-quote grounding, hourly budget, timeout, silent failure.
|
|
79
|
+
`'on'`/`'turn'` keep the per-turn behaviour for anyone who wants it.
|
|
80
|
+
|
|
81
|
+
- **Review groups a digest.** List rows now carry `sourceSession`, and the review tab
|
|
82
|
+
shows "N proposed together" with one button to verify that batch — the shape the
|
|
83
|
+
reviewer asked for, instead of five identical rows and five decisions.
|
|
84
|
+
|
|
85
|
+
### Notes
|
|
86
|
+
|
|
87
|
+
- The digest runs independently of the episodic summary, so a user can want one
|
|
88
|
+
without the other, and a failing model cannot break the session release.
|
|
89
|
+
- The buffer is copied before the asynchronous call: releasing it at dispose must
|
|
90
|
+
not race with the model reading it.
|
|
91
|
+
|
|
7
92
|
## [2.2.0] — 2026-11-15
|
|
8
93
|
|
|
9
94
|
Four reviewers of an independent blind review named the same risk: `engines.dsh`
|
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ The settings page writes the fields marked *live* without a restart.
|
|
|
108
108
|
| `rulesMaxBytes` | `1024` | ceiling for the rules block inside that budget |
|
|
109
109
|
| `defaultScope` | `workspace` | scope a write lands in when it does not say |
|
|
110
110
|
| `captureMode` | `rules` | deterministic capture of explicit durable statements |
|
|
111
|
-
| `extractMode` | `off` | `
|
|
111
|
+
| `extractMode` | `off` | `session` asks the model once, when a session ends, for up to `extractSessionMaxCandidates` proposals — one sitting to review, not one per turn. `turn`/`on` keep the older per-turn behaviour. Always candidates, always budgeted |
|
|
112
112
|
| `recallAssist` | `off` | one bounded first-turn recall block per session |
|
|
113
113
|
| `maintenanceMode` | `sweep` | TTL, index repair, and embedding backfill every 6h |
|
|
114
114
|
| `secretPolicy` | `redact` | redact high-confidence secrets, or refuse the write |
|
package/README.zh.md
CHANGED
|
@@ -78,7 +78,7 @@ node "$HOME/.dsh/profiles/<profile>/node_modules/dsh-loci/scripts/cli.mjs" docto
|
|
|
78
78
|
| `rulesMaxBytes` | `1024` | 段内规则块的字节上限 |
|
|
79
79
|
| `defaultScope` | `workspace` | 未显式指定时的写入作用域 |
|
|
80
80
|
| `captureMode` | `rules` | 规则型捕获用户的明确约定 |
|
|
81
|
-
| `extractMode` | `off` | 设为 `on`
|
|
81
|
+
| `extractMode` | `off` | 设为 `session` 时,在**会话结束**让模型提一次、最多 `extractSessionMaxCandidates` 条——一次坐下审完,而不是每轮审一条。`turn`/`on` 保留旧的逐轮行为。一律落为候选,且有预算上限 |
|
|
82
82
|
| `recallAssist` | `off` | 每会话一次、有上限的首轮辅助召回块 |
|
|
83
83
|
| `maintenanceMode` | `sweep` | 每 6 小时做 TTL、索引修复、向量补齐 |
|
|
84
84
|
| `secretPolicy` | `redact` | 高置信密钥脱敏,或直接拒绝写入 |
|
package/client.js
CHANGED
|
@@ -131,11 +131,19 @@ 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.',
|
|
137
143
|
'panel.reviewHint': 'Only entries that could be injected are listed. A summary of a past session is history, not a claim.',
|
|
138
144
|
'panel.reject': 'Reject',
|
|
145
|
+
'panel.digestOf': '{n} proposed together',
|
|
146
|
+
'panel.verifyGroup': 'Verify these {n}',
|
|
139
147
|
'panel.verifyAll': 'Verify all {n}',
|
|
140
148
|
'panel.actionFailed': 'Action failed',
|
|
141
149
|
'panel.injections': 'Recent injections',
|
|
@@ -252,11 +260,19 @@ window.__ModuleLoader__.load({
|
|
|
252
260
|
'panel.history': '历史',
|
|
253
261
|
'panel.evidence': '证据',
|
|
254
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': '关联',
|
|
255
269
|
'panel.candidates': '候选',
|
|
256
270
|
'panel.disputed': '争议',
|
|
257
271
|
'panel.noCandidates': '没有待审条目。',
|
|
258
272
|
'panel.reviewHint': '这里只列可能被注入的条目;过去会话的摘要属于历史,不是主张。',
|
|
259
273
|
'panel.reject': '驳回',
|
|
274
|
+
'panel.digestOf': '同一批次提出 {n} 条',
|
|
275
|
+
'panel.verifyGroup': '核实这 {n} 条',
|
|
260
276
|
'panel.verifyAll': '全部核实({n})',
|
|
261
277
|
'panel.actionFailed': '操作失败',
|
|
262
278
|
'panel.injections': '最近注入',
|
|
@@ -738,7 +754,7 @@ window.__ModuleLoader__.load({
|
|
|
738
754
|
|
|
739
755
|
function Detail({ id, t, onBack, onChange }) {
|
|
740
756
|
const { data, error, loading, reload } = useJson(`/item?id=${encodeURIComponent(id)}&history=1`);
|
|
741
|
-
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 ?? [] };
|
|
742
758
|
const act = async (path, body) => {
|
|
743
759
|
try {
|
|
744
760
|
await apiFetch(path, { method: 'POST', body: JSON.stringify(body) });
|
|
@@ -806,6 +822,43 @@ window.__ModuleLoader__.load({
|
|
|
806
822
|
),
|
|
807
823
|
h('div', { className: 'loci-section' }, 'Body'),
|
|
808
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,
|
|
809
862
|
entry.sourceRef
|
|
810
863
|
? h(
|
|
811
864
|
'div',
|
|
@@ -985,6 +1038,35 @@ window.__ModuleLoader__.load({
|
|
|
985
1038
|
),
|
|
986
1039
|
h('div', { className: 'loci-actions' }, actions, h('button', { type: 'button', className: 'loci-btn is-quiet', onClick: () => onOpen(item.id) }, '→')),
|
|
987
1040
|
);
|
|
1041
|
+
// A session digest arrives as a batch. Showing five identical-shaped rows with
|
|
1042
|
+
// no grouping is what turns one sitting into five decisions; grouping lets the
|
|
1043
|
+
// reviewer confirm the batch that was proposed together.
|
|
1044
|
+
const bySession = (items) => {
|
|
1045
|
+
const groups = new Map();
|
|
1046
|
+
for (const item of items) {
|
|
1047
|
+
const key = item.sourceSession ?? '';
|
|
1048
|
+
const list = groups.get(key);
|
|
1049
|
+
if (list === undefined) groups.set(key, [item]);
|
|
1050
|
+
else list.push(item);
|
|
1051
|
+
}
|
|
1052
|
+
return groups;
|
|
1053
|
+
};
|
|
1054
|
+
const sessionGroups = (items) =>
|
|
1055
|
+
[...bySession(items).entries()]
|
|
1056
|
+
.filter(([key, list]) => key !== '' && list.length > 1)
|
|
1057
|
+
.map(([key, list]) =>
|
|
1058
|
+
h(
|
|
1059
|
+
'div',
|
|
1060
|
+
{ key, className: 'loci-meta', style: { margin: '10px 0 4px', alignItems: 'center' } },
|
|
1061
|
+
h('span', null, `${t('panel.digestOf').replace('{n}', String(list.length))} · ${key.slice(0, 12)}`),
|
|
1062
|
+
h('span', { className: 'loci-grow' }),
|
|
1063
|
+
h(
|
|
1064
|
+
'button',
|
|
1065
|
+
{ type: 'button', className: 'loci-btn is-primary', disabled: busy !== undefined, onClick: () => verifyMany(list) },
|
|
1066
|
+
t('panel.verifyGroup').replace('{n}', String(list.length)),
|
|
1067
|
+
),
|
|
1068
|
+
),
|
|
1069
|
+
);
|
|
988
1070
|
const block = (title, state, actions) =>
|
|
989
1071
|
h(
|
|
990
1072
|
'div',
|
|
@@ -1008,7 +1090,7 @@ window.__ModuleLoader__.load({
|
|
|
1008
1090
|
: null,
|
|
1009
1091
|
),
|
|
1010
1092
|
state.data?.items?.length
|
|
1011
|
-
? state.data.items.map((item) => row(item, actions(item)))
|
|
1093
|
+
? [sessionGroups(state.data.items), ...state.data.items.map((item) => row(item, actions(item)))]
|
|
1012
1094
|
: h('div', { className: 'loci-muted' }, t('panel.noCandidates')),
|
|
1013
1095
|
);
|
|
1014
1096
|
const candidateActions = (item) => [
|
|
@@ -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
|
+
"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') {
|
|
@@ -185,7 +225,10 @@ export class Capture {
|
|
|
185
225
|
* slow model must not delay the rule capture that costs nothing.
|
|
186
226
|
*/
|
|
187
227
|
#scheduleExtraction(sessionId, buffer) {
|
|
188
|
-
|
|
228
|
+
const mode = this.config('extractMode');
|
|
229
|
+
// 'session' deliberately does not schedule per turn: that is the whole point of
|
|
230
|
+
// the mode. It runs once, in summarize(), when the session ends.
|
|
231
|
+
if ((mode !== 'on' && mode !== 'turn') || this.extractor === undefined) return;
|
|
189
232
|
if (buffer.extractTimer !== undefined) return;
|
|
190
233
|
const delay = Math.max(0, Number(this.config('captureDebounceMs') ?? 30000));
|
|
191
234
|
buffer.extractTimer = setTimeout(() => {
|
|
@@ -196,6 +239,7 @@ export class Capture {
|
|
|
196
239
|
.then(() =>
|
|
197
240
|
extractor.run({
|
|
198
241
|
sessionId,
|
|
242
|
+
mode: 'turn',
|
|
199
243
|
messages: buffer.messages,
|
|
200
244
|
scopeKey: this.#workspaceKey(buffer.cwd, sessionId),
|
|
201
245
|
scopeLabel: this.#workspaceLabel(buffer.cwd),
|
|
@@ -299,6 +343,39 @@ export class Capture {
|
|
|
299
343
|
);
|
|
300
344
|
}
|
|
301
345
|
|
|
346
|
+
/**
|
|
347
|
+
* One extraction pass for a whole session, run as it ends.
|
|
348
|
+
*
|
|
349
|
+
* This is the middle ground the review asked for: deterministic rules catch
|
|
350
|
+
* statements that announce themselves, per-turn extraction catches everything but
|
|
351
|
+
* costs one review decision per turn. A session digest reads the whole
|
|
352
|
+
* conversation once and proposes at most a handful of items in one sitting.
|
|
353
|
+
*
|
|
354
|
+
* Fire-and-forget by design: the buffer is copied before the asynchronous call so
|
|
355
|
+
* releasing it cannot race with the model.
|
|
356
|
+
*
|
|
357
|
+
* @param sessionId - session id.
|
|
358
|
+
* @param buffer - the buffered messages.
|
|
359
|
+
*/
|
|
360
|
+
#runSessionDigest(sessionId, buffer) {
|
|
361
|
+
const extractor = this.extractor;
|
|
362
|
+
if (extractor === undefined || buffer === undefined || buffer.messages.length < 4) return;
|
|
363
|
+
const messages = [...buffer.messages];
|
|
364
|
+
const scopeKey = this.#workspaceKey(buffer.cwd, sessionId);
|
|
365
|
+
const scopeLabel = this.#workspaceLabel(buffer.cwd);
|
|
366
|
+
Promise.resolve()
|
|
367
|
+
.then(() => extractor.run({ sessionId, mode: 'session', messages, scopeKey, scopeLabel }))
|
|
368
|
+
.then((result) => {
|
|
369
|
+
if (result?.written > 0) {
|
|
370
|
+
this.statistics.extracted += result.written;
|
|
371
|
+
this.log?.debug?.(`loci: session digest proposed ${result.written} item(s)`);
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
.catch((error) => {
|
|
375
|
+
this.log?.debug?.(`loci: session digest failed: ${String(error?.message ?? error)}`);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
302
379
|
/** Write the episodic summary for one session and drop its buffer. */
|
|
303
380
|
summarize(sessionId, reason = 'dispose') {
|
|
304
381
|
const buffer = this.sessions.get(sessionId);
|
|
@@ -311,6 +388,11 @@ export class Capture {
|
|
|
311
388
|
if (buffer.extractTimer !== undefined) clearTimeout(buffer.extractTimer);
|
|
312
389
|
this.sessions.delete(sessionId);
|
|
313
390
|
};
|
|
391
|
+
// The digest is independent of the episodic summary: a user can want one and not
|
|
392
|
+
// the other, and the digest must still run when summaries are off.
|
|
393
|
+
if (this.config('extractMode') === 'session' && buffer !== undefined) {
|
|
394
|
+
this.#runSessionDigest(sessionId, buffer);
|
|
395
|
+
}
|
|
314
396
|
if (this.config('summaryOnDispose') !== true) {
|
|
315
397
|
release();
|
|
316
398
|
return 0;
|
|
@@ -340,7 +422,11 @@ export class Capture {
|
|
|
340
422
|
scopeKey: this.#workspaceKey(buffer.cwd, sessionId),
|
|
341
423
|
scopeLabel: this.#workspaceLabel(buffer.cwd),
|
|
342
424
|
title,
|
|
343
|
-
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,
|
|
344
430
|
trustSource: 'summary',
|
|
345
431
|
sourceSession: sessionId,
|
|
346
432
|
sourceEvent: buffer.messages.at(-1)?.seq,
|
package/src/host/config.js
CHANGED
|
@@ -61,8 +61,15 @@ export const DEFAULTS = Object.freeze({
|
|
|
61
61
|
captureMaxPerStatementChars: 200,
|
|
62
62
|
/** Write one episodic session summary when a root agent is disposed. */
|
|
63
63
|
summaryOnDispose: true,
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* 'off' | 'on' | 'turn' | 'session' — background extraction of preferences stated
|
|
66
|
+
* in passing. 'on' and 'turn' mean one pass per turn (noisy: one proposal per
|
|
67
|
+
* turn becomes a review chore). 'session' means one digest when the session ends,
|
|
68
|
+
* which is the mode a heavy user asked for: five proposals, one sitting.
|
|
69
|
+
*/
|
|
65
70
|
extractMode: 'off',
|
|
71
|
+
/** How many candidates one session-end digest may propose. */
|
|
72
|
+
extractSessionMaxCandidates: 5,
|
|
66
73
|
/** Hard cap on extraction calls per hour; exhausting it is silent. */
|
|
67
74
|
extractMaxCallsPerHour: 6,
|
|
68
75
|
/** How many candidates one extraction run may write. */
|
|
@@ -123,7 +130,7 @@ export const ENUMS = Object.freeze({
|
|
|
123
130
|
defaultScope: ['workspace', 'project', 'global'],
|
|
124
131
|
recallAssist: ['off', 'session-start'],
|
|
125
132
|
captureMode: ['rules', 'off'],
|
|
126
|
-
extractMode: ['off', 'on'],
|
|
133
|
+
extractMode: ['off', 'on', 'turn', 'session'],
|
|
127
134
|
maintenanceMode: ['sweep', 'off'],
|
|
128
135
|
secretPolicy: ['redact', 'reject'],
|
|
129
136
|
vectorMode: ['off', 'http'],
|
|
@@ -149,6 +156,13 @@ function buildSchema() {
|
|
|
149
156
|
extractMode: z.union(ENUMS.extractMode).default(DEFAULTS.extractMode).volatile(),
|
|
150
157
|
extractMaxCallsPerHour: z.number().step(1).min(0).max(240).default(DEFAULTS.extractMaxCallsPerHour).volatile(),
|
|
151
158
|
extractMaxCandidates: z.number().step(1).min(0).max(20).default(DEFAULTS.extractMaxCandidates).volatile(),
|
|
159
|
+
extractSessionMaxCandidates: z
|
|
160
|
+
.number()
|
|
161
|
+
.step(1)
|
|
162
|
+
.min(0)
|
|
163
|
+
.max(20)
|
|
164
|
+
.default(DEFAULTS.extractSessionMaxCandidates)
|
|
165
|
+
.volatile(),
|
|
152
166
|
extractTimeoutMs: z.number().step(500).min(1000).max(120000).default(DEFAULTS.extractTimeoutMs).volatile(),
|
|
153
167
|
maintenanceMode: z.union(ENUMS.maintenanceMode).default(DEFAULTS.maintenanceMode).volatile(),
|
|
154
168
|
maintenanceIntervalHours: z.number().step(0.5).min(0.5).max(168).default(DEFAULTS.maintenanceIntervalHours).volatile(),
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* lost. This module asks a model to read the recent conversation and propose what
|
|
8
8
|
* is worth remembering.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Five rules keep it from becoming the thing users turn off:
|
|
11
11
|
*
|
|
12
|
-
* 1. **Off by default.** `extractMode` must be set
|
|
12
|
+
* 1. **Off by default.** `extractMode` must be set explicitly.
|
|
13
13
|
* 2. **Candidates only.** Everything it writes has status `candidate`; a person
|
|
14
14
|
* decides. Nothing here can reach the prompt by itself.
|
|
15
15
|
* 3. **Budgeted.** A call cap per hour, a cap on candidates per run, an input cap
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* 4. **It can always fail.** No model configured, timeout, unparseable answer,
|
|
18
18
|
* store error — every path returns `{written: 0, reason}` and the turn is never
|
|
19
19
|
* affected.
|
|
20
|
+
* 5. **Batch, don't drip.** `'session'` mode runs once, when a session ends, and
|
|
21
|
+
* proposes at most a handful of items in one go. `'turn'` mode still exists for
|
|
22
|
+
* people who want it, but reviewing one proposal per turn is how a review queue
|
|
23
|
+
* turns into a chore — the failure a heavy user described as "either it
|
|
24
|
+
* remembers too little or it makes me triage".
|
|
20
25
|
*
|
|
21
26
|
* @module dsh-loci/extract
|
|
22
27
|
*/
|
|
@@ -104,7 +109,8 @@ export class Extractor {
|
|
|
104
109
|
|
|
105
110
|
/** Whether the feature is switched on *and* a model route exists. */
|
|
106
111
|
enabled() {
|
|
107
|
-
|
|
112
|
+
const mode = this.config('extractMode');
|
|
113
|
+
return (mode === 'on' || mode === 'turn' || mode === 'session') && typeof this.complete === 'function';
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
/**
|
|
@@ -129,7 +135,8 @@ export class Extractor {
|
|
|
129
135
|
this.lastReason = reason;
|
|
130
136
|
return { written: 0, reason, ids: [] };
|
|
131
137
|
};
|
|
132
|
-
|
|
138
|
+
const mode = this.config('extractMode');
|
|
139
|
+
if (mode !== 'on' && mode !== 'turn' && mode !== 'session') return fail('disabled');
|
|
133
140
|
if (typeof this.complete !== 'function') return fail('no-model');
|
|
134
141
|
if (this.remainingCalls() <= 0) return fail('budget');
|
|
135
142
|
|
|
@@ -148,7 +155,14 @@ export class Extractor {
|
|
|
148
155
|
return fail('model-error');
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
|
|
158
|
+
// A turn-sized batch and a session-sized digest are different products: three
|
|
159
|
+
// proposals per turn is a queue nobody drains, five at the end of a session is
|
|
160
|
+
// one sitting.
|
|
161
|
+
const cap =
|
|
162
|
+
request?.mode === 'session'
|
|
163
|
+
? Number(this.config('extractSessionMaxCandidates') ?? 5)
|
|
164
|
+
: Number(this.config('extractMaxCandidates') ?? 3);
|
|
165
|
+
const items = parseExtraction(answer).slice(0, cap);
|
|
152
166
|
if (items.length === 0) return fail('nothing');
|
|
153
167
|
|
|
154
168
|
// A quote that is not in the transcript means the model invented it; those
|
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.
|
|
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
|
-
|
|
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
|
/**
|
package/src/host/store/schema.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** Schema version this build writes and expects. */
|
|
11
|
-
export const SCHEMA_VERSION =
|
|
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,
|
package/src/host/store/store.js
CHANGED
|
@@ -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
|
|
|
@@ -976,7 +1009,7 @@ export class Store {
|
|
|
976
1009
|
const items = this.db
|
|
977
1010
|
.prepare(
|
|
978
1011
|
`SELECT m.id, m.revision, m.kind, m.scope, m.scope_label, m.title, m.status, m.pinned, m.tags,
|
|
979
|
-
m.valid_from, m.valid_to, m.updated_at, m.created_at,
|
|
1012
|
+
m.valid_from, m.valid_to, m.updated_at, m.created_at, m.source_session,
|
|
980
1013
|
substr(m.body, 1, 240) AS snippet
|
|
981
1014
|
FROM memory m
|
|
982
1015
|
WHERE (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${keyMarks})
|
|
@@ -1000,6 +1033,9 @@ export class Store {
|
|
|
1000
1033
|
updatedAt: Number(row.updated_at),
|
|
1001
1034
|
createdAt: Number(row.created_at),
|
|
1002
1035
|
snippet: row.snippet ?? '',
|
|
1036
|
+
// Which session proposed this row. A session digest is reviewed as one
|
|
1037
|
+
// batch, so the reviewer needs to see that these items belong together.
|
|
1038
|
+
sourceSession: row.source_session ?? null,
|
|
1003
1039
|
}));
|
|
1004
1040
|
return { items, total };
|
|
1005
1041
|
}
|
|
@@ -1522,6 +1558,59 @@ export class Store {
|
|
|
1522
1558
|
return verified;
|
|
1523
1559
|
}
|
|
1524
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
|
+
|
|
1525
1614
|
#insertRevision(row) {
|
|
1526
1615
|
const result = this.db
|
|
1527
1616
|
.prepare(
|
|
@@ -1529,8 +1618,9 @@ export class Store {
|
|
|
1529
1618
|
id, revision, kind, scope, scope_key, scope_label, title, title_norm, body, tags, entities,
|
|
1530
1619
|
status, trust_source, flags, valid_from, valid_to, recorded_at, supersedes, superseded_by,
|
|
1531
1620
|
evidence, source_session, source_event, verified_at, verified_by, pinned, importance, tier,
|
|
1532
|
-
content_hash, created_at, updated_at, deleted_at
|
|
1533
|
-
|
|
1621
|
+
content_hash, created_at, updated_at, deleted_at,
|
|
1622
|
+
pattern, fix, prevents, when_condition
|
|
1623
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,?,?,?,?)`,
|
|
1534
1624
|
)
|
|
1535
1625
|
.run(
|
|
1536
1626
|
row.id,
|
|
@@ -1563,6 +1653,12 @@ export class Store {
|
|
|
1563
1653
|
row.contentHash,
|
|
1564
1654
|
row.createdAt,
|
|
1565
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,
|
|
1566
1662
|
);
|
|
1567
1663
|
const ord = Number(result.lastInsertRowid);
|
|
1568
1664
|
const text = `${row.title} ${row.body} ${(row.tags ?? []).join(' ')}`;
|
package/src/host/tools/tools.js
CHANGED
|
@@ -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`);
|