Skip to content
3 changes: 3 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

- **仓库地志的身份与位置分家:`portable_key` + 每机位置表,双系统 / 双机共享同一个 memory.db 时地志第一次可以跨端复用(#581)**:mneme 把「库共享」做到了(`memoryDir` + WAL + busy_timeout),chorography 是唯一一处因为记了本机绝对路径而破坏这个前提的功能——Windows 端登记的行,macOS 端拼出来的路径不存在;再跑一次 T0 又多出第二行,从此不带 `repo` 参数的调用命中 repo-ambiguous;T1 的逐字符严格匹配更意味着**每端都必须先各自跑一次 T0**。修法按 issue 的最小方案落地,三个概念各就各位:**身份** = `choro_repos.portable_key`(新列,幂等迁移),取 `remote.origin.url` 归一化——协议头 / 尾斜杠 / 尾 `.git` / 大小写剥平,scp 形态(`git@host:path`)的冒号换 `/`,**userinfo 整体剥掉**(token 不进键:泄进库里是一回事,令牌轮换后同一仓库算出不同键是另一回事);无 remote 退化到**根提交哈希**(`git:<hash>`,跨副本恒定且与「碰巧同名的别的仓库」天然区分——issue 提的「退化为仓库名」不能用,目录名与 package name 重合率太高,第二次登记会吃掉别人的行,实现期在测试夹具上当场撞到)。**位置** = 新表 `choro_repo_paths(repo_key, host_id, root_path, last_seen_at)`,`host_id = os.hostname()`,T0/T1 登记时 upsert。**读侧**(`queries.js` 的 `localRootPath`)按当前 host 取本机路径、取不到回退身份行的 root_path——单机库两个值恒等、行为逐字节不变;本机没登记过时落进既有的 page-missing / unreadable 失败口径,不会拿别的机器的路径读出错误内容。**T1 的登记校验**从「root_path 逐字符相同」改成「本机路径命中**或**身份已登记」——同一仓库在另一台机器登记过,本机直接认领那一行富化,消灭「每端先各跑一次 T0」;认领同时落本机位置行。写路径(`/choro/run`、`/choro/t1` 后台任务、`/choro` 命令的过期判定)同样改吃本机根。**两个刻意的取舍**:① 同键登记收敛到同一行,但 root_path 的 UPDATE 前先查目标值是否被别的行占着(被占保留原值、位置走位置表)——升格期已跨端登记出的重复行**不合并**(页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属),强行改会撞 `idx_choro_repos_root` 直接抛;② 升格存量行的键留 NULL,由它自己的机器重跑 T0/T1 时补上,不猜。已知边界:kb 导入的 choro_page source 是导入当时的绝对路径快照,跨端复用时会失配,另账处理。回归测试 +10(URL 归一化形态表含凭据剥离 / 无 remote 退化链与恒定性 / 旧库补列补表且重开幂等 / 位置表同键同机覆写 / 同键跨端登记收敛同一行且 created_at 不动 / 升格期 root 被占不强改 / 读侧三 host 各解析各的 / **T1 跨端认领端到端**:克隆检出直接富化身份行并登记本机位置),另把四个「同库登记两个相同夹具」的既有用例显式区分开(无 remote 键是根提交哈希,同秒提交的相同内容会同键收敛——这本身就是退化链设计的防误并性质)。变异自证 3 例(删 `upsertChoroRepoPath`/`choroRepoPathFor` 的 return 出口 → store 用例 3 红;读侧 `localRootPath` 去掉位置行解析 → queries 用例红;T1 去掉 byKey 认领 → 端到端用例红),恢复后全绿。**评审补丁(PR #679,六条 Major 全部成立)**:① **路径回退要核验身份**——两台主机的同一条绝对路径可能各自检出不同仓库,`upsertChoroRepo` / T1 的登记校验、读侧 `localRootPath` 都改成「身份优先,路径回退只接受无键存量行或键一致的行」;核验用 `portableKeyFor`(一次 git 读取,按「键+路径」记忆化),不符时读侧返回空串并按 `page-missing` / 不可读上报,绝不 `join("", rel)` 去读进程 CWD;`claimDrift` 的 `hasT1` 同样加了空根守卫。② **身份唯一**:`portable_key` 加部分唯一索引(`WHERE portable_key IS NOT NULL`),插入撞唯一键时回读同一身份行改走更新——并发登记不再产生重复行;索引建失败(存量重复键)退成非唯一索引,不让插件起不来。③ **URL 解析分清端口与 scp 分隔符**:带协议头时 authority 的冒号是端口(默认端口 ssh 22 / https 443 / git 9418 剥掉,其余保留),只有 scp 形态的冒号才是路径分隔符;④ **userinfo 只在 authority 段内剥**,仓库路径里的 `@`(`repo@stable`)不再把主机与路径一起切掉。⑤ `repoByCwd` 的最长前缀改为比较候选自身路径长度(原实现拿身份行里那条外地路径比长度,内层本机路径永远赢不了)。新增回归 5 条(端口 vs 路径段 / 路径里的 @ / 同路径另一身份拒绝且原行不变 / 回退目录身份不符报 page-missing / 前缀比较用候选长度),变异自证 6 例逐条变红。

## [0.14.0-beta.3] - 2026-11-12
- **品牌视觉换新:README 头图与插件列表图标**。三处 README(根、`dsh-mneme/`、`mcp/`)顶部改用 `images/mneme-banner-hero.webp`(1672×941,282 KiB)——原图 2.1 MiB 的 PNG 会让 `images/` 目录体积翻倍。根目录用相对路径,两个子目录用 raw 绝对地址:npm 详情页不解析相对图片路径,`../images/...` 在 npmjs.com 上必然裂图。
- **插件列表图标从手绘 SVG 换成位图**:`package.json` 的 `icon` 改为 `assets/icon.png`(256×256 RGBA、圆角透明、120 KiB,仍在宿主 `iconOf()` 的 256 KiB 上限内);`assets/icon.svg` 删除。列表图标不再要求与客户端内联 mark(`MNEME_MARK_D`)同形,`test/client.test.js` 的图标用例改验 PNG 魔数、IHDR 边长、位深与 RGBA 通道——宿主对坏文件是静默回落成通用插画,不报错。

Expand Down
6 changes: 5 additions & 1 deletion dsh-mneme/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -3458,7 +3458,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
});
return;
}
repoRoot = repo.root_path;
// 本机可用的根(#581):身份行上的 root_path 可能是别的机器最近一次
// 登记的位置,直接拿去跑 T0 会 no-such-dir。
repoRoot = choro.localRootForRepo(repo);
}

