claude-mem-lite 6.24.0 → 6.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -2
- package/README.zh-CN.md +29 -0
- package/bash-utils.mjs +36 -3
- package/hook-optimize.mjs +30 -10
- package/hook-shared.mjs +2 -1
- package/hook-update.mjs +224 -50
- package/install.mjs +464 -112
- package/lib/data-paths.mjs +11 -0
- package/lib/doctor-stale-temp.mjs +117 -14
- package/lib/maintain-core.mjs +8 -1
- package/lib/recall-framing.mjs +18 -9
- package/lib/schema-skew.mjs +5 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/post-tool-use.sh +13 -4
- package/scripts/setup.sh +50 -21
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
12
|
+
"version": "6.25.1",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.25.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -237,6 +237,22 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
|
|
|
237
237
|
repos/ # Shallow-cloned source repos
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
+
## Upgrading to 6.25.0
|
|
241
|
+
|
|
242
|
+
**Every session gets the same first line on a file-recall block again.** No schema-version
|
|
243
|
+
change: 6.24.0 still opens the database after this release has.
|
|
244
|
+
|
|
245
|
+
- **What changes.** Since 6.16.0 half of sessions got a plain statement of where the notes come
|
|
246
|
+
from instead of "system-injected context, continue your planned action", so the two could be
|
|
247
|
+
compared by how often the agent cited a lesson. On the maintainer's own sessions the comparison
|
|
248
|
+
found no difference it could detect, and a scan of the agent's replies found neither line
|
|
249
|
+
surfaced to the user, so the split is closed: unset now means the older line in every session.
|
|
250
|
+
`CLAUDE_MEM_RECALL_FRAMING=factual` keeps the plain statement; `=ab` restores the split.
|
|
251
|
+
- **Also in this release:** when the data directory cannot be read, `doctor` says "not checked"
|
|
252
|
+
for what it could not look at and `status` names the directory, instead of reporting a missing
|
|
253
|
+
or empty store. `maintain execute --ops dedup` names the merge pairs it skips. Details in
|
|
254
|
+
CHANGELOG.md.
|
|
255
|
+
|
|
240
256
|
## Upgrading to 6.24.0
|
|
241
257
|
|
|
242
258
|
**If you set `CLAUDE_CONFIG_DIR`, the installer, updates and hooks now use that directory, as
|
|
@@ -253,7 +269,8 @@ release has. Without `CLAUDE_CONFIG_DIR` nothing in this item changes.
|
|
|
253
269
|
entries an older version wrote into `~/.claude/settings.json` stay there; if no other profile
|
|
254
270
|
uses `~/.claude`, `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` removes them.
|
|
255
271
|
- **Also in this release:** `npm install -g claude-mem-lite` and `npx` no longer install the
|
|
256
|
-
development toolchain
|
|
272
|
+
development toolchain. As a dependency install it went from 298 packages / 541 MB to 96 / 57 MB;
|
|
273
|
+
after the release, `-g` and `npx` measured the same 96 packages. A confirmed
|
|
257
274
|
`maintain execute --ops purge_stale` now deletes every row its preview counted in one run,
|
|
258
275
|
behind one snapshot. `doctor` warns about a failing update check and about an
|
|
259
276
|
`ANTHROPIC_BASE_URL` that ends in `/v1`. Details in CHANGELOG.md.
|
|
@@ -1234,7 +1251,7 @@ and names can change between releases.
|
|
|
1234
1251
|
|----------|-------------|---------|
|
|
1235
1252
|
| `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
|
|
1236
1253
|
| `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
|
|
1237
|
-
| `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. `
|
|
1254
|
+
| `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. Unset or `legacy` = "system-injected context, continue your planned action"; `factual` = a plain statement of where the notes come from; `ab` = each session gets one of the two, for `benchmark/citation-live-replay.mjs --by-framing`. Any other value is `legacy`. | `legacy` |
|
|
1238
1255
|
| `CLAUDE_MEM_SALIENCE` | Selects how the pre-edit lesson line asks for a response: unset = name a lesson's `#NN` only where it changed the edit; `verdict` = the pre-6.17 per-lesson `applied` / `n/a` verdict (the adoption row in CLAUDE.md and the detail doc keep the new wording, and since 6.17.1 the directive says it overrides them); `bind` / `bridge` = comprehension-bridge arms (`bridge` keeps the pre-6.17 verdict wording as its fallback; neither says it overrides the adopted text, so their measured wording stays fixed); `legacy` = no directive. | _(unset)_ |
|
|
1239
1256
|
| `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
|
|
1240
1257
|
| `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
|
package/README.zh-CN.md
CHANGED
|
@@ -199,6 +199,35 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
|
|
|
199
199
|
repos/ # 浅克隆的源代码仓库
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
+
## 升级到 6.25.0
|
|
203
|
+
|
|
204
|
+
**文件召回块的第一行,所有会话重新用同一种写法。** 没有 schema 版本变更:本版本打开过的数据库,6.24.0 仍能打开。
|
|
205
|
+
|
|
206
|
+
- **变了什么。** 从 6.16.0 起,一半会话的第一行是说明笔记来源的事实陈述,另一半保留 "system-injected context,
|
|
207
|
+
continue your planned action",用来比较 agent 引用教训的频率。在维护者自己的会话上,两种写法看不出差别,
|
|
208
|
+
按回复文本扫描,两组里 agent 也都没有把这一行当作可疑内容告诉用户,所以结束分组:
|
|
209
|
+
不设变量时,所有会话都用旧的那一行。
|
|
210
|
+
`CLAUDE_MEM_RECALL_FRAMING=factual` 保留事实陈述的写法,`=ab` 恢复分组。
|
|
211
|
+
- **本版本还有:** 数据目录读不了时,`doctor` 对看不到的项目显示“not checked”,`status` 直接指出这个目录
|
|
212
|
+
无法访问,不再报成数据库缺失或为空。`maintain execute --ops dedup` 会列出跳过的合并对。详见 CHANGELOG.md。
|
|
213
|
+
|
|
214
|
+
## 升级到 6.24.0
|
|
215
|
+
|
|
216
|
+
**如果你设置了 `CLAUDE_CONFIG_DIR`,安装程序、更新和 hook 现在都使用这个目录,和 Claude Code 一致。**
|
|
217
|
+
没有 schema 版本变更:本版本打开过的数据库,6.23.3 仍能打开。没设 `CLAUDE_CONFIG_DIR` 时,这一条不带来任何变化。
|
|
218
|
+
|
|
219
|
+
- **受影响的是谁。** 设了 `CLAUDE_CONFIG_DIR` 时,Claude Code 把 `settings.json`、`plugins/` 和 `.claude.json`
|
|
220
|
+
都放在那个目录里,插件却一直用 `~/.claude` 和 `~/.claude.json`:`install` 把 hook 写到了 Claude Code 不读的
|
|
221
|
+
地方;有两个配置目录时,会话启动的整理步骤和 `self-update` 会清掉另一个配置目录的插件缓存,并改动它的
|
|
222
|
+
`.claude.json`。
|
|
223
|
+
- **要做什么。** 用 npm 或 npx 安装的:在设好这个变量的情况下再运行一次 `claude-mem-lite install`,让 hook 写进
|
|
224
|
+
你的配置目录。插件安装不用做什么。旧版本写进 `~/.claude/settings.json` 的 hook 条目会留在那里;如果没有别的
|
|
225
|
+
配置目录在用 `~/.claude`,可以用 `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` 清掉。
|
|
226
|
+
- **本版本还有:** `npm install -g claude-mem-lite` 和 `npx` 不再安装开发工具链。按依赖方式安装,从 298 个包 /
|
|
227
|
+
541 MB 降到 96 个包 / 57 MB;发布后实测 `-g` 和 `npx` 也是同样的 96 个包。确认执行的
|
|
228
|
+
`maintain execute --ops purge_stale` 现在一次运行就删掉预览里统计的全部行,只做一次快照。`doctor` 会对失败的
|
|
229
|
+
更新检查、以及以 `/v1` 结尾的 `ANTHROPIC_BASE_URL` 发出警告。详见 CHANGELOG.md。
|
|
230
|
+
|
|
202
231
|
## 升级到 6.23.1
|
|
203
232
|
|
|
204
233
|
**如果你设置了 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_API_KEY`,后台 LLM 调用现在会发往那个网关。**
|
package/bash-utils.mjs
CHANGED
|
@@ -632,6 +632,28 @@ function withoutDiffHunks(text) {
|
|
|
632
632
|
// Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
|
|
633
633
|
// uniformly to both command-parsed paths and direct file_path/path/filePath fields.
|
|
634
634
|
|
|
635
|
+
// A line a test runner prints for one test (`# Subtest:` heads a failing one too): it carries the
|
|
636
|
+
// test's name, not a summary.
|
|
637
|
+
const PASSED_TEST_LINE_RE = /^[ \t]*(?:✔|✓|ok[ \t]+\d+[ \t]+-|# Subtest:)/;
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Whether test-runner output carries a red summary or failure banner (detectBashSignificance's
|
|
641
|
+
* veto on a green "0 fail" marker; D#179).
|
|
642
|
+
* @param {string} scan
|
|
643
|
+
* @returns {boolean}
|
|
644
|
+
*/
|
|
645
|
+
function hasRedTestSummary(scan) {
|
|
646
|
+
const lines = scan
|
|
647
|
+
.split('\n')
|
|
648
|
+
.filter((l) => !PASSED_TEST_LINE_RE.test(l))
|
|
649
|
+
.join('\n');
|
|
650
|
+
return (
|
|
651
|
+
/\b[1-9]\d*[ \t]+(?:fail|failed|failures|failing|errors?)\b(?=[ \t]*(?:$|[,;|(]|\.(?:[ \t]|$)|in[ \t]+\S))|^[ \t]*(?:ℹ|#)[ \t]*(?:fail|cancelled)[ \t]+[1-9]\d*[ \t]*$/im.test(
|
|
652
|
+
lines,
|
|
653
|
+
) || /^[ \t]*(?:--- )?FAIL(?:ED)?(?::|[ \t]|$)/m.test(lines)
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
635
657
|
/**
|
|
636
658
|
* Detect significance signals in a Bash command and its response.
|
|
637
659
|
* Checks for errors, test runs, builds, git operations, and deployments.
|
|
@@ -675,12 +697,23 @@ export function detectBashSignificance(input, response) {
|
|
|
675
697
|
// suites prints one summary each, and a node:test run whose failures were all timeouts says
|
|
676
698
|
// "fail 0" next to "cancelled 1" (pre-tag defect review, item 7 F1). Red = a nonzero fail /
|
|
677
699
|
// cancelled count in either summary form.
|
|
700
|
+
// D#179: a count-first red must sit where a summary puts it — end of line, or before `,` `;`
|
|
701
|
+
// `|` `(` `. ` or `in …` — on ONE line, and never on a line a runner prints for a PASSED test
|
|
702
|
+
// (node:test `✔ name (1ms)`, vitest / bun `✓ name`, TAP `ok 1 - name` and its `# Subtest:`),
|
|
703
|
+
// which carries the test's NAME. Matched anywhere and across newlines, a passing run whose test
|
|
704
|
+
// name or log said "3 failed" / "2 failed attempts" read red, and "want 2\nFAIL" read as
|
|
705
|
+
// "2 FAIL". `errors?` is red too (pytest "1 passed, 1 error", tsc "Found 1 error in …",
|
|
706
|
+
// eslint "(2 errors, 0 warnings)"). go / jest / vitest / shell-suite failures carry a
|
|
707
|
+
// case-sensitive FAIL / FAILED banner at line start ("--- FAIL:", "FAILED: 3 case(s)", pytest
|
|
708
|
+
// "FAILED test_x.py::…"), read on its own. The green marker still
|
|
709
|
+
// spans lines on purpose: made one-line, 17 of this machine's 77,819 recorded results flipped
|
|
710
|
+
// to errors, and 15 of them were passing batteries, file reads and reports that only ever
|
|
711
|
+
// matched "0\nFAIL" by accident; 2 were real failures (measured 2026-10-07, recounted by the
|
|
712
|
+
// pre-tag delta review).
|
|
678
713
|
const hasGreenTestSummary =
|
|
679
714
|
looksLikeError &&
|
|
680
715
|
/\b0\s+(fail|failed|failures)\b|^[ \t]*(?:ℹ|#)[ \t]*fail[ \t]+0[ \t]*$/im.test(scan) &&
|
|
681
|
-
|
|
682
|
-
scan,
|
|
683
|
-
);
|
|
716
|
+
!hasRedTestSummary(scan);
|
|
684
717
|
// NOTE: do not add `\bFAIL\s` here — with /i flag it would re-match the
|
|
685
718
|
// very `0 fail\n` token green-summary is trying to exempt. A real test
|
|
686
719
|
// failure produces "N fail" (N≥1) which never triggers hasGreenTestSummary,
|
package/hook-optimize.mjs
CHANGED
|
@@ -484,8 +484,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
484
484
|
// clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
|
|
485
485
|
// A compression keeper is never hidden: hiding it hides every member compressed into it
|
|
486
486
|
// (the same rule NOT_COMPRESSION_KEEPER_SQL enforces on the maintenance writers). It falls
|
|
487
|
-
// through to the
|
|
488
|
-
//
|
|
487
|
+
// through instead: in narrow scope to the stamp-only write below (floored to importance 1
|
|
488
|
+
// and stamped, so it is not re-sent), in wide scope to the normal update.
|
|
489
489
|
const isKeeper = !!db
|
|
490
490
|
.prepare('SELECT 1 FROM observations WHERE compressed_into = ? LIMIT 1')
|
|
491
491
|
.get(cand.id);
|
|
@@ -518,6 +518,31 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
518
518
|
processed++;
|
|
519
519
|
continue;
|
|
520
520
|
}
|
|
521
|
+
// A narrow 0 reply reaches this point only for a row that may not be hidden (a compression
|
|
522
|
+
// keeper, or an importance a person set). It judged the row worthless, so it is no basis for
|
|
523
|
+
// any of the row's text either: the stored title and narrative stay (D#207: 'Weekly summary:
|
|
524
|
+
// auth refactor' became 'Weekly summary', narrative 'x'), and so do type, lesson, concepts,
|
|
525
|
+
// facts, aliases and scope (D#268). Written instead: what hiding would have written bar the
|
|
526
|
+
// hide, i.e. the stamp, so the row is not re-sent, plus the importance floor below. Wide
|
|
527
|
+
// scope stays on the normal path: its rows are substantive by construction, so a 0 there
|
|
528
|
+
// reads as a misjudgment of importance, not of the row.
|
|
529
|
+
if (scoredZero && scope !== 'wide') {
|
|
530
|
+
const res = db
|
|
531
|
+
.prepare(
|
|
532
|
+
`UPDATE observations SET
|
|
533
|
+
importance = CASE WHEN importance_set_at IS NOT NULL THEN importance
|
|
534
|
+
ELSE MAX(?, COALESCE(NULLIF(importance, 0), 1)) END,
|
|
535
|
+
optimized_at = ?
|
|
536
|
+
WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL`,
|
|
537
|
+
)
|
|
538
|
+
.run(clampImportance(parsed.importance), Date.now(), cand.id);
|
|
539
|
+
if (res.changes === 0) {
|
|
540
|
+
skipped++;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
processed++;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
521
546
|
|
|
522
547
|
// Enrichment ("add a lesson") must not reclassify a specific type down to the generic
|
|
523
548
|
// 'change' (lower TYPE_QUALITY + faster decay); keep the stored type on that downgrade.
|
|
@@ -558,13 +583,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
558
583
|
// For `narrow`, the truncate stays on LLM output but NOT on the preserve-on-empty
|
|
559
584
|
// fallback — truncating the row's own stored narrative to buy nothing was the same
|
|
560
585
|
// bug in miniature.
|
|
561
|
-
//
|
|
562
|
-
// A reply that scored the row 0 reaches this point only for a row that may not be hidden
|
|
563
|
-
// (a compression keeper, or an importance a person set). It judged the row worthless, so
|
|
564
|
-
// its title and narrative do not replace the stored ones either (D#207: 'Weekly summary:
|
|
565
|
-
// auth refactor' became 'Weekly summary', narrative 'x').
|
|
566
586
|
const isWide = scope === 'wide';
|
|
567
|
-
const keepStoredText = isWide
|
|
587
|
+
const keepStoredText = isWide;
|
|
568
588
|
const title = keepStoredText ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
|
|
569
589
|
const narrative = keepStoredText
|
|
570
590
|
? cand.narrative
|
|
@@ -611,8 +631,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
611
631
|
// Narrow replaces title and narrative with model text, so an explicit save it rewrites (one
|
|
612
632
|
// whose save-time enrich failed) moves to the re-enrich writer's id, as a cluster-merge
|
|
613
633
|
// keeper does (D#146, D#138). Wide keeps the stored title and narrative (it fills the lesson
|
|
614
|
-
// and the side fields), so the row stays an explicit save
|
|
615
|
-
//
|
|
634
|
+
// and the side fields), so the row stays an explicit save; a narrow 0 reply never gets here.
|
|
635
|
+
// The writer's session row is best-effort.
|
|
616
636
|
let rewriteSessionId = null;
|
|
617
637
|
if (!keepStoredText) {
|
|
618
638
|
const cur = db.prepare('SELECT memory_session_id FROM observations WHERE id = ?').get(cand.id);
|
package/hook-shared.mjs
CHANGED
|
@@ -281,7 +281,8 @@ export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
|
|
|
281
281
|
// out. Both are version-keyed one-shot migration sentinels written by
|
|
282
282
|
// scripts/setup.sh, and their gate is `! -f <marker>` — deleting one re-runs
|
|
283
283
|
// its migration. `.mcp-dedup-v2.78` gates a block that removes
|
|
284
|
-
// mcpServers.mem / mcpServers["mem-lite"] from the
|
|
284
|
+
// mcpServers.mem / mcpServers["mem-lite"] from the config home's .claude.json (one marker per
|
|
285
|
+
// .claude.json since D#270, the same prefix) with
|
|
285
286
|
// a raw writeFileSync (no tmp+rename, no backup), which the repo's own test
|
|
286
287
|
// documents as intentionally one-shot: "If a user later runs `claude mcp add
|
|
287
288
|
// mem ...` themselves, the gate intentionally lets it stand." A 30-day sweep
|
package/hook-update.mjs
CHANGED
|
@@ -318,13 +318,28 @@ function shouldCheck(state) {
|
|
|
318
318
|
// ── GitHub API ─────────────────────────────────────────────
|
|
319
319
|
// Try releases/latest first, fallback to tags (some repos only use tags)
|
|
320
320
|
export async function fetchLatestRelease() {
|
|
321
|
-
|
|
321
|
+
const { release } = await lookupLatestRelease();
|
|
322
|
+
if (release) clearRecordedLookupFailure();
|
|
323
|
+
return release;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// repair looks up through fetchLatestRelease, so a lookup that succeeds there is evidence against
|
|
327
|
+
// the failure doctor reports; without this the warning outlived the repair by a full check interval
|
|
328
|
+
// (D#266). Only the two fields that decide whether doctor warns. lastCheck, latestVersion and
|
|
329
|
+
// rateLimited belong to checkForUpdate (the background check and self-update): rateLimited picks
|
|
330
|
+
// its interval (6 h, else 24 h), so clearing it here pushed the next check back by up to 18 h
|
|
331
|
+
// (post-release review). No write when there is nothing to clear.
|
|
332
|
+
function clearRecordedLookupFailure() {
|
|
333
|
+
const state = readState();
|
|
334
|
+
if (!state.lookupError && !state.lookupFailingSince) return;
|
|
335
|
+
saveState({ ...state, lookupError: null, lookupFailingSince: null });
|
|
322
336
|
}
|
|
323
337
|
|
|
324
338
|
// One lookup's outcome, returned rather than kept in module state: a module flag let a second
|
|
325
339
|
// lookup running at the same time read the first one's 403 (D#260). `rateLimited` is THIS
|
|
326
|
-
// lookup's refusal; the persisted flag is not that, since only a successful
|
|
327
|
-
// (pre-tag review of cd9f1ab7). `error` says why there
|
|
340
|
+
// lookup's refusal; the persisted flag is not that, since only a successful checkForUpdate (the
|
|
341
|
+
// background check or self-update) clears it (pre-tag review of cd9f1ab7). `error` says why there
|
|
342
|
+
// is no release, for doctor (D#255).
|
|
328
343
|
async function lookupLatestRelease() {
|
|
329
344
|
const headers = {
|
|
330
345
|
Accept: 'application/vnd.github+json',
|
|
@@ -813,11 +828,15 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
|
|
|
813
828
|
// updater that was killed mid-swap (it applies the same staleness bound).
|
|
814
829
|
const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress'); // runtime-dir:stays-put — installation identity
|
|
815
830
|
// Intent journal, written INSIDE the backup dir before each rename. On a hard kill
|
|
816
|
-
// the backup dir survives (
|
|
831
|
+
// the backup dir survives (a normal exit deletes it, unless its journal cannot be
|
|
832
|
+
// removed, D#283) and this file says exactly
|
|
817
833
|
// which paths were in flight, so the next entry can finish the rollback at the right
|
|
818
834
|
// granularity — a bare directory walk cannot tell a nested relPath from a directory
|
|
819
835
|
// relPath like `node_modules`.
|
|
820
|
-
const SWAP_JOURNAL = '.swap-journal.json';
|
|
836
|
+
const SWAP_JOURNAL = '.swap-journal.json'; // doctor and cleanup spell it SWAP_JOURNAL_NAME (lib/doctor-stale-temp.mjs)
|
|
837
|
+
// Written into the backup dir when its swap is over (committed, or rolled back): never replay it
|
|
838
|
+
// (D#292 C). Doctor and cleanup spell it SWAP_RESOLVED_NAME.
|
|
839
|
+
const SWAP_RESOLVED = '.swap-resolved';
|
|
821
840
|
|
|
822
841
|
function markSwapStart() {
|
|
823
842
|
try {
|
|
@@ -841,19 +860,131 @@ function clearSwapMarker() {
|
|
|
841
860
|
// backup dir but nothing records it — recovery would then delete the backup dir with
|
|
842
861
|
// the only copy of that file inside it. rollbackInstall guards every entry with
|
|
843
862
|
// existsSync/force, so an intent that never happened is a harmless no-op.
|
|
863
|
+
//
|
|
864
|
+
// By temp file and rename, and it throws (D#289). writeFileSync truncates the journal before it
|
|
865
|
+
// writes it, so a kill or ENOSPC in between left an empty journal, which recovery read as "nothing
|
|
866
|
+
// moved" and then deleted the backups. A write error was swallowed, so the rename it was meant to
|
|
867
|
+
// record went ahead unrecorded. Now the previous journal stays whole until the new one replaces
|
|
868
|
+
// it, and a swap that cannot journal stops before the rename, through the rollback in the caller.
|
|
869
|
+
//
|
|
870
|
+
// Retried for a moment on EPERM/EBUSY/EACCES: the rename replaces a file just written, about twice
|
|
871
|
+
// per switched path, and on Windows a scanner holding that file makes a rename over it fail
|
|
872
|
+
// briefly. Each failure stopped the update and rolled it back, and a rollback is where a restore
|
|
873
|
+
// can fail (D#292 A). Bounded at about 0.7 s, then it throws as before.
|
|
874
|
+
const JOURNAL_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
844
875
|
function journalSwap(backupDir, backedUp, installed) {
|
|
876
|
+
const data = JSON.stringify({ backedUp, installed });
|
|
877
|
+
for (let attempt = 1; ; attempt++) {
|
|
878
|
+
try {
|
|
879
|
+
atomicWriteFileSync(join(backupDir, SWAP_JOURNAL), data);
|
|
880
|
+
return;
|
|
881
|
+
} catch (e) {
|
|
882
|
+
if (attempt >= 8 || !JOURNAL_RETRY_CODES.has(e.code)) throw e;
|
|
883
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Clean up after a swap that is over: committed, or rolled back. Never throws, because a cleanup
|
|
889
|
+
// that throws reaches the caller's catch, which rolls the swap back again (D#283; EBUSY is
|
|
890
|
+
// ordinary on Windows and `force` does not suppress it).
|
|
891
|
+
//
|
|
892
|
+
// The dir is marked resolved first (D#292 C). A journal left behind (it could not be removed, or a
|
|
893
|
+
// kill came first) was replayed by the next recovery: over a committed swap that put the previous
|
|
894
|
+
// release back, and over a rolled-back one whose journal outlived a later swap it deleted a path
|
|
895
|
+
// that swap had installed. Recovery, install, cleanup and doctor leave a resolved dir's journal
|
|
896
|
+
// alone. The marker is a new empty file, so it needs no access to the journal a failed removal
|
|
897
|
+
// could not get. With the marker written, a journal that stays is inert and the backups go anyway,
|
|
898
|
+
// child by child around the journal and the marker: a recursive removal of the dir takes children
|
|
899
|
+
// in directory order and stops at the first failure, so it could take the marker and then stop at
|
|
900
|
+
// the journal (v6.25.1 delta review). If the marker cannot be written either, the journal still
|
|
901
|
+
// goes before the backups, and if it stays, so do the backups, complete, so that a replay restores
|
|
902
|
+
// one whole release.
|
|
903
|
+
function discardBackupDir(backupDir) {
|
|
904
|
+
let resolved = false;
|
|
905
|
+
try {
|
|
906
|
+
writeFileSync(join(backupDir, SWAP_RESOLVED), '');
|
|
907
|
+
resolved = true;
|
|
908
|
+
} catch (e) {
|
|
909
|
+
debugCatch(e, 'mark-swap-resolved');
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
rmSync(join(backupDir, SWAP_JOURNAL), { force: true });
|
|
913
|
+
} catch (e) {
|
|
914
|
+
debugCatch(e, 'discard-swap-journal');
|
|
915
|
+
if (resolved) removeBackupsBeside(backupDir);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
920
|
+
} catch (e) {
|
|
921
|
+
debugCatch(e, 'discard-backup-dir');
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function removeBackupsBeside(backupDir) {
|
|
926
|
+
let names = [];
|
|
927
|
+
try {
|
|
928
|
+
names = readdirSync(backupDir);
|
|
929
|
+
} catch (e) {
|
|
930
|
+
debugCatch(e, 'discard-backups-list');
|
|
931
|
+
}
|
|
932
|
+
for (const name of names) {
|
|
933
|
+
if (name === SWAP_JOURNAL || name === SWAP_RESOLVED) continue;
|
|
934
|
+
try {
|
|
935
|
+
rmSync(join(backupDir, name), { recursive: true, force: true });
|
|
936
|
+
} catch (e) {
|
|
937
|
+
debugCatch(e, 'discard-backup');
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Mark resolved the backup dirs a swap found left over and unfinished (recovery leaves one whose
|
|
943
|
+
// journal it cannot read right now). Called once that swap has committed: it replaced every path
|
|
944
|
+
// it switches, so a later replay of an older journal, once it can be read, would only mix the
|
|
945
|
+
// releases back (v6.25.1 delta review). The same retirement install does after writing the tree.
|
|
946
|
+
function retireUnresolvedBackups(targetDir, names) {
|
|
947
|
+
for (const name of names) {
|
|
948
|
+
try {
|
|
949
|
+
writeFileSync(join(targetDir, name, SWAP_RESOLVED), '');
|
|
950
|
+
debugLog('WARN', 'hook-update', `Retired ${name}: the committed swap replaced what its journal names`);
|
|
951
|
+
} catch (e) {
|
|
952
|
+
debugCatch(e, 'retire-unresolved-backup');
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// The `.update-backup-*` dirs in targetDir that carry no resolved marker.
|
|
958
|
+
function unresolvedBackups(targetDir) {
|
|
845
959
|
try {
|
|
846
|
-
|
|
960
|
+
return readdirSync(targetDir, { withFileTypes: true })
|
|
961
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('.update-backup-'))
|
|
962
|
+
.map((e) => e.name)
|
|
963
|
+
.filter((name) => !existsSync(join(targetDir, name, SWAP_RESOLVED)));
|
|
847
964
|
} catch (e) {
|
|
848
|
-
debugCatch(e, '
|
|
965
|
+
debugCatch(e, 'list-unresolved-backups');
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// The backup dir is resolved before staging goes: removing a whole staging tree takes long enough
|
|
971
|
+
// for a kill to land in, and a kill there left a committed swap's journal to be replayed.
|
|
972
|
+
function discardSwapDirs(stagingDir, backupDir) {
|
|
973
|
+
discardBackupDir(backupDir);
|
|
974
|
+
try {
|
|
975
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
976
|
+
} catch (e) {
|
|
977
|
+
debugCatch(e, 'discard-staging-dir');
|
|
849
978
|
}
|
|
850
979
|
}
|
|
851
980
|
|
|
852
981
|
/**
|
|
853
982
|
* Finish any swap a previous process was killed in the middle of, then clear its
|
|
854
983
|
* residue. Called on every install entry, under the install lock, BEFORE a new
|
|
855
|
-
* staging/backup pair is created.
|
|
856
|
-
*
|
|
984
|
+
* staging/backup pair is created; and by install.mjs, under the same lock, before
|
|
985
|
+
* `install` (and so `repair`) writes the tree and from `cleanup` (D#289).
|
|
986
|
+
* @returns {number} number of backup dirs processed, with a journal or without; one left in place
|
|
987
|
+
* because its journal cannot be read is not counted
|
|
857
988
|
*/
|
|
858
989
|
export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
859
990
|
let entries;
|
|
@@ -880,21 +1011,43 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
880
1011
|
}
|
|
881
1012
|
if (!entry.name.startsWith('.update-backup-')) continue;
|
|
882
1013
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1014
|
+
// Its swap is over and only its cleanup failed: remove what is left, never replay it (D#292 C).
|
|
1015
|
+
if (existsSync(join(dir, SWAP_RESOLVED))) {
|
|
1016
|
+
discardBackupDir(dir);
|
|
1017
|
+
recovered++;
|
|
1018
|
+
continue;
|
|
888
1019
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
//
|
|
892
|
-
|
|
1020
|
+
|
|
1021
|
+
// No journal: the swap is over and its journal is gone (or it was killed before its first
|
|
1022
|
+
// rename), so there is nothing to replay and the dir is residue. A journal that is there but
|
|
1023
|
+
// cannot be read, or not as one (an in-place write of 6.25.0 or older torn by a kill), says
|
|
1024
|
+
// nothing about what moved, and the dir may hold the only copy of it: leave it. It used to be
|
|
1025
|
+
// read as "nothing moved" and deleted (v6.25.1 review). doctor and cleanup name it; the next
|
|
1026
|
+
// swap that commits, or an install, retires it.
|
|
1027
|
+
let journal = { backedUp: [], installed: [] };
|
|
1028
|
+
let raw = null;
|
|
893
1029
|
try {
|
|
894
|
-
|
|
1030
|
+
raw = readFileSync(join(dir, SWAP_JOURNAL), 'utf8');
|
|
895
1031
|
} catch (e) {
|
|
896
|
-
|
|
1032
|
+
if (e.code !== 'ENOENT') {
|
|
1033
|
+
debugCatch(e, 'recover-read-journal');
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
897
1036
|
}
|
|
1037
|
+
if (raw !== null) {
|
|
1038
|
+
try {
|
|
1039
|
+
journal = JSON.parse(raw);
|
|
1040
|
+
} catch {
|
|
1041
|
+
journal = null;
|
|
1042
|
+
}
|
|
1043
|
+
if (!Array.isArray(journal?.backedUp) || !Array.isArray(journal?.installed)) {
|
|
1044
|
+
debugLog('WARN', 'hook-update', `Left ${entry.name} in place: its swap journal cannot be read`);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const { backedUp, installed } = journal;
|
|
1049
|
+
rollbackInstall(installed, backedUp, dir, targetDir);
|
|
1050
|
+
discardBackupDir(dir);
|
|
898
1051
|
recovered++;
|
|
899
1052
|
debugLog(
|
|
900
1053
|
'WARN',
|
|
@@ -905,25 +1058,43 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
905
1058
|
return recovered;
|
|
906
1059
|
}
|
|
907
1060
|
|
|
1061
|
+
// A rollback swaps a file SET back just as the install swapped it in, so it holds the same
|
|
1062
|
+
// marker for the same reason (D#239 g). After the MED-5 smoke gate the loop has already cleared
|
|
1063
|
+
// it; after a throw inside the loop it is still set, and this call takes it over.
|
|
1064
|
+
//
|
|
1065
|
+
// Safe to run twice over one journal (D#283). A backed-up path whose backup is gone holds the OLD
|
|
1066
|
+
// file already, restored by an earlier rollback or never moved out, so it is not deleted: a
|
|
1067
|
+
// second rollback used to delete every installed path, by then the only copy of the old install.
|
|
1068
|
+
// That holds for a journal this code wrote: its cleanup removes the journal before any backup, and
|
|
1069
|
+
// when the journal cannot be removed, the resolved marker keeps it from being replayed. A backup
|
|
1070
|
+
// dir 6.25.0 or older left half deleted beside its journal can still replay to a mix.
|
|
1071
|
+
// The arrays are copied, not reversed in place, so a caller's journal is left as it was.
|
|
908
1072
|
function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1073
|
+
markSwapStart();
|
|
1074
|
+
try {
|
|
1075
|
+
const restored = new Set(backedUp.filter((relPath) => !existsSync(join(backupDir, relPath))));
|
|
1076
|
+
for (const relPath of [...installed].reverse()) {
|
|
1077
|
+
if (restored.has(relPath)) continue;
|
|
1078
|
+
try {
|
|
1079
|
+
rmSync(join(targetDir, relPath), { recursive: true, force: true });
|
|
1080
|
+
} catch {
|
|
1081
|
+
/* best-effort */
|
|
1082
|
+
}
|
|
914
1083
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1084
|
+
for (const relPath of [...backedUp].reverse()) {
|
|
1085
|
+
const backupPath = join(backupDir, relPath);
|
|
1086
|
+
const targetPath = join(targetDir, relPath);
|
|
1087
|
+
try {
|
|
1088
|
+
if (existsSync(backupPath)) {
|
|
1089
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
1090
|
+
renameSync(backupPath, targetPath);
|
|
1091
|
+
}
|
|
1092
|
+
} catch (restoreErr) {
|
|
1093
|
+
debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
|
|
923
1094
|
}
|
|
924
|
-
} catch (restoreErr) {
|
|
925
|
-
debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
|
|
926
1095
|
}
|
|
1096
|
+
} finally {
|
|
1097
|
+
clearSwapMarker();
|
|
927
1098
|
}
|
|
928
1099
|
}
|
|
929
1100
|
|
|
@@ -1026,6 +1197,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1026
1197
|
const backupDir = join(targetDir, `.update-backup-${ts}`);
|
|
1027
1198
|
const backedUp = [];
|
|
1028
1199
|
const installed = [];
|
|
1200
|
+
let committed = false;
|
|
1029
1201
|
|
|
1030
1202
|
const manifest = await loadReleaseManifest(sourceDir);
|
|
1031
1203
|
const switchablePaths = buildSwitchablePaths(manifest.SOURCE_FILES);
|
|
@@ -1035,6 +1207,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1035
1207
|
// one — otherwise this install stacks on top of a mixed-version tree and its
|
|
1036
1208
|
// own backup can no longer restore a coherent state.
|
|
1037
1209
|
recoverInterruptedSwaps(targetDir);
|
|
1210
|
+
// What recovery left unfinished (a journal it cannot read right now) is retired if this swap
|
|
1211
|
+
// commits, and left for the next entry if it rolls back.
|
|
1212
|
+
const leftUnresolved = unresolvedBackups(targetDir);
|
|
1038
1213
|
|
|
1039
1214
|
mkdirSync(stagingDir, { recursive: true });
|
|
1040
1215
|
mkdirSync(backupDir, { recursive: true });
|
|
@@ -1049,6 +1224,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1049
1224
|
}
|
|
1050
1225
|
|
|
1051
1226
|
markSwapStart();
|
|
1227
|
+
// Cleared here only when the loop finished. If a rename throws, the half-swapped tree keeps
|
|
1228
|
+
// the marker through the catch below until rollbackInstall has put the old files back.
|
|
1229
|
+
let swapped = false;
|
|
1052
1230
|
try {
|
|
1053
1231
|
for (const relPath of switchablePaths) {
|
|
1054
1232
|
const stagedPath = join(stagingDir, relPath);
|
|
@@ -1070,8 +1248,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1070
1248
|
journalSwap(backupDir, backedUp, installed);
|
|
1071
1249
|
renameSync(stagedPath, targetPath);
|
|
1072
1250
|
}
|
|
1251
|
+
swapped = true;
|
|
1073
1252
|
} finally {
|
|
1074
|
-
clearSwapMarker();
|
|
1253
|
+
if (swapped) clearSwapMarker();
|
|
1075
1254
|
}
|
|
1076
1255
|
|
|
1077
1256
|
// MED-5: before discarding the rollback backup, prove the switched code boots.
|
|
@@ -1079,13 +1258,14 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1079
1258
|
// keeps working rather than leaving a broken install with no way back.
|
|
1080
1259
|
if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
|
|
1081
1260
|
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
1082
|
-
|
|
1083
|
-
rmSync(backupDir, { recursive: true, force: true });
|
|
1261
|
+
discardSwapDirs(stagingDir, backupDir);
|
|
1084
1262
|
return false;
|
|
1085
1263
|
}
|
|
1086
1264
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1265
|
+
// Committed: from here on the catch below must not roll back (D#283).
|
|
1266
|
+
committed = true;
|
|
1267
|
+
retireUnresolvedBackups(targetDir, leftUnresolved);
|
|
1268
|
+
discardSwapDirs(stagingDir, backupDir);
|
|
1089
1269
|
|
|
1090
1270
|
// Post-update migration: reconcile settings.json against the release we just
|
|
1091
1271
|
// swapped in. `configureHooks()` already strips stale mem entries — but its only
|
|
@@ -1171,17 +1351,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1171
1351
|
return true;
|
|
1172
1352
|
} catch (err) {
|
|
1173
1353
|
debugCatch(err, 'installExtractedRelease');
|
|
1354
|
+
// Past the commit point the backups are gone and the new release IS the install: a rollback
|
|
1355
|
+
// would delete the paths it added and restore nothing (D#283).
|
|
1356
|
+
if (committed) return true;
|
|
1174
1357
|
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
1175
|
-
|
|
1176
|
-
rmSync(stagingDir, { recursive: true, force: true });
|
|
1177
|
-
} catch {
|
|
1178
|
-
/* best-effort */
|
|
1179
|
-
}
|
|
1180
|
-
try {
|
|
1181
|
-
rmSync(backupDir, { recursive: true, force: true });
|
|
1182
|
-
} catch {
|
|
1183
|
-
/* best-effort */
|
|
1184
|
-
}
|
|
1358
|
+
discardSwapDirs(stagingDir, backupDir);
|
|
1185
1359
|
return false;
|
|
1186
1360
|
} finally {
|
|
1187
1361
|
release();
|