claude-mem-lite 6.1.0 → 6.3.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +11 -4
- package/README.zh-CN.md +10 -4
- package/hook-shared.mjs +54 -1
- package/hook-update.mjs +5 -1
- package/hook.mjs +75 -2
- package/install.mjs +184 -0
- package/lib/platform-gate.mjs +87 -0
- package/lib/schema-skew.mjs +379 -0
- package/mem-cli.mjs +42 -1
- package/npm-shrinkwrap.json +4 -3
- package/package.json +5 -2
- package/plugin-cache-guard.mjs +10 -2
- package/schema.mjs +16 -1
- package/scripts/launch.mjs +108 -15
- package/scripts/user-prompt-search.js +20 -0
- package/source-files.mjs +6 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "6.
|
|
13
|
+
"version": "6.3.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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
|
@@ -134,16 +134,23 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
|
|
|
134
134
|
|
|
135
135
|
| Platform | Status | Notes |
|
|
136
136
|
|----------|--------|-------|
|
|
137
|
-
| **Linux** | Supported | Primary development and testing platform |
|
|
137
|
+
| **Linux** | Supported | Primary development and testing platform; the whole CI matrix runs here |
|
|
138
138
|
| **macOS** | Supported | Fully compatible (Intel and Apple Silicon) |
|
|
139
|
-
| **Windows** |
|
|
139
|
+
| **Windows** | Installs, not CI-covered | The MCP server, the CLI and the `node` hooks work (`better-sqlite3` ships `win32-x64` and `win32-arm64` prebuilds, so nothing is compiled). **Three hook commands run under `bash`** — `setup.sh`, `post-tool-use.sh`, `pre-agent-inject.sh` — and need Git for Windows or WSL on `PATH`; `claude-mem-lite doctor` reports it when `bash` cannot be found. No GitHub Actions runner exercises Windows, so this rests on user reports ([#28](https://github.com/sdsrss/claude-mem-lite/issues/28)), not on a green pipeline |
|
|
140
|
+
| **WSL2** | Untested | Linux under the hood, so it should behave as the Linux row; nobody has reported either way |
|
|
141
|
+
|
|
142
|
+
From v5.1.0 through v6.1.0, `package.json` declared `os: ["darwin", "linux"]`. That is an npm *install*
|
|
143
|
+
gate, not a runtime check: on Windows it made `npm install` exit `EBADPLATFORM`, which the
|
|
144
|
+
plugin launcher runs on the first MCP start after every plugin update — so the server never
|
|
145
|
+
came up and `/mcp` reported `CONNECTION_CLOSED`. `win32` is now in the list. A platform that
|
|
146
|
+
is still outside it gets a message naming both sides of the mismatch instead of a guess.
|
|
140
147
|
|
|
141
148
|
## Requirements
|
|
142
149
|
|
|
143
150
|
- **Node.js** >= 22
|
|
144
151
|
- **Claude Code** CLI installed and configured (`claude` command available)
|
|
145
|
-
- **SQLite3** support (provided by `better-sqlite3
|
|
146
|
-
- **Platform**: Linux or macOS (see [Platform Support](#platform-support))
|
|
152
|
+
- **SQLite3** support (provided by `better-sqlite3` 13, which ships prebuilt binaries for 8 platforms — no compiler needed on any of them; a platform it has no prebuild for falls back to building from source)
|
|
153
|
+
- **Platform**: Linux or macOS; Windows installs and runs but is not CI-covered and needs Git Bash or WSL for three hooks (see [Platform Support](#platform-support))
|
|
147
154
|
|
|
148
155
|
## Installation
|
|
149
156
|
|
package/README.zh-CN.md
CHANGED
|
@@ -100,16 +100,22 @@
|
|
|
100
100
|
|
|
101
101
|
| 平台 | 状态 | 说明 |
|
|
102
102
|
|------|------|------|
|
|
103
|
-
| **Linux** | 支持 |
|
|
103
|
+
| **Linux** | 支持 | 主要开发和测试平台;整个 CI 矩阵都跑在这里 |
|
|
104
104
|
| **macOS** | 支持 | 完全兼容(Intel 和 Apple Silicon) |
|
|
105
|
-
| **Windows** |
|
|
105
|
+
| **Windows** | 可安装,但无 CI 覆盖 | MCP server、CLI 和 `node` 类 hook 均可用(`better-sqlite3` 自带 `win32-x64` / `win32-arm64` 预编译产物,无需编译)。**有三个 hook 命令走 `bash`** —— `setup.sh`、`post-tool-use.sh`、`pre-agent-inject.sh` —— 需要 PATH 上有 Git for Windows 或 WSL;`bash` 找不到时 `claude-mem-lite doctor` 会报出来。GitHub Actions 没有 Windows runner,所以这一行依据的是用户报告([#28](https://github.com/sdsrss/claude-mem-lite/issues/28))而不是绿色流水线 |
|
|
106
|
+
| **WSL2** | 未测试 | 底层就是 Linux,预期与 Linux 行一致;但无人报告过实际结果 |
|
|
107
|
+
|
|
108
|
+
v5.1.0 到 v6.1.0 之间,`package.json` 声明的是 `os: ["darwin", "linux"]`。那是 npm 的**安装**门禁,不是运行时检查:
|
|
109
|
+
在 Windows 上它让 `npm install` 以 `EBADPLATFORM` 退出,而插件启动器每次插件更新后的首次 MCP 启动都要跑这条
|
|
110
|
+
安装 —— 于是 server 起不来,`/mcp` 报 `CONNECTION_CLOSED`。现在 `win32` 已加入该列表。仍不在列表内的平台会拿到
|
|
111
|
+
一条同时点明“声明了什么”和“当前是什么”的消息,而不是一句猜测。
|
|
106
112
|
|
|
107
113
|
## 环境要求
|
|
108
114
|
|
|
109
115
|
- **Node.js** >= 22(v4.0.0 起:better-sqlite3 13 要求 >=22,Node 20 已于 2026-04 EOL;`package.json` 的 `engines` 是唯一事实来源)
|
|
110
116
|
- **Claude Code** CLI 已安装并配置(`claude` 命令可用)
|
|
111
|
-
- **SQLite3** 支持(由 `better-sqlite3`
|
|
112
|
-
- **平台**:Linux 或 macOS(参见[平台支持](#平台支持))
|
|
117
|
+
- **SQLite3** 支持(由 `better-sqlite3` 13 提供,它自带 8 个平台的预编译产物,这些平台上都不需要编译器;没有对应预编译产物的平台才会回退到源码编译)
|
|
118
|
+
- **平台**:Linux 或 macOS;Windows 可安装运行,但无 CI 覆盖,且三个 hook 需要 Git Bash 或 WSL(参见[平台支持](#平台支持))
|
|
113
119
|
|
|
114
120
|
## 安装
|
|
115
121
|
|
package/hook-shared.mjs
CHANGED
|
@@ -22,6 +22,12 @@ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
|
22
22
|
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
23
23
|
// neither imports this file) — no cycle.
|
|
24
24
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
25
|
+
import {
|
|
26
|
+
isSchemaSkewError,
|
|
27
|
+
schemaSkewFromError,
|
|
28
|
+
shouldRecordSkew,
|
|
29
|
+
SKEW_MARKER_PREFIX,
|
|
30
|
+
} from './lib/schema-skew.mjs';
|
|
25
31
|
// Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
|
|
26
32
|
// predicates and the handoff constants moved into `lib/` because two lib modules
|
|
27
33
|
// imported them from here and dragged this file's whole import graph — haiku-client,
|
|
@@ -237,6 +243,7 @@ export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
|
|
|
237
243
|
// forever. `.skill-cooldown-` / `.skill-reco-cooldown-` left with the skill registry in
|
|
238
244
|
// v5.0.0; a prefix for files nothing writes any more is dead weight in a hot-path loop.
|
|
239
245
|
'last-mark-compressible-', // per-project auto-compress 24h gate
|
|
246
|
+
SKEW_MARKER_PREFIX, // per-project schema-skew log dedup; regenerated on the next skewed open
|
|
240
247
|
]);
|
|
241
248
|
|
|
242
249
|
// Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
|
|
@@ -382,12 +389,58 @@ export function createSessionId() {
|
|
|
382
389
|
|
|
383
390
|
// ─── Database ────────────────────────────────────────────────────────────────
|
|
384
391
|
|
|
392
|
+
// Last forward-incompat ("the DB is newer than me") failure seen in THIS process, or null.
|
|
393
|
+
// SessionStart needs the two version numbers to render its notice and openDb has just been
|
|
394
|
+
// handed them, so this beats a second DB open — and on a skew there may be no working
|
|
395
|
+
// binding to open with anyway.
|
|
396
|
+
let lastSkew = null;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The schema skew that made the most recent openDb() return null, or null.
|
|
400
|
+
* Cleared by any successful open, so a heal mid-session stops the notice.
|
|
401
|
+
*
|
|
402
|
+
* @returns {{dbVersion: number|null, binaryVersion: number|null}|null}
|
|
403
|
+
*/
|
|
404
|
+
export function lastSchemaSkew() {
|
|
405
|
+
return lastSkew;
|
|
406
|
+
}
|
|
407
|
+
|
|
385
408
|
export function openDb() {
|
|
386
409
|
try {
|
|
387
410
|
// WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
|
|
388
411
|
// silently dead (null DB) on a corrupt WAL until the next MCP server start.
|
|
389
|
-
|
|
412
|
+
const db = ensureDbWithWalRecovery();
|
|
413
|
+
lastSkew = null;
|
|
414
|
+
return db;
|
|
390
415
|
} catch (e) {
|
|
416
|
+
// Forward-incompat is its own family: it cannot be healed by anything this process can
|
|
417
|
+
// do, it repeats on every single open, and it is the one failure the USER has to act on.
|
|
418
|
+
// Record it once and hand the numbers to SessionStart, which is the surface that speaks.
|
|
419
|
+
// Forward-incompat is its own family: nothing this process can do heals it, it repeats on
|
|
420
|
+
// every single open, and it is the one failure the USER has to act on. Dedup lives in
|
|
421
|
+
// lib/schema-skew.mjs so the `ups` face — which opens the DB itself and logged its own 15
|
|
422
|
+
// of the day's 727 lines — shares one implementation instead of drifting from this one.
|
|
423
|
+
//
|
|
424
|
+
// shouldRecordSkew is TOTAL by contract. Nothing in this catch may throw: the first cut
|
|
425
|
+
// called getSessionId() here, which MINTS and writes a session id, so an unwritable
|
|
426
|
+
// runtime dir turned openDb() itself into a thrower. All 13 call sites are written to
|
|
427
|
+
// no-op on null and none of them expects an exception.
|
|
428
|
+
if (isSchemaSkewError(e)) {
|
|
429
|
+
lastSkew = schemaSkewFromError(e) || { dbVersion: null, binaryVersion: null };
|
|
430
|
+
// Guarded even though inferProject() reads env and cwd: "the only statement in this
|
|
431
|
+
// catch cannot throw" was true of the original one-line body and stopped being true
|
|
432
|
+
// the moment anything was added. An unscoped marker is a worse dedup, not a crash.
|
|
433
|
+
let project = '';
|
|
434
|
+
try {
|
|
435
|
+
project = inferProject();
|
|
436
|
+
} catch {
|
|
437
|
+
/* total: the marker degrades to one shared file */
|
|
438
|
+
}
|
|
439
|
+
if (shouldRecordSkew(RUNTIME_DIR, project, lastSkew)) {
|
|
440
|
+
recordHookError('hook-shared:db-open', e, RUNTIME_DIR);
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
391
444
|
// Still null, still no throw — a hook must never crash the host session, and all
|
|
392
445
|
// eight call sites in hook.mjs are written to no-op on null. But "returned null"
|
|
393
446
|
// used to be the ONLY trace: nothing reached runtime/hook-errors/, so `stats`
|
package/hook-update.mjs
CHANGED
|
@@ -228,7 +228,11 @@ function isPluginMode() {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
// ── Dev Mode Detection ─────────────────────────────────────
|
|
231
|
-
|
|
231
|
+
// Exported since the schema-skew notice needs it: a dev checkout must be told `git pull`,
|
|
232
|
+
// never a command that would overwrite its working tree. Re-implementing the check at the
|
|
233
|
+
// call site would make it the second copy of a predicate this file has already had to get
|
|
234
|
+
// right twice (whole-dir symlink, then per-file drift) — the twin-drift class.
|
|
235
|
+
export function isDevMode() {
|
|
232
236
|
try {
|
|
233
237
|
// A dev checkout always carries a .git dir. This catches a whole-directory
|
|
234
238
|
// symlink (~/.claude-mem-lite -> /repo): lstat on server.mjs there follows the
|
package/hook.mjs
CHANGED
|
@@ -60,7 +60,9 @@ import {
|
|
|
60
60
|
episodeHasSignificantContent,
|
|
61
61
|
explainSignificance,
|
|
62
62
|
} from './hook-episode.mjs';
|
|
63
|
-
|
|
63
|
+
// CODE_DIR, not DB_DIR: the schema-skew notice asks which CODE homes exist, and those are
|
|
64
|
+
// always homedir-rooted even when CLAUDE_MEM_DIR relocates the data.
|
|
65
|
+
import { DB_DIR, CODE_DIR } from './schema.mjs';
|
|
64
66
|
import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
|
|
65
67
|
import { entry as preCompactEntry } from './hook-precompact.mjs';
|
|
66
68
|
import {
|
|
@@ -82,6 +84,7 @@ import {
|
|
|
82
84
|
spawnBackground,
|
|
83
85
|
sweepOrphanEpisodeFiles,
|
|
84
86
|
sweepStaleProjectMarkers,
|
|
87
|
+
lastSchemaSkew,
|
|
85
88
|
} from './hook-shared.mjs';
|
|
86
89
|
import { handleLLMEpisode, handleLLMSummary, saveEpisodeImmediate } from './hook-llm.mjs';
|
|
87
90
|
import { readFastSummarySource, insertFastSummary, FAST_SUMMARY_LIMITS } from './lib/fast-summary.mjs';
|
|
@@ -2290,6 +2293,67 @@ async function buildStartupDashboardText(db, project) {
|
|
|
2290
2293
|
}
|
|
2291
2294
|
}
|
|
2292
2295
|
|
|
2296
|
+
/**
|
|
2297
|
+
* Tell the user their memory is version-skewed, on the one surface they read.
|
|
2298
|
+
*
|
|
2299
|
+
* Only fires when openDb() failed for THIS reason — hook-shared records the two version
|
|
2300
|
+
* numbers as it catches, so nothing is re-derived and no second DB open is attempted (on a
|
|
2301
|
+
* skew there may be no usable binding to open with).
|
|
2302
|
+
*
|
|
2303
|
+
* Everything is dynamically imported: this is a cold path that must not cost the healthy
|
|
2304
|
+
* SessionStart an install-shape scan. And it goes through the queue helpers, never a bare
|
|
2305
|
+
* console.log — SessionStart merges three would-be stdout contributors into ONE envelope,
|
|
2306
|
+
* and writing raw prose alongside it once made the host deliver the whole JSON object to
|
|
2307
|
+
* the model as literal text (tests/session-start-stdout-envelope.test.mjs).
|
|
2308
|
+
*/
|
|
2309
|
+
async function emitSchemaSkewNotice() {
|
|
2310
|
+
try {
|
|
2311
|
+
const skew = lastSchemaSkew();
|
|
2312
|
+
if (!skew) return;
|
|
2313
|
+
const [shapeMod, updateMod, skewMod] = await Promise.all([
|
|
2314
|
+
import('./lib/install-shape.mjs'),
|
|
2315
|
+
import('./hook-update.mjs'),
|
|
2316
|
+
import('./lib/schema-skew.mjs'),
|
|
2317
|
+
]);
|
|
2318
|
+
const shape = shapeMod.detectInstallShape({ installDir: CODE_DIR });
|
|
2319
|
+
// WHICH tree is running this hook, not which trees exist. CLAUDE_PLUGIN_ROOT is set in
|
|
2320
|
+
// every hook process Claude Code spawns, so on a machine holding BOTH a managed install
|
|
2321
|
+
// and a plugin cache it is the only thing that knows which one is behind. Deciding from
|
|
2322
|
+
// the machine's global shape printed `claude-mem-lite self-update` beneath a line naming
|
|
2323
|
+
// the plugin cache — a repair that cannot advance the tree it had just named.
|
|
2324
|
+
const runningRoot = process.env.CLAUDE_PLUGIN_ROOT || CODE_DIR;
|
|
2325
|
+
const remedy = skewMod.schemaSkewRemedy({
|
|
2326
|
+
managed: shape.managed,
|
|
2327
|
+
activePluginVersion: shape.activePluginVersion,
|
|
2328
|
+
dev: updateMod.isDevMode(),
|
|
2329
|
+
root: runningRoot,
|
|
2330
|
+
});
|
|
2331
|
+
const notice = skewMod.formatSchemaSkewNotice({
|
|
2332
|
+
dbVersion: skew.dbVersion,
|
|
2333
|
+
binaryVersion: skew.binaryVersion,
|
|
2334
|
+
remedy,
|
|
2335
|
+
// Name the home only when the remedy is about that home, so the two can never disagree.
|
|
2336
|
+
codeHome:
|
|
2337
|
+
remedy.kind === 'plugin' && shape.activePluginVersion
|
|
2338
|
+
? `plugin cache v${shape.activePluginVersion.version}`
|
|
2339
|
+
: undefined,
|
|
2340
|
+
});
|
|
2341
|
+
// BOTH channels, and the HUMAN one is the point. queueHookContext reaches the model;
|
|
2342
|
+
// lib/hook-stdout.mjs's queueHookSystemMessage is documented "for the HUMAN, not the
|
|
2343
|
+
// model" and names v3.70.0 for making exactly this mistake — folding a banner into
|
|
2344
|
+
// additionalContext "kept its content and lost its audience". A notice whose whole job is
|
|
2345
|
+
// to hand the user a command must not depend on the assistant volunteering it.
|
|
2346
|
+
// flushHookStdout merges both into one envelope, so this is additive: the model learns
|
|
2347
|
+
// memory is unavailable, the user gets the repair.
|
|
2348
|
+
queueHookSystemMessage(notice);
|
|
2349
|
+
queueHookContext('SessionStart', notice);
|
|
2350
|
+
} catch (e) {
|
|
2351
|
+
// A hook must never crash the host session, and a notice that cannot render is still
|
|
2352
|
+
// better handled by staying quiet than by taking SessionStart down with it.
|
|
2353
|
+
debugCatch(e, 'session-start-schema-skew');
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2293
2357
|
async function handleSessionStart() {
|
|
2294
2358
|
// GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
|
|
2295
2359
|
// moved here from pre-tool-recall.js's hot path.
|
|
@@ -2471,7 +2535,16 @@ async function handleSessionStart() {
|
|
|
2471
2535
|
const project = inferProject();
|
|
2472
2536
|
|
|
2473
2537
|
const db = openDb();
|
|
2474
|
-
if (!db)
|
|
2538
|
+
if (!db) {
|
|
2539
|
+
// A null DB used to end SessionStart in total silence. For most causes that is right —
|
|
2540
|
+
// they are transient, or a repair path is already running. Forward-incompat is neither:
|
|
2541
|
+
// it persists until the user installs newer code, it disables every write path, and the
|
|
2542
|
+
// only other signal it produces is a `-32000 Connection closed` from the MCP host, which
|
|
2543
|
+
// names nothing. Measured 2026-09-08: a whole day of it, >=648 log lines, zero words to
|
|
2544
|
+
// the user. This is the surface the user actually reads.
|
|
2545
|
+
await emitSchemaSkewNotice();
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2475
2548
|
|
|
2476
2549
|
try {
|
|
2477
2550
|
const now = new Date();
|
package/install.mjs
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
scanPluginCacheHookPollution,
|
|
62
62
|
hasInstallManagedHooks,
|
|
63
63
|
pluginCacheHookEvents,
|
|
64
|
+
settingsHookCommands,
|
|
64
65
|
} from './plugin-cache-guard.mjs';
|
|
65
66
|
import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
|
|
66
67
|
import {
|
|
@@ -69,6 +70,7 @@ import {
|
|
|
69
70
|
nativeBindingRepairHint,
|
|
70
71
|
} from './lib/binding-probe.mjs';
|
|
71
72
|
import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
|
|
73
|
+
import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
|
|
72
74
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
73
75
|
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
74
76
|
import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
|
|
@@ -266,6 +268,59 @@ export function buildDoctorSummary(issues, warnings) {
|
|
|
266
268
|
return `${issues} issue(s) found.${warnSuffix}`;
|
|
267
269
|
}
|
|
268
270
|
|
|
271
|
+
/**
|
|
272
|
+
* How many LIVE hook commands invoke `bash`, and which scripts they are.
|
|
273
|
+
*
|
|
274
|
+
* There are two hook registrations and only one is live per install shape, which is what
|
|
275
|
+
* the first cut of doctor's interpreter check got wrong (pre-ship review P1-1). The plugin
|
|
276
|
+
* shape reads `hooks/hooks.json` out of the plugin cache. The npm / npx / `git clone` shape
|
|
277
|
+
* has no such file — `hooks/hooks.json` is in RELEASE_SIGNED_FILES but NOT in SOURCE_FILES,
|
|
278
|
+
* so nothing deploys it to ~/.claude-mem-lite/ — and registers its hooks in settings.json
|
|
279
|
+
* instead. Reading only the manifest therefore answered "zero bash hooks" on the one shape
|
|
280
|
+
* where two of them are live.
|
|
281
|
+
*
|
|
282
|
+
* Returns THREE outcomes, never two. `count: null` means no registration could be read, and
|
|
283
|
+
* that is deliberately distinct from a count of zero: zero is an answer, null is the absence
|
|
284
|
+
* of one, and a diagnostic that reports them identically tells the reader to stop looking.
|
|
285
|
+
*
|
|
286
|
+
* @param {{manifestPath: string, settingsCommands?: string[], installDir: string}} opts
|
|
287
|
+
* @returns {{count: number|null, source: 'manifest'|'settings'|null, scripts: string[]}}
|
|
288
|
+
*/
|
|
289
|
+
export function resolveBashHookCount({ manifestPath, settingsCommands = [], installDir }) {
|
|
290
|
+
const basenames = (commands) =>
|
|
291
|
+
commands
|
|
292
|
+
.map((c) => {
|
|
293
|
+
const m = c.match(/([^/"\s]+\.sh)/);
|
|
294
|
+
return m ? m[1] : c;
|
|
295
|
+
})
|
|
296
|
+
.sort();
|
|
297
|
+
|
|
298
|
+
if (existsSync(manifestPath)) {
|
|
299
|
+
try {
|
|
300
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
301
|
+
const commands = [];
|
|
302
|
+
for (const matchers of Object.values(parsed?.hooks || {})) {
|
|
303
|
+
for (const m of matchers || []) {
|
|
304
|
+
for (const h of m?.hooks || []) commands.push(String(h?.command || ''));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const bash = commands.filter((c) => c.startsWith('bash '));
|
|
308
|
+
return { count: bash.length, source: 'manifest', scripts: basenames(bash) };
|
|
309
|
+
} catch {
|
|
310
|
+
// A torn manifest is not evidence of zero bash hooks. Fall through to settings.json,
|
|
311
|
+
// and if that says nothing about us either, the caller gets null.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Only OUR entries: settings.json is shared with every other tool the user installs, so a
|
|
315
|
+
// foreign `bash "…"` line is not ours to report on, and — the discriminating half — a
|
|
316
|
+
// settings.json that names nothing of ours is not evidence that no hook needs bash. It is
|
|
317
|
+
// evidence we are reading the wrong registration.
|
|
318
|
+
const ours = settingsCommands.filter((c) => c.includes(installDir));
|
|
319
|
+
if (ours.length === 0) return { count: null, source: null, scripts: [] };
|
|
320
|
+
const bash = ours.filter((c) => c.startsWith('bash '));
|
|
321
|
+
return { count: bash.length, source: 'settings', scripts: basenames(bash) };
|
|
322
|
+
}
|
|
323
|
+
|
|
269
324
|
// Dev installs symlink server.mjs → the project's source file. Used to suppress
|
|
270
325
|
// misleading "first run" messages since hook-update.mjs skips state-writes in
|
|
271
326
|
// this mode (see hook-update.mjs isDevMode).
|
|
@@ -1425,6 +1480,73 @@ async function doctor() {
|
|
|
1425
1480
|
}
|
|
1426
1481
|
}
|
|
1427
1482
|
|
|
1483
|
+
// Can each code home actually OPEN this database? A binding that loads is not the same
|
|
1484
|
+
// question: better-sqlite3 can be perfect and the store still unreadable, because
|
|
1485
|
+
// schema.mjs refuses a DB written by a newer claude-mem-lite (correctly — replaying old
|
|
1486
|
+
// migrations over a newer layout would corrupt it). That is a one-way ratchet, and on a
|
|
1487
|
+
// plugin install it is REACHED ROUTINELY: the cache only advances when Claude Code's
|
|
1488
|
+
// marketplace updater advances it, so anything else that opens the DB — an npm-global
|
|
1489
|
+
// CLI, a dev checkout — can leave the cache locked out. Measured 2026-09-08: DB v49 vs a
|
|
1490
|
+
// live 5.6.0 cache supporting v48, >=648 identical hook errors in one day, and the only
|
|
1491
|
+
// user-visible signal was `-32000 Connection closed` from the MCP host.
|
|
1492
|
+
//
|
|
1493
|
+
// Probed per root, out of process, exactly like the binding check above and for the same
|
|
1494
|
+
// reason: this is the check that has to survive answering the question, and importing
|
|
1495
|
+
// another tree's schema.mjs would poison the process that must report the answer. It is
|
|
1496
|
+
// also why this check is USEFUL TODAY rather than only after the next upgrade — doctor
|
|
1497
|
+
// runs from whichever tree the user invoked, so new code here can diagnose an old cache.
|
|
1498
|
+
if (!existsSync(DB_PATH)) {
|
|
1499
|
+
ok('DB schema: no database yet — nothing to compare');
|
|
1500
|
+
} else if (rootProbes.length === 0) {
|
|
1501
|
+
// The fourth outcome the first cut had and did not print. The `fail` above already tells
|
|
1502
|
+
// the reader no install owns a binding, but a block whose stated design point is "three
|
|
1503
|
+
// outcomes, never two" must not answer a fourth case with silence.
|
|
1504
|
+
dwarn('DB schema: not checked — no install on this machine owns a native binding to read it with');
|
|
1505
|
+
} else {
|
|
1506
|
+
const compat = probeSchemaCompat(shape.runtimeRoots, DB_PATH);
|
|
1507
|
+
const behind = compat.filter((c) => c.status === 'skew');
|
|
1508
|
+
const unknown = compat.filter((c) => c.status === 'unknown');
|
|
1509
|
+
if (behind.length === 0 && unknown.length === 0) {
|
|
1510
|
+
ok(`DB schema: v${compat[0]?.dbVersion} — readable by all ${compat.length} install(s)`);
|
|
1511
|
+
}
|
|
1512
|
+
if (behind.length > 0) {
|
|
1513
|
+
// Dynamic: only a skewed machine pays for it, and it reuses hook-update's isDevMode
|
|
1514
|
+
// rather than re-deriving "is this a checkout", which that file has already had to
|
|
1515
|
+
// correct twice (whole-dir symlink, then per-file drift).
|
|
1516
|
+
let dev = false;
|
|
1517
|
+
try {
|
|
1518
|
+
const { isDevMode } = await import('./hook-update.mjs');
|
|
1519
|
+
dev = isDevMode();
|
|
1520
|
+
} catch {
|
|
1521
|
+
/* unreadable → the initialiser stands: a non-dev install gets the common remedy */
|
|
1522
|
+
}
|
|
1523
|
+
for (const b of behind) {
|
|
1524
|
+
// PER ROOT, inside the loop. Computing one remedy for every skewed tree printed the
|
|
1525
|
+
// machine's global answer beneath a label naming a different tree — on a mixed
|
|
1526
|
+
// managed+plugin install that meant `self-update` under "plugin cache v5.6.0",
|
|
1527
|
+
// which advances nothing. b.root is the tree that is actually behind.
|
|
1528
|
+
const remedy = schemaSkewRemedy({
|
|
1529
|
+
managed: shape.managed,
|
|
1530
|
+
activePluginVersion: shape.activePluginVersion,
|
|
1531
|
+
dev,
|
|
1532
|
+
root: b.root,
|
|
1533
|
+
});
|
|
1534
|
+
// fail, not warn: every write path is dead in this state and only the user can fix it.
|
|
1535
|
+
fail(`DB schema v${b.dbVersion} is newer than ${b.label}, which supports up to v${b.supported}`);
|
|
1536
|
+
for (const c of remedy.commands) log(` ${c}`);
|
|
1537
|
+
if (remedy.note) log(` ${remedy.note}`);
|
|
1538
|
+
issues++;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
for (const u of unknown) {
|
|
1542
|
+
// Deliberately its own outcome. "I could not determine what this install supports"
|
|
1543
|
+
// printed as a green line is the defect the v6.2.0 round wrote and its pre-ship review
|
|
1544
|
+
// caught before the tag — a check that says "nothing to check" and "I could not look"
|
|
1545
|
+
// in the same voice ends the reader's search instead of directing it.
|
|
1546
|
+
dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1428
1550
|
try {
|
|
1429
1551
|
await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
1430
1552
|
ok('@modelcontextprotocol/sdk: verified (import OK)');
|
|
@@ -1865,6 +1987,68 @@ async function doctor() {
|
|
|
1865
1987
|
dwarn('Hook scripts: check failed — ' + e.message);
|
|
1866
1988
|
}
|
|
1867
1989
|
|
|
1990
|
+
// Hook interpreter. Some hook commands are `bash "<script>"` (the PostToolUse and
|
|
1991
|
+
// Agent prefilters, plus setup.sh in the plugin manifest) — the rest are `node`. If bash
|
|
1992
|
+
// cannot run, those commands fail and nothing says so; the check above grades whether the
|
|
1993
|
+
// FILES are present, which they are.
|
|
1994
|
+
//
|
|
1995
|
+
// Keyed on whether bash runs, not on process.platform === 'win32'. A Windows user with
|
|
1996
|
+
// Git for Windows on PATH — the normal case, since Claude Code shells out to bash for its
|
|
1997
|
+
// own Bash tool — has a working configuration and must not be warned; a stripped
|
|
1998
|
+
// container with no bash has a broken one and must be, whatever its platform. This is
|
|
1999
|
+
// also what issue #28's P3-19 intent asked for: `os: [darwin, linux]` was added so a
|
|
2000
|
+
// Windows user "should be told rather than handed a string of silent catch blocks", and
|
|
2001
|
+
// blocking the install told them nothing. This is the telling.
|
|
2002
|
+
try {
|
|
2003
|
+
const {
|
|
2004
|
+
count: bashCommands,
|
|
2005
|
+
source: countSource,
|
|
2006
|
+
scripts: bashScripts,
|
|
2007
|
+
} = resolveBashHookCount({
|
|
2008
|
+
manifestPath: join(PROJECT_DIR, 'hooks', 'hooks.json'),
|
|
2009
|
+
settingsCommands: settingsHookCommands(homedir()),
|
|
2010
|
+
installDir: INSTALL_DIR,
|
|
2011
|
+
});
|
|
2012
|
+
if (bashCommands === null) {
|
|
2013
|
+
// NOT `ok`. Pre-ship review (P1-1) found the first cut printing "no hook command needs
|
|
2014
|
+
// bash" here, on a shape where two of them are registered — a green line that ends the
|
|
2015
|
+
// reader's search is worse than the silence this check exists to remove.
|
|
2016
|
+
dwarn(
|
|
2017
|
+
'Hook interpreter: could not read either hook registration — neither ' +
|
|
2018
|
+
`${join(PROJECT_DIR, 'hooks', 'hooks.json')} nor a claude-mem-lite entry in ` +
|
|
2019
|
+
`${join(homedir(), '.claude', 'settings.json')} — so whether any hook needs bash is unknown.`,
|
|
2020
|
+
);
|
|
2021
|
+
} else if (bashCommands === 0) {
|
|
2022
|
+
ok(`Hook interpreter: no hook command needs bash (per the ${countSource})`);
|
|
2023
|
+
} else {
|
|
2024
|
+
let bashOk = false;
|
|
2025
|
+
try {
|
|
2026
|
+
execFileSync('bash', ['-c', 'exit 0'], { stdio: 'ignore', timeout: 5000 });
|
|
2027
|
+
bashOk = true;
|
|
2028
|
+
} catch {
|
|
2029
|
+
/* not resolvable, or not runnable — either way the hooks that need it cannot fire */
|
|
2030
|
+
}
|
|
2031
|
+
if (bashOk) {
|
|
2032
|
+
ok(`Hook interpreter: bash present (${bashCommands} hook command(s) need it)`);
|
|
2033
|
+
} else {
|
|
2034
|
+
// dwarn, not an issue: everything else works. Saying "broken" about an install
|
|
2035
|
+
// whose MCP server and node hooks are fine would be the mirror of the defect that
|
|
2036
|
+
// sent this round's reporter looking at their disk and their network.
|
|
2037
|
+
// The scripts are NAMED from the live registration rather than described from
|
|
2038
|
+
// memory — the first cut wrote "(episode Read-tracking and the subagent prefilter)",
|
|
2039
|
+
// a two-item gloss on a count of three (P3-1).
|
|
2040
|
+
dwarn(
|
|
2041
|
+
`Hook interpreter: bash not found on PATH — the ${bashCommands} hook command(s) that ` +
|
|
2042
|
+
`invoke it cannot fire (${bashScripts.join(', ')}). The MCP server and the node ` +
|
|
2043
|
+
'hooks are unaffected. On Windows, install Git for Windows or use WSL; elsewhere ' +
|
|
2044
|
+
'this means a stripped PATH.',
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
} catch (e) {
|
|
2049
|
+
dwarn('Hook interpreter: check failed — ' + e.message);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
1868
2052
|
// Stale temp files
|
|
1869
2053
|
try {
|
|
1870
2054
|
// hook-update + the episode workers write runtime/ + staging under DB_DIR
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// lib/platform-gate.mjs — is THIS platform inside the `os` list package.json declares?
|
|
2
|
+
//
|
|
3
|
+
// Why this is a module and not four lines inside scripts/launch.mjs, which is its only
|
|
4
|
+
// consumer: launch.mjs cannot be imported. Its top level installs dependencies, probes the
|
|
5
|
+
// native binding and then imports the MCP server, so a test that imported it would start a
|
|
6
|
+
// server. The alternative to a real import is a source-text scan of launch.mjs, which this
|
|
7
|
+
// repo has repeatedly found to be walkable — a guard that greps for a string passes for a
|
|
8
|
+
// change that keeps the string and deletes the behaviour. So the predicate lives here where
|
|
9
|
+
// tests can drive it, and launch.mjs keeps the wording and the exit.
|
|
10
|
+
//
|
|
11
|
+
// `os` is an npm INSTALL GATE, evaluated against the ROOT package being installed. npm
|
|
12
|
+
// rejects with EBADPLATFORM before it resolves anything, which is why a platform left off
|
|
13
|
+
// the list does not degrade — it fails the install outright, and in plugin mode that install
|
|
14
|
+
// is on the path of every first MCP launch after an update (issue #28).
|
|
15
|
+
//
|
|
16
|
+
// Node built-ins only: this runs BEFORE `npm install`, so node_modules may not exist yet.
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* npm's own `checkList` from npm-install-checks, reproduced rather than approximated.
|
|
23
|
+
*
|
|
24
|
+
* The rule is not "is it in the list": an entry may be NEGATED with `!`, and a list of
|
|
25
|
+
* nothing but negations allows every platform it does not name. `list.includes(platform)`
|
|
26
|
+
* agrees with npm on the simple case and disagrees on `["!win32"]` — where it would tell a
|
|
27
|
+
* linux user their platform is unsupported while npm installs happily. A diagnostic that is
|
|
28
|
+
* wrong in that direction is worse than none.
|
|
29
|
+
*
|
|
30
|
+
* @param {string[]|string} list The `os` value as declared.
|
|
31
|
+
* @param {string} platform A `process.platform` value.
|
|
32
|
+
* @returns {boolean} True when npm would allow the install.
|
|
33
|
+
*/
|
|
34
|
+
export function platformAllowed(list, platform) {
|
|
35
|
+
const entries = typeof list === 'string' ? [list] : list;
|
|
36
|
+
if (!Array.isArray(entries)) return true;
|
|
37
|
+
if (entries.length === 1 && entries[0] === 'any') return true;
|
|
38
|
+
let negated = 0;
|
|
39
|
+
let match = false;
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const negate = typeof entry === 'string' && entry.startsWith('!');
|
|
42
|
+
const test = negate ? entry.slice(1) : entry;
|
|
43
|
+
if (negate) {
|
|
44
|
+
negated++;
|
|
45
|
+
if (platform === test) return false;
|
|
46
|
+
} else if (platform === test) {
|
|
47
|
+
match = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// An all-negation list (and, by the same arithmetic, an empty one) allows anything it
|
|
51
|
+
// has not vetoed above.
|
|
52
|
+
return match || negated === entries.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The `os` list `<root>/package.json` declares, or null when there is nothing to enforce.
|
|
57
|
+
*
|
|
58
|
+
* Fails OPEN on every error. A missing or torn package.json is the incomplete-install case
|
|
59
|
+
* that launch-preflight.mjs already diagnoses with a repair command; turning it into a
|
|
60
|
+
* platform complaint here would replace a good message with a wrong one.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} root Directory holding package.json.
|
|
63
|
+
* @returns {string[]|null}
|
|
64
|
+
*/
|
|
65
|
+
export function readDeclaredPlatforms(root) {
|
|
66
|
+
const p = join(root, 'package.json');
|
|
67
|
+
if (!existsSync(p)) return null;
|
|
68
|
+
try {
|
|
69
|
+
const os = JSON.parse(readFileSync(p, 'utf8'))?.os;
|
|
70
|
+
if (typeof os === 'string') return [os];
|
|
71
|
+
return Array.isArray(os) ? os : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Would npm refuse to install this package on this platform?
|
|
79
|
+
*
|
|
80
|
+
* @param {{root: string, platform?: string}} opts
|
|
81
|
+
* @returns {{blocked: boolean, declared: string[]|null, platform: string}}
|
|
82
|
+
*/
|
|
83
|
+
export function platformGate({ root, platform = process.platform }) {
|
|
84
|
+
const declared = readDeclaredPlatforms(root);
|
|
85
|
+
if (declared === null) return { blocked: false, declared: null, platform };
|
|
86
|
+
return { blocked: !platformAllowed(declared, platform), declared, platform };
|
|
87
|
+
}
|