claude-mem-lite 3.97.0 → 3.99.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 +13 -0
- package/adopt-content.mjs +45 -29
- package/claudemd.mjs +16 -2
- package/cli/common.mjs +12 -4
- package/lib/citation-tracker.mjs +18 -0
- package/mem-cli.mjs +8 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/registry-github.mjs +21 -3
- package/registry-importer.mjs +117 -2
- package/server.mjs +174 -118
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.99.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": "3.
|
|
3
|
+
"version": "3.99.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
|
@@ -809,6 +809,19 @@ claude-mem-lite.
|
|
|
809
809
|
| `CLAUDE_MEM_NO_TEMPLATE_REFRESH` | `1` stops SessionStart from refreshing the adopted `CLAUDE.md` managed block when the shipped template changes. | _(refreshes)_ |
|
|
810
810
|
| `MEM_QUIET_HOOKS` | See Core above — the broadest injection-volume switch. | _(disabled)_ |
|
|
811
811
|
|
|
812
|
+
### Registry import bounds
|
|
813
|
+
|
|
814
|
+
`registry import-url` pulls from a third-party repository, so it is bounded. Entries past a
|
|
815
|
+
bound are refused, not truncated, and the refusal is printed with the import result. Set any
|
|
816
|
+
of these to `0` for the pre-v3.98 unlimited behavior; an unparseable or negative value keeps
|
|
817
|
+
the default rather than removing the bound.
|
|
818
|
+
|
|
819
|
+
| Variable | Description | Default |
|
|
820
|
+
|----------|-------------|---------|
|
|
821
|
+
| `CLAUDE_MEM_IMPORT_MAX_ITEMS` | Max skills/agents imported from one repository. | `200` |
|
|
822
|
+
| `CLAUDE_MEM_IMPORT_MAX_FILE_BYTES` | Max size of a single `SKILL.md`/`AGENT.md`. Oversized entries are skipped; the rest still import. | `2097152` (2 MB) |
|
|
823
|
+
| `CLAUDE_MEM_IMPORT_MAX_TOTAL_BYTES` | Byte budget for one import run. Exhausting it stops the walk and books the remainder as refused. | `52428800` (50 MB) |
|
|
824
|
+
|
|
812
825
|
### Retrieval tuning
|
|
813
826
|
|
|
814
827
|
Prompt-time search (`UPS_*` = the UserPromptSubmit surface). Defaults are the values the
|
package/adopt-content.mjs
CHANGED
|
@@ -17,11 +17,21 @@
|
|
|
17
17
|
// memory-dir MEMORY.md sentinel also carried `v1`, but it lives in a different file
|
|
18
18
|
// and is migrated away (claudemd.migrateLegacyMemoryDir), so there is no collision.
|
|
19
19
|
|
|
20
|
-
import { CLI_INVOKE } from './cli-path.mjs';
|
|
21
|
-
|
|
22
20
|
export const PLUGIN_SLUG = 'claude-mem-lite';
|
|
23
21
|
export const CURRENT_SENTINEL_VERSION = 'v1';
|
|
24
22
|
|
|
23
|
+
// The CLI name as written into the user's project tree — deliberately NOT `CLI_INVOKE`
|
|
24
|
+
// (audit R7 P2-1). CLI_INVOKE resolves to an absolute, VERSION-PINNED path
|
|
25
|
+
// (`node /home/<user>/.claude/plugins/cache/sdsrss/claude-mem-lite/<version>/cli.mjs`), and
|
|
26
|
+
// both generators below write files the user may commit: the managed block lands in
|
|
27
|
+
// <cwd>/CLAUDE.md and the detail doc in <cwd>/.claude/, which is the standard home for
|
|
28
|
+
// project-scoped settings/commands/agents and is commonly tracked. Embedding the resolved
|
|
29
|
+
// path there rewrote the file on every plugin release (needsRefresh sees doc drift) and gave
|
|
30
|
+
// teammates a $HOME path that exists on no other machine. This module's output must be
|
|
31
|
+
// byte-identical across installs; the resolved path belongs only on runtime-generated
|
|
32
|
+
// surfaces that never touch the repo (MCP `instructions`, hook recovery lines).
|
|
33
|
+
const CLI = 'claude-mem-lite';
|
|
34
|
+
|
|
25
35
|
/**
|
|
26
36
|
* The concise managed block injected into <cwd>/CLAUDE.md (between the
|
|
27
37
|
* slug-scoped sentinels — those are added by claudemd.renderBlock, NOT here).
|
|
@@ -32,8 +42,8 @@ export const CURRENT_SENTINEL_VERSION = 'v1';
|
|
|
32
42
|
export function buildClaudeMdBlock() {
|
|
33
43
|
// Intentionally machine-stable: MCP tool names only, NO CLI_INVOKE (that
|
|
34
44
|
// resolves to an absolute path that differs per install — it would make this
|
|
35
|
-
// committed/refreshed block churn across machines). The
|
|
36
|
-
//
|
|
45
|
+
// committed/refreshed block churn across machines). The detail doc holds the
|
|
46
|
+
// full CLI table and, since R7 P2-1, is held to the same standard — see CLI above.
|
|
37
47
|
return `## claude-mem-lite — persistent memory
|
|
38
48
|
|
|
39
49
|
PreToolUse hooks already run \`mem_recall\` for past lessons before Read/Edit/Write. The calls worth making proactively:
|
|
@@ -46,7 +56,7 @@ PreToolUse hooks already run \`mem_recall\` for past lessons before Read/Edit/Wr
|
|
|
46
56
|
| Deferring to a future session | \`mem_defer({title, priority:1|2|3, detail})\`; when fixed, add \`closes_deferred=[N]\` to \`mem_save\` |
|
|
47
57
|
| Looking up past work / history | \`mem_search "keywords"\` · \`mem_recent\` · \`mem_timeline\` |
|
|
48
58
|
|
|
49
|
-
Path cost is round-trips, not milliseconds: the PreToolUse hook above already recalls (0 calls) — prefer it. For an explicit query, if these \`mem_*\` tools are deferred behind ToolSearch this session, the Bash CLI
|
|
59
|
+
Path cost is round-trips, not milliseconds: the PreToolUse hook above already recalls (0 calls) — prefer it. For an explicit query, if these \`mem_*\` tools are deferred behind ToolSearch this session, the Bash CLI \`${CLI}\` is one call vs two (ToolSearch + call); the MCP server instructions carry the absolute path to use when it is not on PATH.
|
|
50
60
|
|
|
51
61
|
Full tool + CLI tables, citation/decay rules, and save discipline → \`.claude/plugin_claude_mem_lite.md\``;
|
|
52
62
|
}
|
|
@@ -60,10 +70,16 @@ Full tool + CLI tables, citation/decay rules, and save discipline → \`.claude/
|
|
|
60
70
|
export function getDetailDoc() {
|
|
61
71
|
return `# claude-mem-lite 插件契约(完整)
|
|
62
72
|
|
|
63
|
-
> 由 \`${
|
|
73
|
+
> 由 \`${CLI} adopt\` 生成、随版本自动刷新;卸载用 \`${CLI} unadopt\`。
|
|
64
74
|
> 精炼触发表在项目 \`CLAUDE.md\` 的 \`claude-mem-lite\` 托管块里;本文件是其展开。
|
|
65
75
|
> 设计背景见 docs/CLAUDE-MD-STEERING-PLAN.md。
|
|
66
76
|
|
|
77
|
+
> **本文下方所有命令写作 \`${CLI} <cmd>\`。** 该名字只在全局装过
|
|
78
|
+
> (\`npm i -g claude-mem-lite\`)时才在 PATH 上;否则用等价的
|
|
79
|
+
> \`node <插件根目录>/cli.mjs <cmd>\`,绝对路径见本会话 MCP server 的 instructions。
|
|
80
|
+
> 本文件**刻意不写死绝对路径**:它随安装位置与版本变化,而本文件可能被提交进仓库,
|
|
81
|
+
> 写死会导致每次升版都改动该文件、且队友拿到的是只在别人机器上存在的路径。
|
|
82
|
+
|
|
67
83
|
## 被动 recall(hook 已自动跑,你只需采纳)
|
|
68
84
|
|
|
69
85
|
PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该文件:
|
|
@@ -108,7 +124,7 @@ PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该
|
|
|
108
124
|
lesson_learned="<一行根因+一行修法>", importance=2)\`。判据:未来改同一文件的会话看到这条能否避坑?能→存。
|
|
109
125
|
- **非显然架构决策后**(≠ 改名/挪代码)调 \`mem_save(type="decision",
|
|
110
126
|
lesson_learned="<约束+为何这样选+牺牲了什么>")\`。\`decision\` 命中率显著高于 \`change\`(当前遥测约
|
|
111
|
-
3:1,会漂移——用 \`${
|
|
127
|
+
3:1,会漂移——用 \`${CLI} stats\` 实测,别套固定倍数);方向稳健:一条好 decision 抵数条 change。
|
|
112
128
|
别注水:decision 只留给真权衡,不是风格选择。
|
|
113
129
|
- **推迟到未来会话**(≠ 在途 todo、≠ 本 PR 跟进)调
|
|
114
130
|
\`mem_defer({title, priority:1|2|3, detail:"<约束+为何推迟>"})\`。
|
|
@@ -127,45 +143,45 @@ PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该
|
|
|
127
143
|
|
|
128
144
|
| 场景 | CLI |
|
|
129
145
|
|------|-----|
|
|
130
|
-
| 清理过期记忆 | \`${
|
|
131
|
-
| 深度优化(Haiku) | \`${
|
|
132
|
-
| 压缩旧条目 | \`${
|
|
133
|
-
| FTS5 索引检查 / 重建 | \`${
|
|
134
|
-
| tier 分组浏览 | \`${
|
|
135
|
-
| 导出 JSON/JSONL | \`${
|
|
136
|
-
| 统计总量 / 健康 | \`${
|
|
137
|
-
| 删除 / 更新某条 | \`${
|
|
138
|
-
| skill-agent registry | \`${
|
|
146
|
+
| 清理过期记忆 | \`${CLI} maintain scan --ops purge_stale\` → \`maintain execute --ops purge_stale --confirm\`(删行必须 \`--confirm\`) |
|
|
147
|
+
| 深度优化(Haiku) | \`${CLI} optimize\`(默认 preview;\`--run\` 执行,\`--task re-enrich,normalize,cluster-merge,smart-compress\`) |
|
|
148
|
+
| 压缩旧条目 | \`${CLI} compress\`(默认 preview;\`--execute\` 执行,\`--age-days N\`) |
|
|
149
|
+
| FTS5 索引检查 / 重建 | \`${CLI} fts-check <check\\|rebuild>\` |
|
|
150
|
+
| tier 分组浏览 | \`${CLI} browse [--tier active]\` |
|
|
151
|
+
| 导出 JSON/JSONL | \`${CLI} export [--format jsonl]\` |
|
|
152
|
+
| 统计总量 / 健康 | \`${CLI} stats [--days 30]\` |
|
|
153
|
+
| 删除 / 更新某条 | \`${CLI} delete <id>[,<id>]\` · \`${CLI} update <id> [--title ...]\` |
|
|
154
|
+
| skill-agent registry | \`${CLI} registry <list\\|search\\|import>\` |
|
|
139
155
|
|
|
140
156
|
## CLI 速查(常用检索)
|
|
141
157
|
|
|
142
158
|
| 命令 | 用途 |
|
|
143
159
|
|------|------|
|
|
144
|
-
| \`${
|
|
145
|
-
| \`${
|
|
146
|
-
| \`${
|
|
147
|
-
| \`${
|
|
148
|
-
| \`${
|
|
149
|
-
| \`${
|
|
160
|
+
| \`${CLI} search "query"\` | FTS5 全文搜索(默认排除低信号 \`Modified X\` 等;加 \`--include-noise\` 找文件变更记录) |
|
|
161
|
+
| \`${CLI} search "err" --type bugfix\` | 按类型过滤 |
|
|
162
|
+
| \`${CLI} recall "file.mjs"\` | 文件相关记忆 |
|
|
163
|
+
| \`${CLI} recent 5\` | 最近 5 条 |
|
|
164
|
+
| \`${CLI} get 42,43\` | 按 ID 展开 |
|
|
165
|
+
| \`${CLI} timeline --anchor 42\` | 时间线上下文 |
|
|
150
166
|
|
|
151
167
|
## CLI 速查(写入 / 记录)
|
|
152
168
|
|
|
153
|
-
写入类工具多从 \`tools/list\` 隐藏 → 只能走 CLI。下表带**硬上限**(超限直接报错,别撞了才知道);完整 flag 见 \`${
|
|
169
|
+
写入类工具多从 \`tools/list\` 隐藏 → 只能走 CLI。下表带**硬上限**(超限直接报错,别撞了才知道);完整 flag 见 \`${CLI} help\`。
|
|
154
170
|
|
|
155
171
|
| 命令 | 签名(含硬约束) |
|
|
156
172
|
|------|------------------|
|
|
157
|
-
| 存观测 | \`${
|
|
158
|
-
| 推迟工作 | \`${
|
|
159
|
-
| 改某条 | \`${
|
|
160
|
-
| 事件日志 | \`${
|
|
173
|
+
| 存观测 | \`${CLI} save "<text>" --type bugfix\\|decision --lesson "<≤500 字符>" [--importance 1-3] [--closes-deferred N]\` — \`<text>\` **必填定位参数**;\`--lesson\` 超 500 直接 fail |
|
|
174
|
+
| 推迟工作 | \`${CLI} defer add "<title ≤200>" [--priority 1\\|2\\|3] [--detail "<约束+为何推迟>"]\` — 标题 >200 挪到 \`--detail\` |
|
|
175
|
+
| 改某条 | \`${CLI} update <id> [--lesson "<≤500>"] [--title T] [--type T] [--importance 1-3] [--narrative T] [--concepts "a b c"]\` |
|
|
176
|
+
| 事件日志 | \`${CLI} activity save --type <bugfix\\|lesson\\|bug\\|discovery\\|refactor\\|feature\\|observation\\|decision> "<title>" [--body T] [--files f1,f2]\` |
|
|
161
177
|
|
|
162
178
|
\`maintain\` / \`optimize\` / \`compress\` 见上方「维护 / 管理类工具」;\`maintain --ops\` 取值 \`cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum\`,省略时默认 \`cleanup,decay,boost,demote_pinned\`(顺序有意义:demote_pinned 必须在 boost 之后);\`--retain-days\` ∈ [7,365]。
|
|
163
179
|
|
|
164
180
|
## 卸载 / 关闭
|
|
165
181
|
|
|
166
|
-
- \`${
|
|
182
|
+
- \`${CLI} unadopt\`:移除 CLAUDE.md 托管块 + \`.claude/plugin_claude_mem_lite.md\`;
|
|
167
183
|
CLAUDE.md 里你自己的内容(sentinel 之外)不动。
|
|
168
|
-
- 本项目永久关闭自动 adopt:\`${
|
|
184
|
+
- 本项目永久关闭自动 adopt:\`${CLI} adopt --disable\`(\`--enable\` 重新武装)。
|
|
169
185
|
- 全局禁用自动 adopt:环境变量 \`MEM_NO_AUTO_ADOPT=1\`。
|
|
170
186
|
- 关闭版本漂移自动刷新(保留你对托管块的手改):\`CLAUDE_MEM_NO_TEMPLATE_REFRESH=1\`。
|
|
171
187
|
`;
|
package/claudemd.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// See docs/CLAUDE-MD-STEERING-PLAN.md for rationale + migration.
|
|
18
18
|
|
|
19
|
-
import { readFileSync, existsSync, unlinkSync, mkdirSync, rmdirSync, readdirSync } from 'fs';
|
|
19
|
+
import { readFileSync, existsSync, unlinkSync, mkdirSync, rmdirSync, readdirSync, lstatSync } from 'fs';
|
|
20
20
|
import { atomicWriteFileSync as atomicWrite } from './lib/atomic-write.mjs';
|
|
21
21
|
import { join } from 'path';
|
|
22
22
|
import { createHash } from 'crypto';
|
|
@@ -259,7 +259,21 @@ export function removeManaged(cwd, slug) {
|
|
|
259
259
|
// Delete the now-empty file rather than writing a 0-byte CLAUDE.md, so
|
|
260
260
|
// unadopt fully restores the pre-adopt state — mirrors the emptied-.claude/
|
|
261
261
|
// cleanup below ("unadopt leaves no trace").
|
|
262
|
-
|
|
262
|
+
//
|
|
263
|
+
// UNLESS the path is a SYMLINK (audit R7 P2-2). writeManaged reaches this file
|
|
264
|
+
// through atomicWriteFileSync, which lstats and writes THROUGH a link on purpose —
|
|
265
|
+
// that is the audit 2026-09-02 P0-5 fix, for CLAUDE.md symlinked into a dotfiles
|
|
266
|
+
// repo (chezmoi/stow/yadm). Unlinking here would delete the LINK and orphan the
|
|
267
|
+
// target, i.e. undo that invariant on the removal side. Empty it through the link
|
|
268
|
+
// instead: a 0-byte file is the lesser evil against silently rearranging the
|
|
269
|
+
// user's dotfiles. Only a regular file we can prove is ours to remove gets removed.
|
|
270
|
+
let isLink = false;
|
|
271
|
+
try {
|
|
272
|
+
isLink = lstatSync(p).isSymbolicLink();
|
|
273
|
+
} catch {
|
|
274
|
+
/* raced away → fall through to the unlink attempt, which will no-op */
|
|
275
|
+
}
|
|
276
|
+
if (raw.trim() === '' && !isLink) {
|
|
263
277
|
try {
|
|
264
278
|
unlinkSync(p);
|
|
265
279
|
} catch {
|
package/cli/common.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// relative-time formatting — every command imports from here so the CLI stays
|
|
9
9
|
// consistent.
|
|
10
10
|
|
|
11
|
-
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
11
|
+
import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from '../format-utils.mjs';
|
|
12
12
|
|
|
13
13
|
// ─── Argument Parsing ────────────────────────────────────────────────────────
|
|
14
14
|
|
|
@@ -107,11 +107,19 @@ export function parseArgs(argv) {
|
|
|
107
107
|
* The transform is idempotent (it strips brackets, it does not re-add them), so a
|
|
108
108
|
* path that already defanged upstream — `context` → buildSessionContextLines — is
|
|
109
109
|
* unaffected.
|
|
110
|
+
*
|
|
111
|
+
* `<skill-loaded>` is neutralized here too (audit 2026-09-05 R6 P1-2). It is deliberately
|
|
112
|
+
* OFF CONTEXT_DELIMITER_RE so the MCP `mem_use` load path can emit a real wrapper — but no
|
|
113
|
+
* CLI command emits one, while `registry search|list` DOES print third-party registry names
|
|
114
|
+
* (a GitHub frontmatter name, or `import --name`, which applies no charset filter). A crafted
|
|
115
|
+
* name therefore forged a complete skill block out of nothing in ordinary CLI output. The MCP
|
|
116
|
+
* twin closes the same hole at its own chokepoint (server.mjs defangResult); doing it on one
|
|
117
|
+
* face only is this repo's first-listed defect class.
|
|
110
118
|
*/
|
|
111
119
|
export function out(text) {
|
|
112
|
-
// String() first:
|
|
113
|
-
//
|
|
114
|
-
outVerbatim(neutralizeContextDelimiters(String(text)));
|
|
120
|
+
// String() first: the neutralizers coerce nullish to '', which would turn a pre-existing
|
|
121
|
+
// `out(undefined)` line from "undefined" into an empty line.
|
|
122
|
+
outVerbatim(neutralizeSkillDelimiters(neutralizeContextDelimiters(String(text))));
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
/**
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -1065,12 +1065,30 @@ const SUBAGENT_INJECT_ID_RE = new RegExp(`^\\s{0,4}#(${OBS_ID_DIGITS})\\s+—`);
|
|
|
1065
1065
|
* Extract observation ids injected into a subagent's PROMPT by pre-agent-inject.js
|
|
1066
1066
|
* (formatSubagentContext). Only the `#NN — ` tag line counts; a body cross-reference is
|
|
1067
1067
|
* ignored. Returns numeric ids.
|
|
1068
|
+
*
|
|
1069
|
+
* TYPE-GATED (audit R7 P1-1). Without `entry.type === 'user'` this scanned assistant text
|
|
1070
|
+
* too, so a subagent that merely QUOTED the block — reviewing this code, summarizing what it
|
|
1071
|
+
* was handed — had those ids counted as injected; and since collectSubagentSurface takes a
|
|
1072
|
+
* per-file `seen ∩ said` intersection, the same quotation credited them as cited, reading
|
|
1073
|
+
* 100% on one self-reference. SURFACE_MATCHERS.task_imperative defends the identical shape by
|
|
1074
|
+
* also gating on the command, for the reason stated in its docblock; this face had nothing.
|
|
1075
|
+
* That mattered beyond metering: `sub.injected` is a citation-decay ENTRY gate (hook.mjs) and
|
|
1076
|
+
* an allow-list for bumpCitationAccess → access_count → the `boost` op → importance.
|
|
1077
|
+
*
|
|
1078
|
+
* The gate value is MEASURED, not assumed (2026-09-05, 11 real subagent transcripts): the
|
|
1079
|
+
* dispatched task prompt is the FIRST entry of each subagent file and carries `type='user'`,
|
|
1080
|
+
* `role='user'`, `isSidechain=true`; the corpus holds assistant ×1036 / attachment ×973 /
|
|
1081
|
+
* user ×668. `tool_result` blocks ride a user entry too, but carry `content` rather than
|
|
1082
|
+
* `text`, so the `x?.text` map below already contributes nothing for them — same reasoning
|
|
1083
|
+
* extractUserTypedIds states for its own scan.
|
|
1084
|
+
*
|
|
1068
1085
|
* @param {string|null|undefined} transcriptPath
|
|
1069
1086
|
* @returns {Set<number>}
|
|
1070
1087
|
*/
|
|
1071
1088
|
export function extractInjectedFromSubagentPrompt(transcriptPath) {
|
|
1072
1089
|
const ids = new Set();
|
|
1073
1090
|
for (const entry of readTranscriptEntries(transcriptPath)) {
|
|
1091
|
+
if (entry.type !== 'user') continue;
|
|
1074
1092
|
const c = entry.message?.content;
|
|
1075
1093
|
const text =
|
|
1076
1094
|
typeof c === 'string'
|
package/mem-cli.mjs
CHANGED
|
@@ -3531,12 +3531,17 @@ async function cmdImport(argv) {
|
|
|
3531
3531
|
}
|
|
3532
3532
|
|
|
3533
3533
|
try {
|
|
3534
|
-
const { importFromGitHub } = await import('./registry-importer.mjs');
|
|
3534
|
+
const { importFromGitHub, formatImportSkips } = await import('./registry-importer.mjs');
|
|
3535
3535
|
out(`[mem] Importing from ${url}...`);
|
|
3536
|
-
|
|
3536
|
+
// `skipped` sink + shared summary (R6 Q1) — same helper the MCP twin renders, so the
|
|
3537
|
+
// bounds cannot end up enforced-but-silent on one of the two faces.
|
|
3538
|
+
const skipped = [];
|
|
3539
|
+
const results = await importFromGitHub(rdb, url, { skipped });
|
|
3540
|
+
const refusal = formatImportSkips(skipped);
|
|
3537
3541
|
|
|
3538
3542
|
if (results.length === 0) {
|
|
3539
3543
|
out('[mem] No skills/agents found in this repository.');
|
|
3544
|
+
if (refusal) out(`[mem] ${refusal}`);
|
|
3540
3545
|
return;
|
|
3541
3546
|
}
|
|
3542
3547
|
|
|
@@ -3544,6 +3549,7 @@ async function cmdImport(argv) {
|
|
|
3544
3549
|
for (const r of results) {
|
|
3545
3550
|
out(` ${r.type === 'skill' ? 'S' : 'A'} ${r.name} (id=${r.id})`);
|
|
3546
3551
|
}
|
|
3552
|
+
if (refusal) out(`[mem] ${refusal}`);
|
|
3547
3553
|
|
|
3548
3554
|
if (flags.enrich) {
|
|
3549
3555
|
out('[mem] Running LLM enrichment...');
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.99.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.99.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
12
12
|
"better-sqlite3": "^12.11.1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.99.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
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/registry-github.mjs
CHANGED
|
@@ -34,25 +34,43 @@ export function parseGitHubUrl(url) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Percent-encode ONE path segment. Git ref names may legally contain `#` (git forbids `?`,
|
|
38
|
+
// not `#`), and so may file names — interpolated raw, that `#` opens a URL FRAGMENT and
|
|
39
|
+
// swallows the rest: `…/git/trees/feat#x?recursive=1` parses as hash `#x?recursive=1` with an
|
|
40
|
+
// EMPTY query, so GitHub answered a NON-recursive tree and every nested skills/*/SKILL.md went
|
|
41
|
+
// silently undiscovered; the raw content URL lost its whole path the same way (audit
|
|
42
|
+
// 2026-09-05 R6 Q2, measured). encodeURIComponent leaves the unreserved set — including the
|
|
43
|
+
// `.`, `-`, `_` and `~` that ordinary owners/repos/branches are made of — untouched.
|
|
44
|
+
const seg = (s) => encodeURIComponent(String(s ?? ''));
|
|
45
|
+
|
|
46
|
+
// A repo-relative file path is MANY segments: encode each one but keep the `/` separators.
|
|
47
|
+
// encodeURIComponent on the whole path would emit `skills%2Ffoo%2FSKILL.md` and 404 every
|
|
48
|
+
// ordinary import — the counter-case pinned in tests/registry-github.test.mjs.
|
|
49
|
+
const segPath = (p) =>
|
|
50
|
+
String(p ?? '')
|
|
51
|
+
.split('/')
|
|
52
|
+
.map(seg)
|
|
53
|
+
.join('/');
|
|
54
|
+
|
|
37
55
|
/**
|
|
38
56
|
* Build GitHub API tree URL (recursive).
|
|
39
57
|
*/
|
|
40
58
|
export function buildTreeUrl(owner, repo, branch) {
|
|
41
|
-
return `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
|
|
59
|
+
return `https://api.github.com/repos/${seg(owner)}/${seg(repo)}/git/trees/${seg(branch)}?recursive=1`;
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
/**
|
|
45
63
|
* Build raw content URL for a file.
|
|
46
64
|
*/
|
|
47
65
|
export function buildContentUrl(owner, repo, branch, path) {
|
|
48
|
-
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
|
|
66
|
+
return `https://raw.githubusercontent.com/${seg(owner)}/${seg(repo)}/${seg(branch)}/${segPath(path)}`;
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
/**
|
|
52
70
|
* Build GitHub API repo metadata URL.
|
|
53
71
|
*/
|
|
54
72
|
export function buildRepoUrl(owner, repo) {
|
|
55
|
-
return `https://api.github.com/repos/${owner}/${repo}`;
|
|
73
|
+
return `https://api.github.com/repos/${seg(owner)}/${seg(repo)}`;
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
/**
|
package/registry-importer.mjs
CHANGED
|
@@ -22,6 +22,71 @@ import { DB_DIR } from './schema.mjs';
|
|
|
22
22
|
// read them under CLAUDE_MEM_DIR relocation (D#29). Equals homedir when the env is unset.
|
|
23
23
|
const MANAGED_DIR = join(DB_DIR, 'managed');
|
|
24
24
|
|
|
25
|
+
// ─── Import bounds (audit 2026-09-05 R6 Q1) ─────────────────────────────────
|
|
26
|
+
// Measured before these existed: a tree offering 500 `skills/*/SKILL.md` entries of 2 MB
|
|
27
|
+
// each imported all 500, issued 502 fetches and wrote 1000.0 MB in 20.1 s — from one
|
|
28
|
+
// `registry import-url`. There was no bound on count, per-file size, or run total, and the
|
|
29
|
+
// input is a third-party repository, so one URL could fill the user's data dir.
|
|
30
|
+
//
|
|
31
|
+
// USER-VISIBLE DEFAULT BEHAVIOR CHANGE: an import that used to be unbounded can now refuse
|
|
32
|
+
// entries. Each bound has an env opt-out and `0` means unlimited — the pre-cap behavior.
|
|
33
|
+
export const IMPORT_DEFAULT_LIMITS = {
|
|
34
|
+
items: 200,
|
|
35
|
+
fileBytes: 2 * 1024 * 1024,
|
|
36
|
+
totalBytes: 50 * 1024 * 1024,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Module-private: both consumers (resolveImportLimits, formatImportSkips) live here, and
|
|
40
|
+
// exporting it would add a name to the knip unused-export baseline for nothing.
|
|
41
|
+
const IMPORT_LIMIT_ENV = {
|
|
42
|
+
items: 'CLAUDE_MEM_IMPORT_MAX_ITEMS',
|
|
43
|
+
fileBytes: 'CLAUDE_MEM_IMPORT_MAX_FILE_BYTES',
|
|
44
|
+
totalBytes: 'CLAUDE_MEM_IMPORT_MAX_TOTAL_BYTES',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const SKIP_REASON_TEXT = {
|
|
48
|
+
'item-cap': 'beyond the per-import item cap',
|
|
49
|
+
'file-too-large': 'over the per-file byte cap',
|
|
50
|
+
'total-budget': 'past the total byte budget for this import',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Effective bounds: caller override (tests) < env < default.
|
|
55
|
+
* @param {object} [override] Partial {items,fileBytes,totalBytes}
|
|
56
|
+
* @param {object} [env] Env source (tests pass their own)
|
|
57
|
+
*/
|
|
58
|
+
function resolveImportLimits(override = {}, env = process.env) {
|
|
59
|
+
const limits = {};
|
|
60
|
+
for (const key of Object.keys(IMPORT_DEFAULT_LIMITS)) {
|
|
61
|
+
const base = override[key] ?? IMPORT_DEFAULT_LIMITS[key];
|
|
62
|
+
const raw = env[IMPORT_LIMIT_ENV[key]];
|
|
63
|
+
if (raw === undefined || String(raw).trim() === '') {
|
|
64
|
+
limits[key] = base;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const n = Number(raw);
|
|
68
|
+
// `0` = unlimited, the documented opt-out. Anything unparseable or negative KEEPS the
|
|
69
|
+
// bound: the failure mode of a typo must be "the limit still applies", never "no limit"
|
|
70
|
+
// — the same fail-closed rule registryConfineEnabled states for its escape hatch.
|
|
71
|
+
limits[key] = Number.isFinite(n) && n >= 0 ? (n === 0 ? Infinity : n) : base;
|
|
72
|
+
}
|
|
73
|
+
return limits;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One-line refusal summary for the two import faces. Shared so the CLI and the MCP tool
|
|
78
|
+
* cannot drift into two spellings of the same refusal (this repo's first-listed defect class).
|
|
79
|
+
* @param {Array<{reason: string}>} skipped
|
|
80
|
+
* @returns {string} '' when nothing was refused.
|
|
81
|
+
*/
|
|
82
|
+
export function formatImportSkips(skipped) {
|
|
83
|
+
if (!skipped || skipped.length === 0) return '';
|
|
84
|
+
const byReason = new Map();
|
|
85
|
+
for (const s of skipped) byReason.set(s.reason, (byReason.get(s.reason) || 0) + 1);
|
|
86
|
+
const parts = [...byReason].map(([reason, n]) => `${n} ${SKIP_REASON_TEXT[reason] || reason} (${reason})`);
|
|
87
|
+
return `Refused ${skipped.length}: ${parts.join('; ')}. Set ${IMPORT_LIMIT_ENV.items}/${IMPORT_LIMIT_ENV.fileBytes}/${IMPORT_LIMIT_ENV.totalBytes} (0 = unlimited) to change these bounds.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
25
90
|
// ─── Tree Discovery ─────────────────────────────────────────────────────────
|
|
26
91
|
|
|
27
92
|
// Patterns: flat (skills/name/SKILL.md), plugin (plugins/x/skills/y/SKILL.md),
|
|
@@ -346,11 +411,30 @@ export async function importFromGitHub(db, url, opts = {}) {
|
|
|
346
411
|
const discovered = discoverFromTree(treeData, pathFilter);
|
|
347
412
|
if (discovered.length === 0) return [];
|
|
348
413
|
|
|
414
|
+
// 4b. Apply the import bounds (R6 Q1). `skipped` is a caller-supplied sink so both faces
|
|
415
|
+
// can render the refusal; callers that pass nothing keep the previous return shape.
|
|
416
|
+
const limits = resolveImportLimits(opts.limits, opts.env);
|
|
417
|
+
const skipped = opts.skipped ?? [];
|
|
418
|
+
let admitted = discovered;
|
|
419
|
+
if (discovered.length > limits.items) {
|
|
420
|
+
admitted = discovered.slice(0, limits.items);
|
|
421
|
+
for (const over of discovered.slice(limits.items)) {
|
|
422
|
+
skipped.push({ name: over.name, type: over.type, reason: 'item-cap' });
|
|
423
|
+
}
|
|
424
|
+
debugLog(
|
|
425
|
+
'WARN',
|
|
426
|
+
'importer',
|
|
427
|
+
`Item cap ${limits.items} reached; refused ${discovered.length - limits.items} entries`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
349
431
|
const repoUrl = `https://github.com/${owner}/${repo}`;
|
|
350
432
|
const results = [];
|
|
433
|
+
let totalBytes = 0;
|
|
351
434
|
|
|
352
435
|
// 5. Process each discovered item
|
|
353
|
-
for (
|
|
436
|
+
for (let i = 0; i < admitted.length; i++) {
|
|
437
|
+
const item = admitted[i];
|
|
354
438
|
try {
|
|
355
439
|
// 5a. Fetch content via raw GitHub URL
|
|
356
440
|
const contentUrl = buildContentUrl(owner, repo, branch, item.filePath);
|
|
@@ -361,14 +445,45 @@ export async function importFromGitHub(db, url, opts = {}) {
|
|
|
361
445
|
}
|
|
362
446
|
const content = await contentResp.text();
|
|
363
447
|
|
|
448
|
+
// 5a-bis. Byte bounds, checked on the fetched body before anything is parsed or
|
|
449
|
+
// written. The per-file cap refuses ONE entry and keeps going; the run total is a
|
|
450
|
+
// budget, so exhausting it stops the walk and books every remaining entry as refused
|
|
451
|
+
// (a partial import must still account for what it did not take).
|
|
452
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
453
|
+
if (bytes > limits.fileBytes) {
|
|
454
|
+
skipped.push({ name: item.name, type: item.type, reason: 'file-too-large', bytes });
|
|
455
|
+
debugLog('WARN', 'importer', `Refused ${item.filePath}: ${bytes} B over cap ${limits.fileBytes}`);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (totalBytes + bytes > limits.totalBytes) {
|
|
459
|
+
for (const rest of admitted.slice(i)) {
|
|
460
|
+
skipped.push({ name: rest.name, type: rest.type, reason: 'total-budget' });
|
|
461
|
+
}
|
|
462
|
+
debugLog('WARN', 'importer', `Total byte budget ${limits.totalBytes} exhausted at ${item.filePath}`);
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
totalBytes += bytes;
|
|
466
|
+
|
|
364
467
|
// 5b. Parse frontmatter
|
|
365
468
|
const { frontmatter, body } = parseFrontmatter(content);
|
|
366
469
|
|
|
367
470
|
// Root skill naming: use frontmatter name if present, else repo name for root, else discovered name
|
|
368
471
|
const rawName = frontmatter.name || (item.name === 'root' ? repo : item.name);
|
|
369
472
|
const name = rawName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
370
|
-
// Path traversal guard: reject names that would escape managed directory
|
|
371
473
|
const typeDir = item.type === 'agent' ? 'agents' : 'skills';
|
|
474
|
+
// Segment guard, BEFORE the confinement check — which cannot catch these (audit
|
|
475
|
+
// 2026-09-05 R6 P3-2). `.` and `..` survive the charset filter (dot is allowed) and
|
|
476
|
+
// then PASS confinement, because join() resolves them away first: `<managed>/skills/..`
|
|
477
|
+
// IS `<managed>`, admitted on isPathConfined's `resolved === base` arm. Not a traversal
|
|
478
|
+
// — the write stays inside managedDir — but it lands outside the one-directory-per-
|
|
479
|
+
// resource layout (`<managed>/SKILL.md`, `<managed>/skills/SKILL.md`), where the flat
|
|
480
|
+
// scanner picks the latter up as a loose resource named `SKILL`, and two repos both
|
|
481
|
+
// declaring `name: .` overwrite each other. An empty name collapses the same way.
|
|
482
|
+
if (!name || name === '.' || name === '..') {
|
|
483
|
+
debugLog('WARN', 'importer', `Rejected non-segment name: ${rawName}`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
// Path traversal guard: reject names that would escape managed directory
|
|
372
487
|
if (!isPathConfined(join(managedDir, typeDir, name), managedDir)) {
|
|
373
488
|
debugLog('WARN', 'importer', `Rejected path-traversal name: ${rawName}`);
|
|
374
489
|
continue;
|
package/server.mjs
CHANGED
|
@@ -293,14 +293,28 @@ function applyArgAliases(args, pairs) {
|
|
|
293
293
|
return next;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
|
|
296
|
+
/**
|
|
297
|
+
* @param {object} result Tool result.
|
|
298
|
+
* @param {object} [opts]
|
|
299
|
+
* @param {boolean} [opts.skillBlocks=true] Also neutralize `<skill-loaded>`. Default ON:
|
|
300
|
+
* registry rows carry third-party text (a GitHub frontmatter name, or `import --name`,
|
|
301
|
+
* which applies no charset filter), and every registry render used to interpolate it raw —
|
|
302
|
+
* so a crafted name FORGED a whole skill block out of nothing in ordinary search/list
|
|
303
|
+
* output (audit 2026-09-05 R6 P1-2; F7 on a third face). Enumerating mem_registry found
|
|
304
|
+
* the same shape on seven branches plus the shared formatRegistryListLine, which is why
|
|
305
|
+
* this is a chokepoint default rather than seven call-site patches. `mem_use` — the one
|
|
306
|
+
* handler that must emit a REAL wrapper — turns it off explicitly and defangs its own
|
|
307
|
+
* untrusted pieces per call site instead (R6 P1-1).
|
|
308
|
+
*/
|
|
309
|
+
function defangResult(result, { skillBlocks = true } = {}) {
|
|
297
310
|
if (!result || !Array.isArray(result.content)) return result;
|
|
311
|
+
const scrub = skillBlocks
|
|
312
|
+
? (t) => neutralizeSkillDelimiters(neutralizeContextDelimiters(t))
|
|
313
|
+
: neutralizeContextDelimiters;
|
|
298
314
|
return {
|
|
299
315
|
...result,
|
|
300
316
|
content: result.content.map((c) =>
|
|
301
|
-
c && c.type === 'text' && typeof c.text === 'string'
|
|
302
|
-
? { ...c, text: neutralizeContextDelimiters(c.text) }
|
|
303
|
-
: c,
|
|
317
|
+
c && c.type === 'text' && typeof c.text === 'string' ? { ...c, text: scrub(c.text) } : c,
|
|
304
318
|
),
|
|
305
319
|
};
|
|
306
320
|
}
|
|
@@ -311,14 +325,18 @@ function defangResult(result) {
|
|
|
311
325
|
* @param {boolean} [opts.verbatim=false] Skip the defang pass. Only for payloads that
|
|
312
326
|
* must round-trip byte-exact — `mem_export` feeds `restore`, so neutralizing it would
|
|
313
327
|
* silently corrupt backups of any memory that legitimately discusses these tags.
|
|
328
|
+
* @param {boolean} [opts.emitsSkillBlock=false] This handler legitimately emits a real
|
|
329
|
+
* `<skill-loaded>` wrapper, so the chokepoint must not strip it. `mem_use` is the only
|
|
330
|
+
* one, and it neutralizes its own untrusted body/name/path per call site (R6 P1-1).
|
|
331
|
+
* Applies to the SUCCESS path only — an error message never emits a wrapper.
|
|
314
332
|
*/
|
|
315
|
-
function safeHandler(fn, { verbatim = false } = {}) {
|
|
333
|
+
function safeHandler(fn, { verbatim = false, emitsSkillBlock = false } = {}) {
|
|
316
334
|
return async (args, extra) => {
|
|
317
335
|
try {
|
|
318
336
|
lastMcpRequestTime = Date.now();
|
|
319
337
|
idleCleanupRan = false;
|
|
320
338
|
const result = await fn(args, extra);
|
|
321
|
-
return verbatim ? result : defangResult(result);
|
|
339
|
+
return verbatim ? result : defangResult(result, { skillBlocks: !emitsSkillBlock });
|
|
322
340
|
} catch (err) {
|
|
323
341
|
return defangResult({ content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
|
|
324
342
|
}
|
|
@@ -1683,11 +1701,16 @@ server.registerTool(
|
|
|
1683
1701
|
if (!args.url) {
|
|
1684
1702
|
return { content: [{ type: 'text', text: 'import_url requires a url parameter' }], isError: true };
|
|
1685
1703
|
}
|
|
1686
|
-
const { importFromGitHub } = await import('./registry-importer.mjs');
|
|
1704
|
+
const { importFromGitHub, formatImportSkips } = await import('./registry-importer.mjs');
|
|
1687
1705
|
try {
|
|
1688
|
-
|
|
1706
|
+
// `skipped` sink + shared summary (R6 Q1): the import bounds must REFUSE visibly, and
|
|
1707
|
+
// the CLI twin renders the identical string from the identical helper.
|
|
1708
|
+
const skipped = [];
|
|
1709
|
+
const results = await importFromGitHub(rdb, args.url, { skipped });
|
|
1710
|
+
const refusal = formatImportSkips(skipped);
|
|
1689
1711
|
if (results.length === 0) {
|
|
1690
|
-
|
|
1712
|
+
const head = `No skills/agents found in: ${args.url}`;
|
|
1713
|
+
return { content: [{ type: 'text', text: refusal ? `${head}\n${refusal}` : head }] };
|
|
1691
1714
|
}
|
|
1692
1715
|
|
|
1693
1716
|
let enrichMsg = '';
|
|
@@ -1707,7 +1730,7 @@ server.registerTool(
|
|
|
1707
1730
|
content: [
|
|
1708
1731
|
{
|
|
1709
1732
|
type: 'text',
|
|
1710
|
-
text: `Imported ${results.length} resource(s) from ${args.url}:\n${lines.join('\n')}${enrichMsg}`,
|
|
1733
|
+
text: `Imported ${results.length} resource(s) from ${args.url}:\n${lines.join('\n')}${enrichMsg}${refusal ? `\n${refusal}` : ''}`,
|
|
1711
1734
|
},
|
|
1712
1735
|
],
|
|
1713
1736
|
};
|
|
@@ -1787,133 +1810,166 @@ server.registerTool(
|
|
|
1787
1810
|
description: descriptionOf('mem_use'),
|
|
1788
1811
|
inputSchema: memUseSchema,
|
|
1789
1812
|
},
|
|
1790
|
-
safeHandler(
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1813
|
+
safeHandler(
|
|
1814
|
+
async (args) => {
|
|
1815
|
+
const rdb = getRegistryDb();
|
|
1816
|
+
if (!rdb) {
|
|
1817
|
+
return { content: [{ type: 'text', text: 'Registry DB not available.' }], isError: true };
|
|
1818
|
+
}
|
|
1795
1819
|
|
|
1796
|
-
|
|
1797
|
-
|
|
1820
|
+
const name = args.name.trim();
|
|
1821
|
+
const type = args.type || 'skill';
|
|
1798
1822
|
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1823
|
+
// 1. Exact match by name or invocation_name — the ONLY path that loads content.
|
|
1824
|
+
const row = rdb
|
|
1825
|
+
.prepare(
|
|
1826
|
+
`
|
|
1803
1827
|
SELECT id, name, type, local_path, invocation_name, capability_summary
|
|
1804
1828
|
FROM resources
|
|
1805
1829
|
WHERE status = 'active' AND type = ?
|
|
1806
1830
|
AND (name = ? OR invocation_name = ?)
|
|
1807
1831
|
LIMIT 1
|
|
1808
1832
|
`,
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1833
|
+
)
|
|
1834
|
+
.get(type, name, name);
|
|
1835
|
+
|
|
1836
|
+
// 2. Name miss → SUGGEST, never substitute. The FTS5 search still runs (it is what
|
|
1837
|
+
// produces the candidate list), but its result is only ever rendered as names: loading
|
|
1838
|
+
// the top hit under the caller's requested name shipped a different skill's body inside
|
|
1839
|
+
// <skill-loaded> plus "Follow the instructions above to execute this <type>." — with
|
|
1840
|
+
// nothing marking the swap, so an agent that asked for A executed B (audit F1,
|
|
1841
|
+
// 2026-08-14: with only `deploy-rollback-runbook` registered, `deploy-notes` /
|
|
1842
|
+
// `rollback-checklist` / `runbook-index` each returned its full body). Loading stays an
|
|
1843
|
+
// exact-name decision the caller makes.
|
|
1844
|
+
if (!row) {
|
|
1845
|
+
let candidates = [];
|
|
1846
|
+
try {
|
|
1847
|
+
candidates = searchResources(rdb, name, { type, limit: 5 })
|
|
1848
|
+
.map((r) => r.name)
|
|
1849
|
+
.filter(Boolean);
|
|
1850
|
+
} catch {
|
|
1851
|
+
/* a suggestion is best-effort; the miss message below still stands */
|
|
1852
|
+
}
|
|
1853
|
+
// Every echo of the caller's own name below is bounded + delimiter-inert (audit F7):
|
|
1854
|
+
// raw interpolation let a crafted `name` forge a <skill-loaded> block and the execute
|
|
1855
|
+
// imperative inside this message, and the handler-wide defangResult cannot catch it —
|
|
1856
|
+
// <skill-loaded> is off CONTEXT_DELIMITER_RE precisely so the real load path can emit
|
|
1857
|
+
// it. `truncate` also folds newlines, so a multi-line name cannot fake block structure.
|
|
1858
|
+
// Registered names are defanged too (a crafted one can be imported), but NOT truncated:
|
|
1859
|
+
// the suggestion tells the caller to load one by its exact name, so it must stay exact.
|
|
1860
|
+
const echoed = neutralizeSkillDelimiters(truncate(name, ECHO_NAME_MAX));
|
|
1861
|
+
const echoedCandidates = candidates.map((n) => neutralizeSkillDelimiters(n));
|
|
1862
|
+
const head = `No ${type} found for "${echoed}".`;
|
|
1863
|
+
const browse = `mem_registry(action="search", query="${echoed}")`;
|
|
1864
|
+
if (candidates.length === 0) {
|
|
1865
|
+
return { content: [{ type: 'text', text: `${head} Try ${browse} to browse.` }] };
|
|
1866
|
+
}
|
|
1867
|
+
const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
|
|
1868
|
+
return {
|
|
1869
|
+
content: [
|
|
1870
|
+
{
|
|
1871
|
+
type: 'text',
|
|
1872
|
+
text:
|
|
1873
|
+
`${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
|
|
1874
|
+
`Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.`,
|
|
1875
|
+
},
|
|
1876
|
+
],
|
|
1877
|
+
};
|
|
1842
1878
|
}
|
|
1843
|
-
const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
|
|
1844
|
-
return {
|
|
1845
|
-
content: [
|
|
1846
|
-
{
|
|
1847
|
-
type: 'text',
|
|
1848
|
-
text:
|
|
1849
|
-
`${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
|
|
1850
|
-
`Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.`,
|
|
1851
|
-
},
|
|
1852
|
-
],
|
|
1853
|
-
};
|
|
1854
|
-
}
|
|
1855
1879
|
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
skillPath
|
|
1862
|
-
|
|
1880
|
+
// 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
|
|
1881
|
+
let skillPath = row.local_path || '';
|
|
1882
|
+
if (skillPath && !skillPath.endsWith('.md')) {
|
|
1883
|
+
for (const candidate of [
|
|
1884
|
+
join(skillPath, 'SKILL.md'),
|
|
1885
|
+
join(skillPath, `skills/${row.name}/SKILL.md`),
|
|
1886
|
+
]) {
|
|
1887
|
+
if (existsSync(candidate)) {
|
|
1888
|
+
skillPath = candidate;
|
|
1889
|
+
break;
|
|
1890
|
+
}
|
|
1863
1891
|
}
|
|
1864
1892
|
}
|
|
1865
|
-
}
|
|
1866
1893
|
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1894
|
+
// 4. Path confinement check — prevent reading arbitrary files via crafted local_path.
|
|
1895
|
+
// Base is the env-aware data dir (D#29): managed/ relocates with CLAUDE_MEM_DIR and
|
|
1896
|
+
// equals homedir when unset, so this does not weaken the non-relocated confinement.
|
|
1897
|
+
const managedBase = DB_DIR;
|
|
1898
|
+
if (skillPath && !isPathConfined(skillPath, managedBase)) {
|
|
1899
|
+
return {
|
|
1900
|
+
content: [
|
|
1901
|
+
{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` },
|
|
1902
|
+
],
|
|
1903
|
+
isError: true,
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1877
1906
|
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1907
|
+
// 5. Read content
|
|
1908
|
+
let content;
|
|
1909
|
+
try {
|
|
1910
|
+
content = readFileSync(skillPath, 'utf8');
|
|
1911
|
+
} catch {
|
|
1912
|
+
const msg = skillPath.endsWith('.md')
|
|
1913
|
+
? `Found ${type} "${row.name}" but cannot read file: ${skillPath}`
|
|
1914
|
+
: `Found ${type} "${row.name}" but no .md file in: ${skillPath}`;
|
|
1915
|
+
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
1916
|
+
}
|
|
1888
1917
|
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1918
|
+
// 5. Record invocation
|
|
1919
|
+
try {
|
|
1920
|
+
rdb
|
|
1921
|
+
.prepare(
|
|
1922
|
+
`
|
|
1894
1923
|
INSERT INTO invocations (resource_id, session_id, trigger, adopted, outcome)
|
|
1895
1924
|
VALUES (?, ?, 'user_explicit', 1, 'success')
|
|
1896
1925
|
`,
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1926
|
+
)
|
|
1927
|
+
.run(row.id, process.env.CLAUDE_SESSION_ID || 'unknown');
|
|
1928
|
+
} catch {
|
|
1929
|
+
/* non-critical */
|
|
1930
|
+
}
|
|
1902
1931
|
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1932
|
+
const _home = homedir();
|
|
1933
|
+
const portablePath =
|
|
1934
|
+
skillPath && skillPath.startsWith(_home) ? '~' + skillPath.slice(_home.length) : skillPath || '';
|
|
1935
|
+
|
|
1936
|
+
// Defang the untrusted pieces before wrapping (audit 2026-09-05 R6 P1-1). All three come
|
|
1937
|
+
// from a third-party repo by way of the registry — `registry import-url` stores a body
|
|
1938
|
+
// verbatim, and `registry import --name` stores a name with no charset filter at all — so
|
|
1939
|
+
// this emitter is the containment boundary, not the import.
|
|
1940
|
+
//
|
|
1941
|
+
// The handler-wide defangResult only runs neutralizeContextDelimiters, and <skill-loaded>
|
|
1942
|
+
// is deliberately OFF that list (format-utils.mjs) so this very line can emit a real
|
|
1943
|
+
// wrapper. So the body needs the per-call-site neutralizer: a literal `</skill-loaded>`
|
|
1944
|
+
// in it closed the wrapper and forged a second block attributed to another skill, with
|
|
1945
|
+
// the "Follow the instructions above" sentence below landing after it as an endorsement.
|
|
1946
|
+
// Name and path are stripped rather than neutralized because they land in ATTRIBUTE
|
|
1947
|
+
// position, where a bare `"` breaks out of the tag regardless of any tag-shaped pattern.
|
|
1948
|
+
//
|
|
1949
|
+
// Same treatment the sibling face already applies (scripts/pre-skill-bridge.js, audit
|
|
1950
|
+
// 2026-08-14 M-4 + D#122 ③). The wrapper itself stays live — that is the counter-case
|
|
1951
|
+
// pinned by tests/audit-findings-20260814.test.mjs:605 and this file's last case.
|
|
1952
|
+
// `row.type` is not defanged: the resources CHECK constraint admits only 'skill'|'agent'.
|
|
1953
|
+
const attrSafe = (s) => String(s ?? '').replace(/["'<>]/g, '');
|
|
1954
|
+
const safeName = attrSafe(row.name);
|
|
1955
|
+
const safePath = attrSafe(portablePath);
|
|
1956
|
+
const safeBody = neutralizeSkillDelimiters(content);
|
|
1957
|
+
const pathAttr = safePath ? ` path="${safePath}"` : '';
|
|
1958
|
+
const reloadHint = safePath ? ` Reload: Read("${safePath}")` : '';
|
|
1959
|
+
return {
|
|
1960
|
+
content: [
|
|
1961
|
+
{
|
|
1962
|
+
type: 'text',
|
|
1963
|
+
text: `<skill-loaded name="${safeName}" type="${row.type}"${pathAttr}>\n${safeBody}\n</skill-loaded>\n\nFollow the instructions above to execute this ${row.type}.${reloadHint}`,
|
|
1964
|
+
},
|
|
1965
|
+
],
|
|
1966
|
+
};
|
|
1967
|
+
// The one handler that emits a real <skill-loaded> wrapper, so the chokepoint's
|
|
1968
|
+
// skill-block pass is turned OFF here — see defangResult. Everything untrusted inside
|
|
1969
|
+
// the wrapper is neutralized above, per call site.
|
|
1970
|
+
},
|
|
1971
|
+
{ emitsSkillBlock: true },
|
|
1972
|
+
),
|
|
1917
1973
|
);
|
|
1918
1974
|
|
|
1919
1975
|
// ─── Tool: mem_update ────────────────────────────────────────────────────────
|