try {
Expand Down Expand Up @@ -3534,6 +3536,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
}
const started = choro.startT1Job({
repo,
// 本机根(#581):身份行的 root_path 可能是别的机器登记的位置。
rootPath: typeof choro.localRootForRepo === "function" ? choro.localRootForRepo(repo) : null,
module: body && typeof body.module === "string" && body.module.trim() !== "" ? body.module.trim() : null,
retryFailed: body && typeof body === "object" ? body.retryFailed === true : false
});
Expand Down
9 changes: 6 additions & 3 deletions dsh-mneme/lib/choro-command.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ function dateOnly(iso) {
* 几十毫秒起步(P29)。这里是用户主动敲的,付得起一次 rev-parse。
* 三种结局分开报:一致 / 不一致 / 读不到 git——不能把「读不到」说成「没过期」。
*/
function staleness(repo) {
function staleness(repo, localRoot) {
const baseline = String(repo.git_baseline ?? "").trim();
const head = gitHead(repo.root_path);
// 本机根(#581):repo 由 repoByCwd 解析(身份行),root_path 可能是别的机器
// 最近一次登记的位置;调用方经 queries.localRootForRepo 解析本机路径。旧装配
// 没有这个函数时退回行的 root_path(单机语义不变)。
const head = gitHead(localRoot ?? repo.root_path);
if (!head) return { state: "no-git", baseline, head: null };
if (baseline && baseline === head) return { state: "fresh", baseline, head };
return { state: "stale", baseline, head };
Expand Down Expand Up @@ -81,7 +84,7 @@ export function createChoroHandler({ choro, config, language = () => "zh" } = {}
const listed = block ? block.split("\n").filter((line) => line.startsWith("- ")).length : 0;
const rest = Math.max(0, outline.modules.length - listed);
if (rest > 0) lines.push(S.truncated(rest));
const head = staleness(repo);
const head = staleness(repo, typeof choro.localRootForRepo === "function" ? choro.localRootForRepo(repo) : null);
if (head.state === "fresh") lines.push(S.fresh(shortSha(head.head)));
else if (head.state === "stale") lines.push(S.stale(shortSha(head.baseline), shortSha(head.head), cli));
else lines.push(S.noGit);
Expand Down
125 changes: 112 additions & 13 deletions dsh-mneme/lib/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -644,10 +644,24 @@ CREATE TABLE IF NOT EXISTS choro_repos (
plan_fingerprint TEXT, -- 上次成功 run 的 plan.yaml 内容 sha256
git_baseline TEXT, -- 上次成功 run 的 HEAD
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
updated_at TEXT NOT NULL,
portable_key TEXT -- 可移植身份(#581):remote URL 归一化,无 remote 退化根提交哈希(git:<sha>)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_root ON choro_repos(root_path);

-- 每机一份的位置表(#581):身份与位置分家——memory.db 可以跨端共享,工作
-- 副本的位置不能。host_id 取 os.hostname()(src/wiki/portable.js);同一身份
-- 同一台机器只有一行,T0/T1 登记时 upsert。读侧按当前 host 取本机路径,取
-- 不到回退 choro_repos.root_path(行上的 root_path 从此是「最近一次登记的
-- 位置」,单机库两个值恒等,行为不变)。
CREATE TABLE IF NOT EXISTS choro_repo_paths (
repo_key TEXT NOT NULL,
host_id TEXT NOT NULL,
root_path TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
PRIMARY KEY (repo_key, host_id)
);

-- 模块划分与依赖边。fingerprint = 模块文件集 + 边集的内容 sha256:幂等语义靠
-- replaceChoroGraph 的整仓替换,不靠逐行 diff,所以本表没有「上次见过的划分」
-- 之外的账要记。
Expand Down Expand Up @@ -1372,6 +1386,20 @@ export function createStore(path) {
// 向量本体列),存量库经 PRAGMA 检查补列。
addColumn("choro_vectors", "vector", "ALTER TABLE choro_vectors ADD COLUMN vector TEXT");
addColumn("choro_vectors", "source_generated_at", "ALTER TABLE choro_vectors ADD COLUMN source_generated_at TEXT");
// chorography 跨端身份(#581):存量行的 portable_key 留 NULL——键只在它
// 自己的机器上重跑 T0/T1 时补上,不猜(remote URL 拿不到就是拿不到)。
// choro_repo_paths 建表走 SCHEMA 的 CREATE TABLE IF NOT EXISTS,老库重开自动补。
addColumn("choro_repos", "portable_key", "ALTER TABLE choro_repos ADD COLUMN portable_key TEXT");
// 身份唯一(PR #679 评审):同一 portable_key 只许一行,否则并发登记会各自插入、
// 之后任意取其中一行。部分索引(键非空才约束)保证升格前的 NULL 行不受影响。
// 建索引可能因存量重复键失败(本功能尚未发布,概率趋零,但不能因此让插件起不来)
// ——失败时退成非唯一索引,upsert 的「插入撞唯一键→回读再更新」分支只在索引建成
// 时生效,退化的库上并发仍可能重复,这是启动可用性与约束强度之间的取舍。
try {
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_portable ON choro_repos(portable_key) WHERE portable_key IS NOT NULL;");
} catch {
db.exec("CREATE INDEX IF NOT EXISTS idx_choro_repos_portable ON choro_repos(portable_key);");
}

// v0.8.0 A1(issue #17)scope 隔离存储层:四列全部可空、不带 DEFAULT——存量行
// 零重写,读侧把 NULL 视为未标注(= 全局可见)。是否落值由 scopeEnabled 门控
Expand Down Expand Up @@ -5428,19 +5456,88 @@ export function createStore(path) {
return db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath) ?? undefined;
}

// 登记或刷新仓库行。created_at 只在首次插入时落(ON CONFLICT 不碰它);
// plan_fingerprint / git_baseline 永远反映上次成功 run——失败 run 不写这两格
// (红线 5「失败不推进 state」,库侧与 state.json 同一口径)。
function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt) {
// 位置行 upsert(#581):同一身份同一台机器只一行,root_path 与 last_seen_at
// 反映最近一次登记。repo_key 为空(无 remote 且连根提交都读不到,非 git 目录)就不落——位置表
// 只为可移植身份服务,没有身份的行留在 choro_repos.root_path 的旧语义里。
function upsertChoroRepoPath(repoKey, hostId, rootPath, nowIso) {
if (!repoKey || !hostId) return;
db.prepare(
`INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(root_path) DO UPDATE SET
name = excluded.name,
plan_fingerprint = excluded.plan_fingerprint,
git_baseline = excluded.git_baseline,
updated_at = excluded.updated_at`
).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt);
`INSERT INTO choro_repo_paths (repo_key, host_id, root_path, last_seen_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(repo_key, host_id) DO UPDATE SET
root_path = excluded.root_path,
last_seen_at = excluded.last_seen_at`
).run(repoKey, hostId, rootPath, nowIso);
}

function choroRepoPathFor(repoKey, hostId) {
if (!repoKey || !hostId) return null;
return db.prepare("SELECT * FROM choro_repo_paths WHERE repo_key = ? AND host_id = ?").get(repoKey, hostId) ?? null;
}

// 登记或刷新仓库行。created_at 只在首次插入时落;plan_fingerprint / git_baseline
// 永远反映上次成功 run——失败 run 不写这两格(红线 5「失败不推进 state」,库侧
// 与 state.json 同一口径)。
//
// 身份(#581):同一 portable_key 的行**优先**收敛(键的唯一性靠它保证——若
// 让 root_path 命中优先,升格期的跨端重复行会在各自机器上把同一个键 adoption
// 到两行上,键就废了);本机路径命中只在没有同键行时兜底(无键仓库的旧口径)。
// 收敛到的行上 root_path 更新为「最近一次登记的位置」,读侧经 choroRepoPathFor
// 回退它——单机库两个值恒等、行为不变。**升级前已跨端登记出的重复行不合并**:
// 页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属;所以 UPDATE
// root_path 前先查目标值是否被别的行占着(被占就保留本行原值,位置走位置表)
// ——否则升格期登记会撞 idx_choro_repos_root 直接抛。portable_key 缺席(无
// remote 且读不到根提交,非 git 目录)时退回旧的 root_path 冲突口径,存量语义不变。
function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt, portableKey = null) {
let existing = portableKey
? (db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey) ?? undefined)
: undefined;
if (!existing) {
// 路径回退必须校验身份(PR #679 评审):两台主机的同一条绝对路径可能各自
// 检出不同仓库。若该路径已属另一身份的行,拿它复用会把对方的名字、基线、
// 键一起改掉,随后 T0 还会用本仓库的图替换对方的模块与 claims。无键的存量
// 行(升格前登记)仍允许按路径认领——那时候键还不存在,路径就是唯一线索。
const byRoot = choroRepoByRoot(rootPath);
if (byRoot && portableKey && byRoot.portable_key != null && byRoot.portable_key !== portableKey) {
throw new Error(
`choro repo path conflict: ${rootPath} is already registered as ${byRoot.portable_key}; ` +
`refusing to re-register it as ${portableKey}`
);
}
existing = byRoot;
}
if (existing) {
const rootTaken = db.prepare("SELECT id FROM choro_repos WHERE root_path = ? AND id != ?")
.get(rootPath, existing.id);
const nextRoot = rootTaken ? existing.root_path : rootPath;
db.prepare(
`UPDATE choro_repos SET root_path = ?, name = ?, plan_fingerprint = ?, git_baseline = ?, updated_at = ?
WHERE id = ?`
).run(nextRoot, name, planFingerprint, gitBaseline, updatedAt, existing.id);
if (portableKey && existing.portable_key !== portableKey) {
db.prepare("UPDATE choro_repos SET portable_key = ? WHERE id = ?").run(portableKey, existing.id);
}
return choroRepoByRoot(nextRoot);
}
try {
db.prepare(
`INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at, portable_key)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt, portableKey);
} catch (err) {
// 并发登记(PR #679 评审):两个进程可能都先查到「无记录」再各自插入。
// portable_key 的唯一索引会让后到者失败,此时重新按身份取行并走更新,
// 而不是把第二个写入变成用户可见的失败。
const message = String(err?.message ?? err);
if (!portableKey || !/UNIQUE constraint failed: choro_repos\.portable_key/i.test(message)) throw err;
const raced = db.prepare("SELECT * FROM choro_repos WHERE portable_key = ?").get(portableKey);
if (!raced) throw err;
db.prepare(
`UPDATE choro_repos SET root_path = ?, name = ?, plan_fingerprint = ?, git_baseline = ?, updated_at = ?
WHERE id = ?`
).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, raced.id);
return choroRepoByRoot(rootPath);
}
return choroRepoByRoot(rootPath);
}

Expand Down Expand Up @@ -6087,6 +6184,8 @@ export function createStore(path) {
// chorography(仓库地志):M0 只写不读,消费面 M1 接(见上面注释块)。
choroRepoByRoot,
upsertChoroRepo,
upsertChoroRepoPath,
choroRepoPathFor,
replaceChoroGraph,
choroPageBySlug,
recordChoroPageFailure,
Expand Down
Loading
Loading