@yandy0725/pi-memory 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,11 @@ Aligned with Claude Code's auto memory mechanism: **one memory = one file**, a `
6
6
 
7
7
  > ## ⚠️ Breaking changes
8
8
  >
9
+ > **In 2.5.0:**
10
+ >
11
+ > - **Windows directory names changed shape.** `local/<project>` keys now split on `\` as well as `/`, so a Windows project directory is one readable component (`C_3a__Users__you__proj`) instead of a nested tree (`C_3a/Users/you/proj`). Memories stored under the old nested layout are orphaned: run `/memory` inside the project to read the new `Dir:` path, then create that directory if it does not exist and move the old project directory's contents into it (in PowerShell: `New-Item -ItemType Directory -Force "<new dir>"` then `Move-Item "<old project dir>\*" "<new dir>"`); the empty intermediate directories left behind can be ignored. `git/<host>__<owner>__<repo>` names are unchanged on POSIX; on Windows they are unchanged too unless the key's first `.`-delimited label is a reserved device name, or the key contains a backslash, or it ends in a dot or a space — those few names are sanitised now (see [Windows](#windows)). POSIX output is byte-identical to before.
12
+ > - **Entry files whose stem is a Windows reserved device name now get a `_` prefix** (`name: "CON"` → `_CON.md`) on every platform, so the name is safe in a shared `git/` directory. Only newly created files are affected — existing entries keep their file names.
13
+ >
9
14
  > **In 2.4.0:**
10
15
  >
11
16
  > - **The package-level `enabled` switch is gone.** `memory.json` has no top-level `enabled` any more: a leftover key is ignored, and `{"enabled": false}` no longer disables anything — it used to skip model validation too, so a disabled config often had no model configured. Disable the extension the way you disable any pi package — by not loading it — see [Disabling the extension](#disabling-the-extension). `dream` is now a required task in every session.
@@ -161,7 +166,7 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
161
166
 
162
167
  | Key | Default | Description |
163
168
  |-----|---------|-------------|
164
- | `memoryDir` | `~/.pi/memory` | Root directory for all memory data |
169
+ | `memoryDir` | `~/.pi/memory` | Root directory for all memory data. `~`, `~/` and (on Windows) `~\` are expanded; relative values are resolved against the working directory |
165
170
  | `memIndexMaxLines` | `200` | Write capacity: max non-empty lines in `MEMORY.md` (the `# Memory Index` header and hand-written headings count too, so this is not exactly the memory count) |
166
171
  | `memIndexMaxBytes` | `25600` | Write capacity: max bytes of `MEMORY.md` |
167
172
  | `memIndexInjectMaxLines` | `50` | Injection window: max lines of the index put into the `memory_index` section. The window keeps the **newest** lines and drops the **oldest** ones — the index is pure chronological order, so a smaller window never hides the memory you just wrote. **`0` (either key) injects no index at all** — the `memory_index` section stays empty |
@@ -272,7 +277,7 @@ It runs with the five main-agent actions (never `rename` / `rebuild_index`), no
272
277
  | Level | Scope | Behaviour |
273
278
  |---|---|---|
274
279
  | In-process logical lock (per memory dir) | one primitive call; or a whole dream round | Waits up to `lock.timeoutMs`, then throws a readable error naming the directory. `extract` uses the non-waiting form and skips the turn |
275
- | Cross-process `.lock` | milliseconds, around the physical write | Acquired with `link` (atomic), **never reclaimed automatically**: no TTL, no heartbeat, no takeover |
280
+ | Cross-process `.lock` | milliseconds per retried call around the physical write (a retry storm can hold it for a few seconds) | Acquired with `open(…, "wx")` (`O_CREAT|O_EXCL`) — the **create** is atomic on NTFS, ReFS, exFAT/FAT32 and in a network share's namespace, so the lock excludes processes on every machine using that share; in a sync-client folder it only excludes same-machine processes (see [Windows](#windows)). The holder record is written immediately after the file is created. **Never reclaimed automatically**: no TTL, no heartbeat, no takeover |
276
281
 
277
282
  A crash inside a write can therefore leave a `.lock` behind, and nothing will ever delete it for you — that is the deliberate price of a hard mutual-exclusion guarantee. The error names the pid, op and start time; `/memory unlock` is the one sanctioned way to clear it.
278
283
 
@@ -376,15 +381,26 @@ Directory names are derived as follows:
376
381
  - git repos whose remote is http(s), ssh (including scp-style `[user@]host:owner/repo`, where the user is optional) or `git://`, plus the `git+ssh://` / `git+https://` aliases → `git/<host>__<owner>__<repo>`; port, credentials, trailing `/` and `.git` are stripped and the host is lowercased
377
382
  - remote URLs are read from the raw git config (`remote.<name>.url`; `origin` first, then alphabetical, first usable URL wins), so `url.*.insteadOf` rewrites do not change the mapping
378
383
  - scheme forms apply WHATWG URL normalization (IDN hosts become punycode, percent-encoding and `.`/`..` folding apply, credentials/queries/fragments are dropped), while scp forms keep the path as written — equivalent remotes written differently can map to different directories
379
- - everything else — non-git directories, git repos without a remote, `file://` or local-path remotes → `local/<absolute-path>` (git repos use the repository root; a Windows-style drive-letter remote such as `C:/repos/foo.git` is treated as scp-style on POSIX, matching git)
384
+ - everything else — non-git directories, git repos without a remote, `file://`, UNC (`\\server\share\repo.git`), relative and local-path remotes → `local/<absolute-path>` (git repos use the repository root). A Windows drive-letter remote (`Z:\repos\foo.git`) counts as a **local path on Windows**, matching how git treats it there; the same string on POSIX is scp syntax (the drive letter is a host) and still maps to a `git/` name, also matching git
380
385
  - `/` becomes `__`; characters that are not portable in file names (`<>:"|?*`, control characters) become `_XX` hex escapes
381
386
  - names longer than 120 UTF-8 bytes are truncated to 100 bytes on a code-point boundary plus a `__<hash8>` suffix
382
- - names target POSIX filesystems: a backslash is an ordinary character, and no Windows device-name or trailing-dot handling is applied
387
+ - names are platform-aware: on Windows a backslash is a separator, and names avoid reserved device names and trailing dots/spaces; on POSIX a backslash stays an ordinary character and these Windows-only rules do not apply. The one rule applied everywhere is the `_` prefix for entry file stems that look like a device name (see [Windows](#windows))
383
388
 
384
389
  The mapping is not injective: underscores are kept as-is, so `/home/a__b` and `/home/a/b` both map to `home__a__b` (and share one memory directory). Changing or renaming a remote, adding a remote that sorts before the one currently in use, or moving a local directory changes the memory directory, orphaning the old one.
385
390
 
386
391
  **Older legacy layout:** versions before 1.x stored memory under `~/.pi/memory/<12-char-sha256>/`; those directories are no longer read or written. To migrate a project manually, compute the old hash with `printf '%s' "$(git rev-parse --show-toplevel)" | sha256sum | cut -c1-12` (use `$PWD` outside a git repo), then `mv` that directory to the new location (run `/memory` inside the project to see the new path) and split its topic files by hand (see [1.x data](#1x-data)).
387
392
 
393
+ ## Windows
394
+
395
+ pi-memory runs natively on Windows — no WSL required.
396
+
397
+ - **Directory names.** The name derived from a project key is always a single, legal Windows component: `\` counts as a separator (so `C:\Users\you\proj` → `C_3a__Users__you__proj`), a trailing dot or space is hex-escaped (`proj.` → `proj_2e`), and a name whose first `.`-delimited label is a reserved device name gets a `_` prefix (`nul` → `_nul`). Directory names are identical across platforms for an ordinary remote's `git/` key (`github.com__owner__repo`), so a shared `memoryDir` keeps working when the same repository is opened from Linux and Windows. Keys whose first label collides with a reserved device name (`aux.example.com/...`), that contain a backslash, or that end in a dot or a space can still differ between platforms.
398
+ - **Repository root.** The root comes from `git rev-parse --show-toplevel`, so a repository subdirectory maps to the same memory directory as the repository root. Note the root is taken from git's output as-is: it must be a native Windows path, which Git for Windows prints (`C:/...`). A cygwin/MSYS build of `git` prints POSIX-style paths (`/cygdrive/c/...`), which would place the memory directory under a wrong prefix — put Git for Windows' `git.exe` first on `PATH` if you have several git builds installed.
399
+ - **`memoryDir`.** `~`, `~/` and (on Windows) `~\` are expanded; relative values are resolved to absolute paths. Windows paths and UNC shares both work.
400
+ - **Locking.** The cross-process lock is created with `open(…, "wx")` (`CREATE_NEW`). On a network share that primitive is atomic in the share's single namespace, so the lock excludes processes on every machine using that share — a `memoryDir` on a non-NTFS volume is supported. In a **sync-client folder** (OneDrive, Dropbox, …) there is no single namespace: each machine keeps its own copy, so the lock only excludes processes on the same machine — do not share one `memoryDir` between machines that way; use a network share when you need cross-machine exclusion. A transient `EPERM`/`EACCES`/`EBUSY` from an antivirus scanner, an editor or a file indexer is retried with a short backoff; a persistent failure still reports the original error.
401
+ - **Line endings.** Every memory file pi-memory reads tolerates CRLF and lone CR (they are normalised to LF before parsing), and every write emits LF. A `MEMORY.md` or entry file re-saved by Notepad or another Windows editor therefore neither disappears from the index nor inflates the unrecognised-line count.
402
+ - **Known limitation.** A file whose name is a reserved device name (for example `con.md`, created by hand or by an older version on another platform) is skipped on Windows instead of being read, because opening that name reaches the console device rather than the file. Rename it (using a `\\?\` path) or re-create the memory under a new name. While such a file exists, adding a memory whose name derives to it (`CON` now derives `_CON.md`) creates a second file and a second same-named index line; the next `rebuild_index` — which dream runs regularly — drops the stale line.
403
+
388
404
  ## Notifications
389
405
 
390
406
  | When | Notification |
package/README.zh.md CHANGED
@@ -6,6 +6,11 @@ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏
6
6
 
7
7
  > ## ⚠️ 破坏性变更
8
8
  >
9
+ > **2.5.0:**
10
+ >
11
+ > - **Windows 上的目录名形态变了。** `local/<项目>` 的 key 现在按 `\` 与 `/` 一起分段,因此 Windows 项目目录是一个可读的单分量名(`C_3a__Users__you__proj`),而不是嵌套的一棵树(`C_3a/Users/you/proj`)。旧嵌套布局下的记忆会变成孤儿目录:在项目里跑一次 `/memory` 读出新的 `Dir:` 路径,再建好那个目录并把旧项目目录里的内容搬过去(PowerShell 里先 `New-Item -ItemType Directory -Force "<新目录>"`,再 `Move-Item "<旧项目目录>\*" "<新目录>"`);留下的空中间目录可以不管。`git/<host>__<owner>__<repo>` 在 POSIX 上形态不变;Windows 上同样不变,除非 key 的首段是保留设备名、或 key 含反斜杠、或以点或空格结尾 —— 这类少数名字现在会被收尾(见 [Windows](#windows))。POSIX 输出与之前逐字节一致。
12
+ > - **stem 是 Windows 保留设备名的 entry 文件现在会加 `_` 前缀**(`name: "CON"` → `_CON.md`),**所有平台**都这样,这样名字在共享的 `git/` 目录里两边都安全。只影响**新建**文件 —— 既有 entry 保留原名。
13
+ >
9
14
  > **2.4.0:**
10
15
  >
11
16
  > - **包级开关 `enabled` 已删除。** `memory.json` 不再有顶层 `enabled`:残留的键会被忽略,`{"enabled": false}` 不再能禁用任何东西 —— 它以前还会跳过模型校验,所以被禁用的配置往往也没配模型。要禁用整个扩展,请像禁用其他 pi package 一样「不加载它」,见[禁用本扩展](#禁用本扩展)。`dream` 现在是每个会话的必需任务。
@@ -161,7 +166,7 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
161
166
 
162
167
  | 键 | 默认值 | 说明 |
163
168
  |-----|---------|------|
164
- | `memoryDir` | `~/.pi/memory` | 所有记忆数据的根目录 |
169
+ | `memoryDir` | `~/.pi/memory` | 所有记忆数据的根目录。`~`、`~/` 与(Windows 上)`~\` 会展开;相对路径按工作目录解析为绝对路径 |
165
170
  | `memIndexMaxLines` | `200` | 写入口径:`MEMORY.md` 的最大非空行数(`# Memory Index` 头行与手写标题同样占额度,所以并不等于记忆条数) |
166
171
  | `memIndexMaxBytes` | `25600` | 写入口径:`MEMORY.md` 的最大字节数 |
167
172
  | `memIndexInjectMaxLines` | `50` | 注入口径:放进 `memory_index` section 的最大行数。窗口保留**最新**的行、丢弃**最旧**的行 —— 索引是纯时间序,窗口再小也不会藏住你刚写完的那条。**任一键写 `0` = 完全不注入索引**(section 保持空值) |
@@ -272,7 +277,7 @@ extract 拿到的是结构化渲染,而不是有损的两条消息摘要:
272
277
  | 层级 | 作用域 | 行为 |
273
278
  |---|---|---|
274
279
  | 进程内逻辑锁(按记忆目录分键) | 单次原语;或 dream 的整轮 | 最多等 `lock.timeoutMs`,超时抛一条写明目录的可读错误。`extract` 用不等待的形态,直接跳过本回合 |
275
- | 跨进程 `.lock` | 毫秒级,只包住物理写入 | 用 `link` 原子获取,**永不自动回收**:没有 TTL、没有心跳、没有接管 |
280
+ | 跨进程 `.lock` | 每次重试调用毫秒级,只包住物理写入(重试风暴下可持有数秒) | 用 `open(…, "wx")`(`O_CREAT|O_EXCL`)建立 —— **创建**在 NTFS、ReFS、exFAT/FAT32 与网络共享的命名空间里是原子的,因此能排除使用同一共享的所有机器上的进程;在同步客户端目录里只能排除同机进程(见 [Windows](#windows))。持有者记录在文件建立后立即写入。**永不自动回收**:无 TTL、无心跳、无接管 |
276
281
 
277
282
  因此写入中途崩溃可能留下一个 `.lock`,而且**没有任何进程会替你删掉它** —— 这是「互斥是硬保证」的刻意代价。错误文案会写明 pid、op、开始时间与路径;`/memory unlock` 是唯一被认可的清除方式。
278
283
 
@@ -376,15 +381,26 @@ Lock: free
376
381
  - remote 是 http(s)、ssh(含 scp 写法 `[user@]host:owner/repo`,user 可省略)或 `git://`,以及 `git+ssh://` / `git+https://` 别名的 git 仓库 → `git/<host>__<owner>__<repo>`;端口、凭据、结尾的 `/` 与 `.git` 会被剥掉,host 转小写
377
382
  - remote URL 从原始 git config 读取(`remote.<name>.url`;先 `origin`,再按字母序,第一个可用的胜出),所以 `url.*.insteadOf` 重写不会影响映射
378
383
  - scheme 形式走 WHATWG URL 规范化(IDN host 转 punycode,百分号编码与 `.`/`..` 折叠生效,凭据/query/fragment 被丢弃),scp 形式保留原样路径 —— 等价但写法不同的 remote 可能映射到不同目录
379
- - 其余情况 —— 非 git 目录、没有 remote 的 git 仓库、`file://` 或本地路径 remote → `local/<absolute-path>`(git 仓库用仓库根;Windows 盘符形式的 remote 如 `C:/repos/foo.git` 在 POSIX 上按 scp 写法处理,与 git 一致)
384
+ - 其余情况 —— 非 git 目录、没有 remote 的 git 仓库、`file://`、UNC(`\\server\share\repo.git`)、相对路径与本地路径 remote → `local/<absolute-path>`(git 仓库用仓库根)。Windows 盘符形式的 remote(`Z:\repos\foo.git`)**在 Windows 上算本地路径**,与 git 在那里的一致;同一串在 POSIX 上是 scp 写法(盘符字母是 host),仍映射到 `git/` 目录名,也与 git 一致
380
385
  - `/` 变成 `__`;文件名里不可移植的字符(`<>:"|?*`、控制字符)变成 `_XX` 十六进制转义
381
386
  - 超过 120 UTF-8 字节的名字在码点边界截断到 100 字节,再加 `__<hash8>` 后缀
382
- - 名字面向 POSIX 文件系统:反斜杠是普通字符,不做 Windows 设备名与结尾句点处理
387
+ - 名字是平台感知的:Windows 上反斜杠是分隔符,并避开保留设备名与结尾点/空格;POSIX 上反斜杠仍是普通字符,上述 Windows 规则不适用。唯一在所有平台都生效的规则是「stem 形如设备名的 entry 文件名加 `_` 前缀」(见 [Windows](#windows))
383
388
 
384
389
  映射不是单射:下划线原样保留,所以 `/home/a__b` 与 `/home/a/b` 都映射到 `home__a__b`(共享同一个记忆目录)。改动或重命名 remote、新增一个排序更靠前的 remote、移动本地目录,都会改变记忆目录,旧目录会被孤立。
385
390
 
386
391
  **更老的布局:** 1.x 之前的版本把记忆存在 `~/.pi/memory/<12-char-sha256>/`;这些目录不再被读写。要手工迁移,用 `printf '%s' "$(git rev-parse --show-toplevel)" | sha256sum | cut -c1-12` 算出旧 hash(不在 git 仓库里就用 `$PWD`),把那个目录 `mv` 到新位置(在项目里跑 `/memory` 可以看到新路径),其 topic 文件需要按 [1.x 数据](#1x-数据)手工拆分。
387
392
 
393
+ ## Windows
394
+
395
+ pi-memory 在 Windows 上原生可用,不需要 WSL。
396
+
397
+ - **目录名。** 由项目 key 派生的名字一定是单个、合法的 Windows 分量:`\` 被当作分隔符(`C:\Users\you\proj` → `C_3a__Users__you__proj`),结尾的点或空格被十六进制转义(`proj.` → `proj_2e`),而「第一个 `.` 之前的部分」是保留设备名的名字会加 `_` 前缀(`nul` → `_nul`)。普通 remote 的 `git/` 目录名在所有平台上一致(`github.com__owner__repo`),因此共享 `memoryDir` 时同一仓库从 Linux 与 Windows 打开都会落到同一个目录。首段与保留设备名撞名(如 `aux.example.com/...`)、含反斜杠、或以点或空格结尾的 key,其目录名在两端仍可能不同。
398
+ - **仓库根。** 根路径取自 `git rev-parse --show-toplevel`:从仓库子目录启动与从仓库根启动映射到同一个记忆目录。注意该路径是按 git 的输出原样使用的,必须是原生 Windows 形态 —— Git for Windows 会打印 `C:/...`;若 PATH 上的 `git` 是 cygwin/MSYS 构建,它会打印 `/cygdrive/c/...` 这类 POSIX 形态路径,记忆目录会落到错前缀下。装了多个 git 时,把 Git for Windows 的 `git.exe` 放到 PATH 前面。
399
+ - **`memoryDir`。** `~`、`~/` 与(Windows 上)`~\` 都会展开;相对路径会被解析成绝对路径。Windows 路径与 UNC 共享都可用。
400
+ - **锁。** 跨进程锁用 `open(…, "wx")`(`CREATE_NEW`)建立。在网络共享上这个原语在共享的单一命名空间里是原子的,因此锁能排除使用同一共享的**所有机器**上的进程 —— `memoryDir` 放在非 NTFS 卷上也能用。在**同步客户端目录**(OneDrive、Dropbox 等)里没有单一命名空间:每台机器各留一份副本,锁只能排除同一台机器上的进程 —— 不要用这种方式在多台机器间共享 `memoryDir`;需要跨机互斥时用网络共享。杀软、编辑器或索引器造成的瞬时 `EPERM`/`EACCES`/`EBUSY` 会做短退避重试;持续失败仍按原始错误报出。
401
+ - **换行符。** 读取记忆文件时一律容忍 CRLF 与孤立 CR(解析前归一为 LF),写入一律输出 LF。被记事本等 Windows 编辑器重新保存过的 `MEMORY.md` 或 entry 文件既不会从索引里消失,也不会推高「无法识别的行」计数。
402
+ - **已知限制。** 名字是保留设备名的文件(例如 `con.md`,由手工创建或旧版本在别的平台上创建)在 Windows 上会被**跳过**而不是被读取 —— 按该名字打开会到达控制台设备而不是文件。请改名(需用 `\\?\` 路径)或换名字重新写入这条记忆。在它存在期间,若再添加一条会派生到该名字的记忆(`CON` 现在派生 `_CON.md`),会得到第二个文件与第二条同名索引行;下一次 `rebuild_index`(dream 会定期调用)会清掉那条陈旧索引行。
403
+
388
404
  ## 通知
389
405
 
390
406
  | 时机 | 通知 |
package/index.ts CHANGED
@@ -7,6 +7,7 @@ import { runDream } from "./src/dream";
7
7
  import { indexCapacity, parseEntryIndex } from "./src/entry-index";
8
8
  import { runExtract } from "./src/extract";
9
9
  import { readLockStatus, type LockInfo } from "./src/fs-lock";
10
+ import { withFsRetry } from "./src/fs-retry";
10
11
  import { readRecordedMemoryIndex } from "./src/index-source";
11
12
  import { applyIndexSection, buildIndexSection, buildInjection, indexInjectionCapacity, injectSurfacedContent, runSideQuery, scanEntries } from "./src/inject";
12
13
  import {
@@ -127,7 +128,10 @@ async function unlockMemory(memoryDir: string, ui: ExtensionUIContext): Promise<
127
128
  const ok = await ui.confirm("Memory lock", question);
128
129
  if (!ok) return;
129
130
  try {
130
- await unlink(lockPath);
131
+ // 恢复路径也要重试:用户正是因为一把可能被瞬时错误困住的锁才走到这里,而删除本身
132
+ // 同样会被杀软/索引器/同步客户端打断 —— 这里再报一次 EPERM 就是把用户困在原地。
133
+ // 幂等(删一个已不存在的文件走下面的 ENOENT 分支),重试无副作用。
134
+ await withFsRetry(() => unlink(lockPath));
131
135
  ui.notify("Memory lock removed.", "info");
132
136
  } catch (e) {
133
137
  // confirm 到 unlink 之间锁自己消失了:那正是想要的结果
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.4.0",
6
+ "version": "2.5.1",
7
7
  "description": "File-system driven persistent memory layer for pi coding agent",
8
8
  "license": "MIT",
9
9
  "repository": {
package/src/config.ts CHANGED
@@ -162,9 +162,12 @@ export function requiredModel(cfg: MemoryConfig, task: ModelTask): string {
162
162
  return value;
163
163
  }
164
164
 
165
- function expandTilde(p: string): string {
165
+ function expandTilde(p: string, platform: NodeJS.Platform = process.platform): string {
166
166
  if (p === "~") return homedir();
167
167
  if (p.startsWith("~/")) return join(homedir(), p.slice(2));
168
+ // `~\` 只在 Windows 上展开:POSIX 上 `~\foo` 是以 `~` 开头的合法文件名,
169
+ // 改写它会破坏用户真实的路径(spec Ruling 4)。
170
+ if (platform === "win32" && p.startsWith("~\\")) return join(homedir(), p.slice(2));
168
171
  return p;
169
172
  }
170
173
 
@@ -200,6 +203,8 @@ export interface LoadConfigContext {
200
203
  isProjectTrusted(): boolean;
201
204
  _globalDir?: string;
202
205
  _configDirName?: string;
206
+ /** 测试注入缝:命名/展开规则跟随的平台。默认 `process.platform`。 */
207
+ _platform?: NodeJS.Platform;
203
208
  }
204
209
 
205
210
  export async function loadConfig(ctx: LoadConfigContext): Promise<MemoryConfig> {
@@ -215,6 +220,6 @@ export async function loadConfig(ctx: LoadConfigContext): Promise<MemoryConfig>
215
220
  cfg = deepMerge(cfg, readJsonSafe(projectFile));
216
221
  }
217
222
 
218
- cfg.memoryDir = expandTilde(cfg.memoryDir);
223
+ cfg.memoryDir = expandTilde(cfg.memoryDir, ctx._platform ?? process.platform);
219
224
  return cfg;
220
225
  }
package/src/dream.ts CHANGED
@@ -5,6 +5,7 @@ import { runHeadlessAgent } from "./agent-runner";
5
5
  import type { SessionPersistenceConfig, ThinkLevel } from "./config";
6
6
  import { BACKUP_DIR, type MemoryStore } from "./memory-store";
7
7
  import { createSnapshot } from "./snapshot";
8
+ import { isReservedWindowsName } from "./windows-names";
8
9
 
9
10
  /** Build the dream consolidation task. dream 只有 `memory` 工具的 7 个 action,没有任何文件工具。 */
10
11
  export function buildDreamTask(maxLines: number): string {
@@ -72,14 +73,20 @@ export interface RunDreamOpts {
72
73
  * - `sessions/`(目录,`sessionPersistence.enabled` 时存在)跳过 —— 同理,而且它可能很大。
73
74
  * `createSnapshot` 的 `cp` 遇到目录会抛 `ERR_FS_EISDIR`(非 ENOENT → fail-closed 上抛 → dream 直接失败)。
74
75
  * - `.lock` / `.dream-meta.json` 跳过(都是 dotfile):锁记录与元数据不属于记忆内容。
76
+ * - win32:保留设备名文件(`con.md`…)跳过 —— 按名 `cp` 会命中设备(见下)。
75
77
  * - 其余全部 `*.md`(entry 文件 + `MEMORY.md`)都会被快照。
76
78
  */
77
- async function snapshotFiles(memoryDir: string): Promise<string[]> {
79
+ async function snapshotFiles(memoryDir: string, platform: NodeJS.Platform = process.platform): Promise<string[]> {
78
80
  const entries = await readdir(memoryDir, { withFileTypes: true }).catch(() => []);
79
- return entries
80
- .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
81
- .map((entry) => entry.name)
82
- .sort();
81
+ return (
82
+ entries
83
+ .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
84
+ // win32:保留设备名文件按名打开会命中设备(CON 等),`createSnapshot` 的 cp 会失败;
85
+ // dream 是 fail-closed 的,一次 cp 失败就打断整轮(与 `#entryFiles` 同一契约,spec §4.3)。
86
+ .filter((entry) => !(platform === "win32" && isReservedWindowsName(entry.name)))
87
+ .map((entry) => entry.name)
88
+ .sort()
89
+ );
83
90
  }
84
91
 
85
92
  /**
@@ -101,9 +108,10 @@ export async function runDream(opts: RunDreamOpts): Promise<string> {
101
108
  }
102
109
 
103
110
  // 进入时对整目录拍一次快照;这一轮里各原语的逐文件快照被 skipSnapshot 跳过(spec §6)。
104
- const files = await snapshotFiles(opts.memoryDir);
111
+ const files = await snapshotFiles(opts.memoryDir, opts.store.cfg.platform);
105
112
  await createSnapshot(join(opts.memoryDir, BACKUP_DIR), "dream", files, opts.memoryDir, {
106
113
  keep: opts.store.cfg.lock.snapshotKeep,
114
+ platform: opts.store.cfg.platform,
107
115
  });
108
116
 
109
117
  return runHeadlessAgent({
package/src/filename.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isReservedWindowsName } from "./windows-names";
2
3
 
3
4
  /** 文件系统不安全字符与控制字符。 */
4
5
  // biome-ignore lint/suspicious/noControlCharactersInRegex: 控制字符是故意列入的(Global Constraints 要求剥离 U+0000–U+001F 与 U+007F)
@@ -31,7 +32,12 @@ function truncateBytes(input: string, maxBytes: number): string {
31
32
  export function entryFileName(name: string): string {
32
33
  const cleaned = name.replace(UNSAFE, "_").replace(EDGE, "");
33
34
  const stem = cleaned.length === 0 ? "" : truncateBytes(cleaned.replace(WHITESPACE_RUN, "-"), STEM_MAX_BYTES);
34
- return `${stem.length === 0 ? `entry-${shortHash(name)}` : stem}${ENTRY_EXT}`;
35
+ // 保留设备名(`con`、`nul`、`com1`…)在 Windows 上会命中设备而不是文件:`memory add name="CON"`
36
+ // 的内容会写进控制台,而索引里已经多了一行 —— 静默丢记忆。**全平台**加前缀:git 类记忆目录
37
+ // 跨机共享,名字必须在两边都安全(spec Ruling 5)。既有文件不受影响:addEntry 按 frontmatter
38
+ // 的 name 复用已有文件,这里只影响**新建**文件的派生名。
39
+ const finalStem = stem.length === 0 ? `entry-${shortHash(name)}` : isReservedWindowsName(stem) ? `_${stem}` : stem;
40
+ return `${finalStem}${ENTRY_EXT}`;
35
41
  }
36
42
 
37
43
  /** 在已占用的文件名集合中为 base 找一个空闲名字(追加 -2、-3……)。 */
package/src/fs-lock.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { link, open, readFile, rm, unlink } from "node:fs/promises";
1
+ import { open, readFile, rm } from "node:fs/promises";
2
2
  import { hostname } from "node:os";
3
+ import { withFsRetry } from "./fs-retry";
3
4
 
4
5
  /**
5
6
  * **跨进程**锁。只保护「毫秒级的物理写入」这一件事。
@@ -9,8 +10,9 @@ import { hostname } from "node:os";
9
10
  * 也就不需要 TTL、续约心跳与存活探测。
10
11
  *
11
12
  * **永不自动回收**:另一个进程崩溃留下的锁没有任何人能释放它,但**也不会被自动删掉**。
12
- * 原因是「移走别人的锁」无法用 POSIX 原语做到可证明安全:`link` 这个合法获取原语的条件正是
13
- * 「锁路径不存在」,所以任何「先移走旧锁、再建立自己的」的接管都会产生一个空窗,其它等待者
13
+ * 原因是「移走别人的锁」无法用 POSIX 原语做到可证明安全(Windows 同样没有 compare-and-delete
14
+ * 原语):锁路径不存在(`open(wx)`)正是
15
+ * 合法获取条件,所以任何「先移走旧锁、再建立自己的」的接管都会产生一个空窗,其它等待者
14
16
  * 可以合法地抢占它;一旦移走的其实是某个**活持有者**刚建立的记录,互斥就无法再恢复(把记录
15
17
  * 挪回去又会顶掉抢占者,而路径只能容纳一条记录)。实测:把 `rm` 换成原子 `rename` 仍然会双持有,
16
18
  * 加上「比字节 + 放回」则会把空窗拉长,同进程内可稳定复现双持有。
@@ -45,7 +47,9 @@ export class MemoryLockedError extends Error {
45
47
  super(
46
48
  abandoned
47
49
  ? `Memory lock at ${lockPath} is abandoned by ${described} — delete the file to clear it`
48
- : `Memory is locked by ${described}`,
50
+ : holder
51
+ ? `Memory is locked by ${described}`
52
+ : `Memory lock at ${lockPath} is still being written (0 bytes) — if no other process is writing, delete the file or run /memory unlock`,
49
53
  );
50
54
  this.name = "MemoryLockedError";
51
55
  }
@@ -81,13 +85,18 @@ function isLockInfo(value: unknown): value is LockInfo {
81
85
  type LockRead =
82
86
  /** 锁路径不存在 —— 这是「空闲」,不是「有问题」。 */
83
87
  | { kind: "absent" }
84
- /** 存在但无法解释(非 JSON、空文件、形状不对、读不到):只有人工能清除它。 */
88
+ /** 存在且 0 字节:持有者刚建立文件、记录还没写完(或恰好崩溃在这一瞬间)。 */
89
+ | { kind: "empty" }
90
+ /** 存在但无法解释(非 JSON、空文件之外的坏内容、形状不对、读不到):只有人工能清除它。 */
85
91
  | { kind: "unreadable" }
86
92
  | { kind: "held"; holder: LockInfo };
87
93
 
88
94
  /**
89
- * 读锁的三种状态。**必须区分「不存在」与「存在但读不懂」** —— 把前者当成后者会让
95
+ * 读锁的状态。**必须区分「不存在」与「存在但读不懂」** —— 把前者当成后者会让
90
96
  * 「持有者刚释放、锁刚被删」被误报成「遗弃的锁」,从而在正常竞争下抛出误导性的错误。
97
+ *
98
+ * 自 `open(wx)` 原语起还多一态 `empty`:锁文件建立与记录写入之间有一个极短窗口(spec §4.4),
99
+ * 它必须与「读不懂」分开 —— 否则等待者会把「别人正在建锁」误报成「遗弃的锁」并立刻失败。
91
100
  */
92
101
  async function readLockState(lockPath: string): Promise<LockRead> {
93
102
  let raw: string;
@@ -98,6 +107,9 @@ async function readLockState(lockPath: string): Promise<LockRead> {
98
107
  // 权限之类的错误:无法判断内容,按「读不懂」处理(不接管、报可操作错误)
99
108
  return { kind: "unreadable" };
100
109
  }
110
+ // 0 字节是**正常的中间态**:`open(wx)` 建立文件与写入记录之间有一个极短窗口(spec §4.4)。
111
+ // 它必须与「读不懂」分开 —— 否则等待者会把「别人正在建锁」误报成「遗弃的锁」并立刻失败。
112
+ if (raw.length === 0) return { kind: "empty" };
101
113
  try {
102
114
  const parsed: unknown = JSON.parse(raw);
103
115
  return isLockInfo(parsed) ? { kind: "held", holder: parsed } : { kind: "unreadable" };
@@ -109,14 +121,17 @@ async function readLockState(lockPath: string): Promise<LockRead> {
109
121
  /**
110
122
  * 锁的诊断视图(spec §14 的 `/memory`)。
111
123
  *
112
- * **复用私有的 `readLockState`**,不另写一份读逻辑:三态必须与获取路径同源,
124
+ * **复用私有的 `readLockState`**,不另写一份读逻辑:状态必须与获取路径同源,
113
125
  * 否则会出现「`/memory` 说 free,下一次写入却报 locked」这种无法诊断的矛盾。
126
+ * 对外只暴露三态:`empty`(建立中的 0 字节记录)归入 `unreadable` —— 对人类观察者而言
127
+ * 它只可能是崩溃残留,处置方式与「读不懂」相同(`/memory unlock`)。
114
128
  * 它**不判断存活、也不删任何东西**(永不自动回收);持有者是否已死由调用方自己看 pid。
115
129
  */
116
130
  export async function readLockStatus(
117
131
  lockPath: string,
118
132
  ): Promise<{ kind: "absent" } | { kind: "unreadable" } | { kind: "held"; holder: LockInfo }> {
119
- return readLockState(lockPath);
133
+ const state = await readLockState(lockPath);
134
+ return state.kind === "empty" ? { kind: "unreadable" } : state;
120
135
  }
121
136
 
122
137
  type AcquireOutcome =
@@ -124,49 +139,68 @@ type AcquireOutcome =
124
139
  | { acquired: false; holder: LockInfo | null; abandoned: boolean };
125
140
 
126
141
  /**
127
- * 尝试一次获取。路径为空时不当作失败 —— 那是「刚好被释放」,直接再试一次 link(CAS 会自然地
142
+ * 尝试一次获取。路径为空时不当作失败 —— 那是「刚好被释放」,直接再试一次 `open(wx)`(CAS 会自然地
128
143
  * 决出唯一赢家);两次都撞上「刚好被释放」才交回给调用方重试。
129
144
  */
130
145
  async function acquireOnce(lockPath: string, info: LockInfo): Promise<AcquireOutcome> {
131
146
  for (let round = 0; round < 2; round++) {
132
- if (await writeExclusive(lockPath, info)) return { acquired: true };
147
+ if (await createExclusive(lockPath, info)) return { acquired: true };
133
148
  const state = await readLockState(lockPath);
134
149
  if (state.kind === "absent") continue;
150
+ // 建立中:有人在写记录,等它写完(或超时)。**不是**遗弃。
151
+ if (state.kind === "empty") return { acquired: false, holder: null, abandoned: false };
135
152
  if (state.kind === "unreadable") return { acquired: false, holder: null, abandoned: true };
136
153
  return { acquired: false, holder: state.holder, abandoned: isAbandoned(state.holder) };
137
154
  }
138
155
  return { acquired: false, holder: null, abandoned: false };
139
156
  }
140
157
 
141
- let tempCounter = 0;
142
-
143
158
  /**
144
- * 原子获取:先把持有者信息写进同目录的唯一临时文件,再 `link` 到锁路径。
159
+ * 原子获取:`open(lockPath, "wx")`(POSIX 的 `O_CREAT|O_EXCL`、Windows 的 `CREATE_NEW`、
160
+ * SMB2 的 `FILE_CREATE`)保证只有一个进程能建立锁文件,建立后**立即**写入完整的持有者记录。
145
161
  *
146
- * 不能用 `open(lockPath, "wx")` 后紧接着单独写内容 —— 那会让锁路径出现「存在但 0 字节」的
147
- * 中间态,等待者读到空文件会把它判为「无法解释」并据为己有。link 是原子的:锁路径要么不存在,
148
- * 要么内容是完整的 JSON。
162
+ * 与旧实现的差别:旧实现先把记录写进同目录的临时文件、再 `link` 到锁路径,因而内容原子出现;
163
+ * 但 `link` **只支持 NTFS**(ReFS、部分网络共享与云盘目录都不支持),在那些卷上整把锁取不到
164
+ * → 记忆完全不可写(spec §1.2 P4)。`open(wx)` 的**建立**在任意卷上都原子 —— 原子的只是
165
+ * 「谁建了这个文件」,**不是记录内容**:内容随后才写,于是有「文件已建立、内容未写入」的极短
166
+ * 窗口,由读取侧的 `empty` 态与 `/memory unlock` 兜底(spec §4.4)。
149
167
  */
150
- async function writeExclusive(lockPath: string, info: LockInfo): Promise<boolean> {
151
- tempCounter += 1;
152
- const tempPath = `${lockPath}.${process.pid}.${tempCounter}.tmp`;
168
+ async function createExclusive(lockPath: string, info: LockInfo): Promise<boolean> {
169
+ let handle: Awaited<ReturnType<typeof open>>;
153
170
  try {
154
- const handle = await open(tempPath, "wx");
155
- try {
156
- await handle.writeFile(JSON.stringify(info), "utf8");
157
- } finally {
158
- await handle.close();
159
- }
160
- try {
161
- await link(tempPath, lockPath);
162
- return true;
163
- } catch (e) {
164
- if ((e as NodeJS.ErrnoException).code === "EEXIST") return false;
165
- throw e;
166
- }
167
- } finally {
168
- await unlink(tempPath).catch(() => {});
171
+ handle = await withFsRetry(() => open(lockPath, "wx"));
172
+ } catch (e) {
173
+ if ((e as NodeJS.ErrnoException).code === "EEXIST") return false;
174
+ throw e;
175
+ }
176
+ try {
177
+ const record = Buffer.from(JSON.stringify(info), "utf8");
178
+ await withFsRetry(async () => {
179
+ // 定长位置写 + 完整性校验:写入用显式偏移,重试不会续写(`writeFile` 会把第二次写入
180
+ // 追加到当前位置 → 两个 JSON 拼接 → 读者判 unreadable → 持有者自己也释放不掉这把锁)。
181
+ let written = 0;
182
+ while (written < record.length) {
183
+ const { bytesWritten } = await handle.write(record, written, record.length - written, written);
184
+ if (bytesWritten <= 0) {
185
+ throw Object.assign(new Error("lock record write made no progress"), { code: "EIO" });
186
+ }
187
+ written += bytesWritten;
188
+ }
189
+ });
190
+ // `close` 也可能失败(SMB/网络盘的延迟刷写错误、EIO),而它在记录**已落盘**之后发生:
191
+ // 若直接上抛,`withLock` 的 finally 不会执行(acquire 从未返回 true),锁会留在原地并指向
192
+ // 本进程 → 本进程此后每次获取都读到「活持有者」并自锁到 timeout。故与写入失败同一处置。
193
+ await handle.close();
194
+ } catch (e) {
195
+ // 走到这里说明这把锁没有可用的持有者(记录没写进去,或 close 失败):先关句柄再删文件
196
+ // (Windows 上删除打开中的文件需要 share-delete,且删除会延迟到关闭之后),否则会留下
197
+ // 0 字节/自指的锁挡住后来的写入者。`rm` 走重试:win32 上杀软/索引器造成的瞬时 EPERM
198
+ // 正是本任务要消掉的那类残留(spec §1.2 P5)。
199
+ await handle.close().catch(() => {});
200
+ await withFsRetry(() => rm(lockPath, { force: true })).catch(() => {});
201
+ throw e;
169
202
  }
203
+ return true;
170
204
  }
171
205
 
172
206
  function holderInfo(op: string, now: number): LockInfo {
@@ -186,13 +220,14 @@ function isAbandoned(holder: LockInfo | null): boolean {
186
220
 
187
221
  /**
188
222
  * 只在锁仍是自己持有的情况下删除;否则留给真正的持有者。
189
- * 记录读不懂时也**不删** —— 那是别人的状态(或需要人工处理的状态),不该由我们清理。
223
+ * 记录读不懂或仍是 0 字节(建立中)时也**不删** —— 那是别人的状态(或需要人工处理的状态),
224
+ * 不该由我们清理 —— 我们无法证明它属于自己(spec §4.4)。`force: true` 让「刚好被释放」不是错误。
190
225
  */
191
226
  async function releaseLock(lockPath: string): Promise<void> {
192
227
  const state = await readLockState(lockPath);
193
- if (state.kind === "unreadable") return;
228
+ if (state.kind === "unreadable" || state.kind === "empty") return;
194
229
  if (state.kind === "held" && !isOwnRecord(state.holder)) return;
195
- await rm(lockPath, { force: true });
230
+ await withFsRetry(() => rm(lockPath, { force: true }));
196
231
  }
197
232
 
198
233
  function isOwnRecord(holder: LockInfo): boolean {
@@ -0,0 +1,51 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+
3
+ /**
4
+ * 物理文件系统调用的短退避重试。存在理由(spec §1.2 P5):Windows 上杀软扫描、索引器、
5
+ * 编辑器会用瞬时 `EPERM`/`EBUSY` 打断正常写入;而 pi-memory 是 fail-closed 且锁永不自动回收 ——
6
+ * 一次瞬时错误若落在释放锁的 `rm(.lock)` 上,就会留下一个只能人工 `/memory unlock` 的锁,
7
+ * 之后该项目**所有**记忆写入被堵死。
8
+ */
9
+
10
+ /** 全平台都算瞬时:这些 errno 在 POSIX 上同样是可恢复的争用(对齐 Node `fs.rm` 的重试集合)。 */
11
+ const TRANSIENT_CODES = new Set(["EBUSY", "EMFILE", "ENFILE", "ENOTEMPTY"]);
12
+ /** 只在 win32 追加:POSIX 上 EPERM/EACCES 是永久性权限错误,重试只会拖慢 fail-closed。 */
13
+ const WINDOWS_ONLY_CODES = new Set(["EPERM", "EACCES"]);
14
+
15
+ export function isTransientFsError(err: unknown, platform: NodeJS.Platform = process.platform): boolean {
16
+ const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
17
+ if (typeof code !== "string") return false;
18
+ if (TRANSIENT_CODES.has(code)) return true;
19
+ return platform === "win32" && WINDOWS_ONLY_CODES.has(code);
20
+ }
21
+
22
+ export interface FsRetryOptions {
23
+ retries?: number;
24
+ baseDelayMs?: number;
25
+ maxDelayMs?: number;
26
+ platform?: NodeJS.Platform;
27
+ /** 测试注入缝:替换真实等待。 */
28
+ sleep?: (ms: number) => Promise<void>;
29
+ }
30
+
31
+ /**
32
+ * 重试 `fn` 直到成功、遇到非瞬时错误、或耗尽预算。
33
+ * **错过的错误原样上抛**(含耗尽后的最后一次)—— 调用方的 fail-closed 语义与错误文案都不变
34
+ * (spec Ruling 8),这里不包装新错误类型。
35
+ */
36
+ export async function withFsRetry<T>(fn: () => Promise<T>, options: FsRetryOptions = {}): Promise<T> {
37
+ const retries = options.retries ?? 6;
38
+ const baseDelayMs = options.baseDelayMs ?? 20;
39
+ const maxDelayMs = options.maxDelayMs ?? 300;
40
+ const platform = options.platform ?? process.platform;
41
+ const wait = options.sleep ?? ((ms: number) => sleep(ms));
42
+
43
+ for (let attempt = 0; ; attempt++) {
44
+ try {
45
+ return await fn();
46
+ } catch (e) {
47
+ if (attempt >= retries || !isTransientFsError(e, platform)) throw e;
48
+ await wait(Math.min(maxDelayMs, baseDelayMs * 2 ** attempt));
49
+ }
50
+ }
51
+ }
@@ -5,8 +5,10 @@ import { deriveDescription, type EntryType, parseEntryFile, serializeEntryFile }
5
5
  import { formatIndexLine, indexCapacity, parseEntryIndex, removeIndexLine, upsertIndexLine } from "./entry-index";
6
6
  import { entryFileName, resolveUniqueFileName } from "./filename";
7
7
  import { withLock } from "./fs-lock";
8
+ import { withFsRetry } from "./fs-retry";
8
9
  import { isProcessLockActive, tryWithProcessLock, withProcessLock } from "./process-lock";
9
10
  import { createSnapshot } from "./snapshot";
11
+ import { isReservedWindowsName } from "./windows-names";
10
12
 
11
13
  export const INDEX_FILE = "MEMORY.md";
12
14
  export const LOCK_FILE = ".lock";
@@ -63,6 +65,8 @@ export interface StoreConfig {
63
65
  /** 跨进程 `.lock` 的等待上限。注意这里**没有 ttl** —— 锁只在毫秒级的物理写入期间持有,
64
66
  * 且永不自动回收(见 fs-lock.ts);dream 的整轮互斥由 process-lock.ts 的进程内队列承担。 */
65
67
  lock: { timeoutMs: number; snapshotKeep: number };
68
+ /** 命名/读取规则跟随的平台(默认 `process.platform`)。测试注入缝,生产不传。 */
69
+ platform?: NodeJS.Platform;
66
70
  }
67
71
 
68
72
  export interface EntrySummary {
@@ -109,11 +113,30 @@ function compareSummaries(a: EntrySummary, b: EntrySummary): number {
109
113
  export class MemoryStore {
110
114
  readonly #cache = new Map<string, CacheRow>();
111
115
 
116
+ get #platform(): NodeJS.Platform {
117
+ return this.cfg.platform ?? process.platform;
118
+ }
119
+
120
+ /** 物理写入路径的统一重试入口(spec §4.5):只包物理调用,不包逻辑。 */
121
+ #retry<T>(fn: () => Promise<T>): Promise<T> {
122
+ return withFsRetry(fn, { platform: this.#platform });
123
+ }
124
+
112
125
  constructor(readonly cfg: StoreConfig) {}
113
126
 
127
+ /** 读取路径与 `#savingQueue` 的 `mkdir` **刻意不重试**(spec §4.5):读失败不改变磁盘状态,
128
+ * 目录创建失败会在获取锁阶段就干净地报错(无残留);重试只会拖慢 fail-closed。 */
114
129
  async #entryFiles(): Promise<string[]> {
115
130
  const names = await readdir(this.cfg.memoryDir).catch(() => []);
116
- return names.filter((n) => n.endsWith(".md") && n !== INDEX_FILE && !n.startsWith(".")).sort();
131
+ return (
132
+ names
133
+ .filter((n) => n.endsWith(".md") && n !== INDEX_FILE && !n.startsWith("."))
134
+ // win32:按名打开保留设备名文件会命中设备(CON 等),`readFile` 会阻塞在控制台而不是
135
+ // 返回内容。修复前的版本或外部工具可能留下过这种文件,这里把它从清单里剔除 ——
136
+ // 最坏表现是「该条记忆在本机不可见」,而不是卡住整个扫描(spec Ruling 5)。
137
+ .filter((n) => !(this.#platform === "win32" && isReservedWindowsName(n)))
138
+ .sort()
139
+ );
117
140
  }
118
141
 
119
142
  /** 已被占用的文件名:磁盘上的条目文件 + 索引文件本身。
@@ -214,6 +237,7 @@ export class MemoryStore {
214
237
  async #snapshot(label: string, files: string[]): Promise<void> {
215
238
  await createSnapshot(join(this.cfg.memoryDir, BACKUP_DIR), label, files, this.cfg.memoryDir, {
216
239
  keep: this.cfg.lock.snapshotKeep,
240
+ platform: this.#platform,
217
241
  });
218
242
  }
219
243
 
@@ -298,12 +322,6 @@ export class MemoryStore {
298
322
  return out;
299
323
  }
300
324
 
301
- /** 清空并重建缓存(手工编辑过目录后用)。 */
302
- async refreshCache(): Promise<void> {
303
- this.#cache.clear();
304
- await this.listEntries();
305
- }
306
-
307
325
  /** name 校验:非空、单行、不含 `](`。add 与 replace 共用,否则改名就成了绕过入口。 */
308
326
  #validateName(name: string): void {
309
327
  if (!name) throw new Error("name is required");
@@ -353,23 +371,25 @@ export class MemoryStore {
353
371
  const now = new Date();
354
372
 
355
373
  await this.#maybeSnapshot(options, "write", [INDEX_FILE, file]);
356
- await writeFile(
357
- join(this.cfg.memoryDir, file),
358
- serializeEntryFile(
359
- {
360
- name,
361
- description,
362
- type: input.type ?? existing?.type ?? "feedback",
363
- created,
364
- modified: now.toISOString(),
365
- },
366
- body,
374
+ await this.#retry(() =>
375
+ writeFile(
376
+ join(this.cfg.memoryDir, file),
377
+ serializeEntryFile(
378
+ {
379
+ name,
380
+ description,
381
+ type: input.type ?? existing?.type ?? "feedback",
382
+ created,
383
+ modified: now.toISOString(),
384
+ },
385
+ body,
386
+ ),
387
+ "utf8",
367
388
  ),
368
- "utf8",
369
389
  );
370
390
 
371
391
  const next = upsertIndexLine(await this.readIndex(), { name, file, description });
372
- await writeFile(this.#indexPath(), next, "utf8");
392
+ await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
373
393
  this.#cache.delete(file);
374
394
 
375
395
  return { file, capacityWarning: this.#capacityWarning(next) };
@@ -407,26 +427,28 @@ export class MemoryStore {
407
427
  const file = name === current.name ? current.file : await this.#resolveTargetFile(name, current.file);
408
428
 
409
429
  await this.#maybeSnapshot(options, "write", [INDEX_FILE, current.file, file]);
410
- await writeFile(
411
- join(this.cfg.memoryDir, file),
412
- serializeEntryFile(
413
- {
414
- name,
415
- description,
416
- type: patch.type ?? current.type,
417
- created: current.created,
418
- modified: new Date().toISOString(),
419
- },
420
- body,
430
+ await this.#retry(() =>
431
+ writeFile(
432
+ join(this.cfg.memoryDir, file),
433
+ serializeEntryFile(
434
+ {
435
+ name,
436
+ description,
437
+ type: patch.type ?? current.type,
438
+ created: current.created,
439
+ modified: new Date().toISOString(),
440
+ },
441
+ body,
442
+ ),
443
+ "utf8",
421
444
  ),
422
- "utf8",
423
445
  );
424
446
  if (file !== current.file) {
425
447
  // 大小写不敏感 / 做 Unicode 规范化的文件系统上,两个不同的字符串可能指向同一 inode;
426
448
  // 那种情况下上面的 writeFile 已经写穿了原文件,再 unlink 会把刚写入的文件删掉。
427
449
  const target = join(this.cfg.memoryDir, file);
428
450
  const source = join(this.cfg.memoryDir, current.file);
429
- if (!(await sameFile(target, source))) await unlinkStrict(source);
451
+ if (!(await sameFile(target, source))) await this.#retry(() => unlinkStrict(source));
430
452
  }
431
453
 
432
454
  const raw = await this.readIndex();
@@ -442,7 +464,7 @@ export class MemoryStore {
442
464
  { name, file, description },
443
465
  line ? { atLineNo: line.lineNo } : undefined,
444
466
  );
445
- await writeFile(this.#indexPath(), next, "utf8");
467
+ await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
446
468
  this.#cache.delete(current.file);
447
469
  this.#cache.delete(file);
448
470
 
@@ -456,8 +478,9 @@ export class MemoryStore {
456
478
  if (!current) throw new Error(`Entry "${ref}" not found`);
457
479
 
458
480
  await this.#maybeSnapshot(options, "write", [INDEX_FILE, current.file]);
459
- await unlinkStrict(join(this.cfg.memoryDir, current.file));
460
- await writeFile(this.#indexPath(), removeIndexLine(await this.readIndex(), current.file), "utf8");
481
+ await this.#retry(() => unlinkStrict(join(this.cfg.memoryDir, current.file)));
482
+ const next = removeIndexLine(await this.readIndex(), current.file);
483
+ await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
461
484
  this.#cache.delete(current.file);
462
485
  });
463
486
  }
@@ -483,7 +506,9 @@ export class MemoryStore {
483
506
  ...effectiveHeader,
484
507
  ...summaries.map((s) => formatIndexLine(s.name, s.file, s.description)),
485
508
  ];
486
- await writeFile(this.#indexPath(), rebuilt.length === 0 ? "" : `${rebuilt.join("\n")}\n`, "utf8");
509
+ await this.#retry(() =>
510
+ writeFile(this.#indexPath(), rebuilt.length === 0 ? "" : `${rebuilt.join("\n")}\n`, "utf8"),
511
+ );
487
512
  this.#cache.clear();
488
513
 
489
514
  return { entries: summaries.length, headerLines: effectiveHeader.length };
package/src/paths.ts CHANGED
@@ -1,11 +1,25 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { join, resolve } from "node:path";
3
+ import { join, posix, resolve, win32 } from "node:path";
4
4
  import { promisify } from "node:util";
5
+ import { windowsSafeName } from "./windows-names";
5
6
 
6
7
  const execFileP = promisify(execFile);
7
8
  const GIT_TIMEOUT_MS = 3000;
8
9
 
10
+ /**
11
+ * 仓库根:`git rev-parse --show-toplevel`(绝对路径)。
12
+ *
13
+ * 2026-10-04 用户决定回到这个实现(spec Ruling 12,**撤销 Ruling 3**):Windows 上只用
14
+ * PowerShell、Git Bash 不在支持面内,所以「MSYS 形态输出会让 `resolve()` 得到 `C:\c\…`」这条
15
+ * 动机消失。随之删除了 `--is-inside-work-tree` 判据与 MSYS 仿真用例:裸仓库、cwd 在 `.git`
16
+ * 内部(含 `.git/objects`)时 git 以 `fatal: this operation must be run in a work tree` 退出,
17
+ * 这里天然返回 `null`,调用方落回 `local/<绝对路径>`;非仓库同样由 catch 返回 `null`。
18
+ *
19
+ * 已知限制(spec §7):若 PATH 上的 `git` 是 cygwin/MSYS 构建(对任何调用者都打印
20
+ * `/cygdrive/c/…` 这类 POSIX 形态路径),`resolve()` 会得到错误前缀 —— 同一项目会出现第二个
21
+ * 记忆目录。Git for Windows 的 `git.exe` 打印原生路径,不受影响。
22
+ */
9
23
  async function gitToplevel(cwd: string): Promise<string | null> {
10
24
  try {
11
25
  const { stdout } = await execFileP("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: GIT_TIMEOUT_MS });
@@ -38,24 +52,41 @@ function truncateToBytes(input: string, maxBytes: number): string {
38
52
  return kept;
39
53
  }
40
54
 
55
+ export interface NamingOptions {
56
+ /** 命名规则跟随的平台(默认 `process.platform`)。win32 上 `\` 作为分隔符参与分段。 */
57
+ platform?: NodeJS.Platform;
58
+ }
59
+
60
+ // win32 上 `\` 也是分隔符 —— 它必须参与分段,而不是被转义成字面量:转义会让整个名字在
61
+ // `join(memoryDir, …)` 时仍被拆成多级目录(`local/C_3a/Users/...`),而畸形 key
62
+ // (`host/a\..\..\..\etc`)里的 `..` 段还能让目录逃出 memoryDir(spec §1.2 P1/P7)。
63
+ const PATH_SEPARATORS = /[/\\]/;
64
+ const POSIX_SEPARATORS = /\//;
65
+
41
66
  /**
42
67
  * Encode a project key into a single, human-readable directory name.
43
- * `host/repo/path` → `host__repo__path`; `/abs/path` → `abs__path`.
44
- * Naming targets POSIX filesystems: `\` is an ordinary character, without
45
- * Windows device-name or trailing-dot handling.
68
+ * `host/repo/path` → `host__repo__path`; `/abs/path` → `abs__path`。
69
+ *
70
+ * 平台差异(spec Ruling 1/D2):win32 上按 `/` 与 `\` 双分隔符分段,并保证输出满足三条
71
+ * 不变量 —— 不含分隔符、不以 `.`/空格结尾、不是保留设备名;POSIX 上 `\` 仍是普通字符,
72
+ * 输出与 win32 支持引入前逐字节一致。
46
73
  */
47
- export function projectDirName(key: string): string {
74
+ export function projectDirName(key: string, options?: NamingOptions): string {
75
+ const platform = options?.platform ?? process.platform;
76
+ const separator = platform === "win32" ? PATH_SEPARATORS : POSIX_SEPARATORS;
48
77
  const segments = key
49
- .split("/")
78
+ .split(separator)
50
79
  .filter((segment) => segment !== "" && segment !== "." && segment !== "..")
51
80
  .map(escapeSegment);
52
81
  if (segments.length === 0) return "root";
53
82
  const joined = segments.join("__");
54
83
  // Filesystems cap one name at 255 bytes: count bytes so multi-byte names
55
84
  // (CJK, emoji) cannot exceed the limit, and cut on code point boundaries.
56
- if (Buffer.byteLength(joined, "utf8") <= DIR_NAME_MAX_BYTES) return joined;
57
- const suffix = createHash("sha256").update(key).digest("hex").slice(0, HASH_LENGTH);
58
- return `${truncateToBytes(joined, DIR_NAME_KEEP_BYTES)}__${suffix}`;
85
+ const name =
86
+ Buffer.byteLength(joined, "utf8") <= DIR_NAME_MAX_BYTES
87
+ ? joined
88
+ : `${truncateToBytes(joined, DIR_NAME_KEEP_BYTES)}__${createHash("sha256").update(key).digest("hex").slice(0, HASH_LENGTH)}`;
89
+ return platform === "win32" ? windowsSafeName(name) : name;
59
90
  }
60
91
 
61
92
  export type ProjectKind = "git" | "local";
@@ -68,6 +99,12 @@ export interface ProjectIdentity {
68
99
 
69
100
  const GIT_PROTOCOLS = new Set(["http", "https", "ssh", "git"]);
70
101
  const SCHEME_URL = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
102
+ /**
103
+ * Windows 盘符路径(`Z:\…` / `Z:/…`)。在 win32 上这是**本地路径**,不是 scp 的 `host:path`:
104
+ * Git for Windows 就这么解释(用户可以 `git clone Z:\some.git`);而 `resolve()` 会把它当成
105
+ * host `z`(spec Ruling 13)。POSIX 上同一串是合法的 scp 写法(host `C`),与 git 一致,不改判。
106
+ */
107
+ const WINDOWS_DRIVE_PATH = /^[a-zA-Z]:[\\/]/;
71
108
 
72
109
  /** Lowercase and IDN-normalize a host the way the URL API does for https. */
73
110
  function normalizeHost(host: string): string {
@@ -81,11 +118,14 @@ function normalizeHost(host: string): string {
81
118
  /**
82
119
  * Normalize an http(s)/ssh/git remote URL to `host/path`.
83
120
  * Accepts `[user@]host:path` scp syntax and `git+ssh` / `git+https` aliases.
84
- * Returns null for file:// URLs, local paths and URLs without a repository path.
121
+ * Returns null for file:// URLs, local paths and URLs without a repository path
122
+ * (因此盘符本地 remote 在 win32 上也返回 null → 落回 `local/<绝对路径>`)。
85
123
  */
86
- export function normalizeRemoteUrl(url: string): string | null {
124
+ export function normalizeRemoteUrl(url: string, platform: NodeJS.Platform = process.platform): string | null {
87
125
  const raw = url.trim();
88
126
  if (!raw) return null;
127
+ // win32 上「盘符 + 分隔符」开头的是本地路径,不是 scp 的 host(见 `WINDOWS_DRIVE_PATH`)。
128
+ if (platform === "win32" && WINDOWS_DRIVE_PATH.test(raw)) return null;
89
129
 
90
130
  let host: string;
91
131
  let path: string;
@@ -160,18 +200,46 @@ async function gitRemoteUrls(cwd: string): Promise<RemoteEntry[]> {
160
200
  }
161
201
  }
162
202
 
163
- /** Resolve the project identity: normalized git remote, or the project root path. */
164
- export async function projectIdentity(cwd: string): Promise<ProjectIdentity> {
203
+ /** Resolve the project identity: normalized git remote, or the project root path.
204
+ *
205
+ * `platform` 决定盘符远程的归类(win32 上当本地路径,见 `normalizeRemoteUrl`);其余平台行为不变。
206
+ */
207
+ export async function projectIdentity(cwd: string, platform: NodeJS.Platform = process.platform): Promise<ProjectIdentity> {
165
208
  const toplevel = await gitToplevel(cwd);
166
209
  if (!toplevel) return { kind: "local", key: resolve(cwd) };
167
210
  for (const { url } of await gitRemoteUrls(cwd)) {
168
- const key = normalizeRemoteUrl(url);
211
+ const key = normalizeRemoteUrl(url, platform);
169
212
  if (key) return { kind: "git", key };
170
213
  }
171
214
  return { kind: "local", key: resolve(toplevel) };
172
215
  }
173
216
 
217
+ type PathApi = typeof import("node:path");
218
+
219
+ /**
220
+ * 断言 `dir` 确实是 `base` 之内的一层(spec §4.2 / D4)。
221
+ *
222
+ * 派生名一旦含有分隔符,`join` 就会把它拆成多级路径 —— 畸形 remote(key 里带 `\..\..`)
223
+ * 能让目录逃出 `memoryDir`。fail-closed:越界即抛错,由 session_start 转成配置错误态,
224
+ * 而不是悄悄写到别的地方。
225
+ *
226
+ * 导出仅为直接单测:POSIX 命名下 `projectDirName` 不可能产出分隔符,公开 API 走不到这条分支。
227
+ * `platform` 决定用哪套 `path` 语义(win32 的 `relative` 大小写不敏感、认 `\` 与盘符)。
228
+ */
229
+ export function assertInsideRoot(base: string, dir: string, platform: NodeJS.Platform = process.platform): void {
230
+ const api: PathApi = platform === "win32" ? win32 : posix;
231
+ const rel = api.relative(base, dir);
232
+ if (rel === "" || rel.startsWith("..") || api.isAbsolute(rel)) {
233
+ throw new Error(`Refusing to use memory directory ${dir}: it escapes ${base}`);
234
+ }
235
+ }
236
+
174
237
  export async function resolveMemoryDir(config: { memoryDir: string }, cwd: string): Promise<string> {
175
238
  const { kind, key } = await projectIdentity(cwd);
176
- return join(config.memoryDir, kind, projectDirName(key));
239
+ // 相对路径配置(如 `"./memory"`)固化成绝对路径:逻辑锁的 key 用这个字符串,
240
+ // 同一目录的不同写法不该得到两把锁(见 `memory-store.ts` 的 `#logicalKey`)。
241
+ const base = resolve(config.memoryDir);
242
+ const dir = join(base, kind, projectDirName(key));
243
+ assertInsideRoot(base, dir);
244
+ return dir;
177
245
  }
package/src/snapshot.ts CHANGED
@@ -1,9 +1,22 @@
1
1
  import { cp, mkdir, readdir, rm, stat } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
+ import { withFsRetry } from "./fs-retry";
3
4
 
4
5
  export interface SnapshotOptions {
5
6
  keep: number;
6
7
  now?: () => Date;
8
+ /** 重试判定跟随的平台(默认 `process.platform`):win32 才把 EPERM/EACCES 当瞬时错误。 */
9
+ platform?: NodeJS.Platform;
10
+ }
11
+
12
+ /**
13
+ * 只包住**幂等**的物理调用(`mkdir` 是 `recursive` 的、`cp` 会覆盖),因此重试不会产生副作用。
14
+ * 快照跑在**每一次**写入原语里(memory-store 的 `#maybeSnapshot`,已在跨进程锁之内),而
15
+ * Windows 上杀软扫描与同步客户端打断的正是 `mkdir`/`cp` 这类调用:不重试的话,一次瞬时
16
+ * EPERM 就会让 `memory add` 直接失败(spec §1.2 P5)。
17
+ */
18
+ function withSnapshotRetry<T>(fn: () => Promise<T>, platform?: NodeJS.Platform): Promise<T> {
19
+ return withFsRetry(fn, { platform });
7
20
  }
8
21
 
9
22
  /** 可字典序排序的快照时间戳(ISO 8601,`:` 与 `.` 换为 `-`)。 */
@@ -11,14 +24,16 @@ export function snapshotStamp(now: () => Date): string {
11
24
  return now().toISOString().replace(/[:.]/g, "-");
12
25
  }
13
26
 
14
- async function uniqueDir(backupRoot: string, base: string): Promise<string> {
15
- await mkdir(backupRoot, { recursive: true });
27
+ async function uniqueDir(backupRoot: string, base: string, platform?: NodeJS.Platform): Promise<string> {
28
+ await withSnapshotRetry(() => mkdir(backupRoot, { recursive: true }), platform);
16
29
  for (let n = 1; ; n++) {
17
30
  const candidate = n === 1 ? join(backupRoot, base) : join(backupRoot, `${base}-${n}`);
18
31
  try {
19
- await mkdir(candidate);
32
+ await withSnapshotRetry(() => mkdir(candidate), platform);
20
33
  return candidate;
21
34
  } catch (e) {
35
+ // EEXIST 仍然是「换下一个名字」的信号:它不在 `withFsRetry` 的瞬时错误集合里,
36
+ // 重试不会吞掉它(重名快照的用例靠这一行)。
22
37
  if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
23
38
  }
24
39
  }
@@ -32,11 +47,16 @@ export async function createSnapshot(
32
47
  memoryDir: string,
33
48
  options: SnapshotOptions,
34
49
  ): Promise<string> {
35
- const dir = await uniqueDir(backupRoot, `${snapshotStamp(options.now ?? (() => new Date()))}-${label}`);
50
+ const dir = await uniqueDir(
51
+ backupRoot,
52
+ `${snapshotStamp(options.now ?? (() => new Date()))}-${label}`,
53
+ options.platform,
54
+ );
36
55
  for (const file of files) {
37
56
  try {
38
- await cp(join(memoryDir, file), join(dir, basename(file)));
57
+ await withSnapshotRetry(() => cp(join(memoryDir, file), join(dir, basename(file))), options.platform);
39
58
  } catch (e) {
59
+ // ENOENT(条目文件还不存在)同样不在重试集合里:跳过行为不变。
40
60
  if ((e as NodeJS.ErrnoException).code === "ENOENT") continue;
41
61
  throw e;
42
62
  }
@@ -63,6 +83,9 @@ export async function pruneSnapshots(backupRoot: string, keep: number): Promise<
63
83
 
64
84
  dirs.sort();
65
85
  for (const name of dirs.slice(0, Math.max(0, dirs.length - Math.max(0, keep)))) {
66
- await rm(join(backupRoot, name), { recursive: true, force: true });
86
+ // 删目录会被杀软/索引器打成瞬时 EPERM/EBUSY:用 Node 自带的退避重试。
87
+ // 这两个参数**不是 Windows 专属**(Linux 上实测:`maxRetries: 3, retryDelay: 300`
88
+ // 会把一个 EMFILE 重试三次),因此全平台都生效。
89
+ await rm(join(backupRoot, name), { recursive: true, force: true, maxRetries: 6, retryDelay: 50 });
67
90
  }
68
91
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Windows 名字的两条硬规则。目录名(`paths.ts`)与 entry 文件名(`filename.ts`)共用。
3
+ *
4
+ * 为什么需要(spec §1.2 P2/P6):
5
+ * - Windows 把一组「保留设备名」当设备:`NUL`、`CON`、`COM1`…;**带扩展名也一样**
6
+ * (`NUL.txt`、`NUL.tar.gz` 都等价于 `NUL`),判定基准是「第一个 `.` 之前的部分」。
7
+ * 用这些名字创建文件得不到文件:写入落到设备上(`con.md` 写进控制台),而索引里
8
+ * 已经多了一行 —— 表现为静默丢记忆。
9
+ * - Windows 静默剥掉结尾的 `.` 与空格:`proj.` 与 `proj ` 都是 `proj`,
10
+ * 于是两个不同的 key 会撞进同一个目录。
11
+ *
12
+ * 两条规则**全平台生效**:git 类记忆目录跨机共享,名字必须在两边都安全(spec Ruling 5)。
13
+ */
14
+
15
+ /** 保留设备名(大小写不敏感)。MS 文档的经典集合 + 控制台别名。 */
16
+ const RESERVED_BASE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9]|conin\$|conout\$)$/i;
17
+
18
+ /** 判定基准是**第一个 `.` 之前的部分**:`NUL.tar.gz` 等价于 `NUL`。 */
19
+ export function isReservedWindowsName(name: string): boolean {
20
+ const dot = name.indexOf(".");
21
+ return RESERVED_BASE.test(dot === -1 ? name : name.slice(0, dot));
22
+ }
23
+
24
+ /** 结尾的 `.` / 空格 → `_2e` / `_20`(沿用 `escapeSegment` 的 `_XX` 十六进制词汇表)。 */
25
+ export function escapeWindowsTrailing(name: string): string {
26
+ const last = name[name.length - 1];
27
+ if (last === ".") return `${name.slice(0, -1)}_2e`;
28
+ if (last === " ") return `${name.slice(0, -1)}_20`;
29
+ return name;
30
+ }
31
+
32
+ /**
33
+ * 把名字收尾成 Windows 可安全使用的形态。
34
+ * 顺序固定:先转义结尾点/空格、再判保留名(固定只为确定性;两种顺序都安全)。
35
+ */
36
+ export function windowsSafeName(name: string): string {
37
+ const escaped = escapeWindowsTrailing(name);
38
+ return isReservedWindowsName(escaped) ? `_${escaped}` : escaped;
39
+ }