@yandy0725/pi-memory 1.3.3 → 1.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-memory
2
2
 
3
- File-system driven persistent memory layer for pi coding agent. Stores project knowledge across sessions — facts, preferences, debugging history — in plain Markdown files under `~/.pi/memory/<project-hash>/`.
3
+ File-system driven persistent memory layer for pi coding agent. Stores project knowledge across sessions — facts, preferences, debugging history — in plain Markdown files under `~/.pi/memory/<git|local>/<project>/`.
4
4
 
5
5
  Aligned with Claude Code's auto memory mechanism: per-topic MEMORY.md index, automatic topic file surfacing based on relevance, per-turn memory extraction, and typed memory categories.
6
6
 
@@ -17,7 +17,7 @@ Aligned with Claude Code's auto memory mechanism: per-topic MEMORY.md index, aut
17
17
  - **Dream nudge**: after N sessions or N hours, a gentle notification suggests running `/dream`
18
18
  - **`/memory` command**: show status, toggle on/off, inspect index and topic files
19
19
  - **Session search**: the `memory search scope=sessions` action queries past conversation history
20
- - **Branch-safe**: memory directory is keyed by the git root (or absolute path), so forks share memory naturally
20
+ - **Readable, clone-safe layout**: memory lives under `~/.pi/memory/git/<host__owner__repo>/` for git repos with an http(s)/ssh/git remote (including scp-style and `git+ssh`/`git+https`), and `~/.pi/memory/local/<absolute-path>/` otherwise — clones and worktrees of the same repo share memory (a fork has its own remote, so it gets its own directory)
21
21
  - **Path traversal protection**: topic files are validated against escaping the memory directory
22
22
 
23
23
  ## Install
@@ -86,13 +86,13 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
86
86
  | `memIndexMaxBytes` | `25600` | Max bytes in `MEMORY.md` before capacity errors |
87
87
  | `defaults.model` | — | Shared model fallback for all sub-tasks. Per-task `model` overrides |
88
88
  | `defaults.sessionPersistence.enabled` | `false` | Shared session persistence fallback (default: in-memory). Per-task overrides |
89
- | `defaults.sessionPersistence.sessionDir` | `memoryDir/sessions/` | Custom session directory for persisted headless agent sessions |
89
+ | `defaults.sessionPersistence.sessionDir` | `<project memory dir>/sessions/` | Custom session directory for persisted headless agent sessions |
90
90
  | `dream.nudgeAfterSessions` | `5` | Sessions since last dream before nudge is shown |
91
91
  | `dream.nudgeAfterHours` | `24` | Hours since last dream before nudge is shown |
92
92
  | `dream.model` | — | Model for dream consolidation (`"provider/id"`). Falls back to `defaults.model` → parent model |
93
93
  | `dream.thinkLevel` | `"high"` | Thinking effort for dream subagent: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"` |
94
94
  | `dream.sessionPersistence.enabled` | `false` | Persist dream agent sessions to disk (debug/audit). Falls back to `defaults.sessionPersistence.enabled` |
95
- | `dream.sessionPersistence.sessionDir` | `memoryDir/sessions/` | Custom session directory for dream sessions |
95
+ | `dream.sessionPersistence.sessionDir` | `<project memory dir>/sessions/` | Custom session directory for dream sessions |
96
96
  | `sessionSearch.maxSessions` | `10` | Max sessions to scan when searching history |
97
97
  | `sessionSearch.maxMatches` | `5` | Max matches to return from history search |
98
98
  | `autoSurfacing.enabled` | `true` | ⭐ Enable per-turn topic file auto-injection |
@@ -108,6 +108,8 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
108
108
  | `extractMemories.sessionPersistence.enabled` | `false` | Persist extract agent sessions to disk. Falls back to `defaults.sessionPersistence.enabled` |
109
109
  | `extractMemories.maxContextTokens` | `2000` | ⭐ Max tokens of conversation to analyze |
110
110
 
111
+ Persisted headless agent sessions default to `<project memory dir>/sessions/` — inside the project's memory directory, not inside your working copy. For example `~/.pi/memory/git/github.com__owner__repo/sessions/`.
112
+
111
113
  Project-level config (`.pi/memory.json`) is only loaded when the project is trusted.
112
114
 
113
115
  ## How it works
@@ -230,15 +232,30 @@ A confirmation dialog is shown before the consolidation begins. The result summa
230
232
 
231
233
  ```
232
234
  ~/.pi/memory/
233
- <12-char-sha256>/
234
- MEMORY.md — compact index: one line per topic file
235
- .dream-meta.json — last dream timestamp + session count
236
- debugging.md — topic files with frontmatter + ## entries
237
- preferences.md
238
- ...
235
+ git/
236
+ github.com__yandy__pi-packages/ ← https://github.com/yandy/pi-packages.git
237
+ MEMORY.md — compact index: one line per topic file
238
+ .dream-meta.json — last dream timestamp + session count
239
+ debugging.md — topic files with frontmatter + ## entries
240
+ preferences.md
241
+ ...
242
+ local/
243
+ home__yandy__workspace__scratch/ ← non-git directory /home/yandy/workspace/scratch
239
244
  ```
240
245
 
241
- The hash is derived from the project's git root (or absolute path), ensuring each project gets its own memory namespace.
246
+ Directory names are derived as follows:
247
+
248
+ - 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
249
+ - 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
250
+ - 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
251
+ - 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)
252
+ - `/` becomes `__`; characters that are not portable in file names (`<>:"|?*`, control characters) become `_XX` hex escapes
253
+ - names longer than 120 UTF-8 bytes are truncated to 100 bytes on a code-point boundary plus a `__<hash8>` suffix
254
+ - names target POSIX filesystems: a backslash is an ordinary character, and no Windows device-name or trailing-dot handling is applied
255
+
256
+ 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.
257
+
258
+ **Legacy layout:** earlier versions 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).
242
259
 
243
260
  ## Snapshot semantics
244
261
 
package/README.zh.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-memory
2
2
 
3
- 基于文件系统的持久化记忆层,为 pi 编程代理提供跨会话的项目记忆。事实、偏好、调试历史等知识以纯 Markdown 文件形式存储在 `~/.pi/memory/<项目哈希>/` 下。
3
+ 基于文件系统的持久化记忆层,为 pi 编程代理提供跨会话的项目记忆。事实、偏好、调试历史等知识以纯 Markdown 文件形式存储在 `~/.pi/memory/<git|local>/<项目>/` 下。
4
4
 
5
5
  对齐 Claude Code 的 auto memory 机制:每 topic 一行的 MEMORY.md 紧凑索引、基于相关性的 topic 文件自动注入、per-turn 记忆自动提取、类型化记忆分类。
6
6
 
@@ -17,7 +17,7 @@
17
17
  - **梦醒提醒**:经过 N 个会话或 N 小时后,温和通知建议运行 `/dream`
18
18
  - **`/memory` 命令**:查看状态、开关记忆、检查索引和主题文件
19
19
  - **会话搜索**:`memory search scope=sessions` 操作可检索过往对话历史
20
- - **分支安全**:记忆目录以 git 根目录为键,分叉仓库自然共享记忆
20
+ - **可读且 clone 安全**:有 http(s)/ssh/git remote(含 scp 式与 `git+ssh`/`git+https`)的仓库记忆存放在 `~/.pi/memory/git/<host__owner__repo>/`,其余项目存放在 `~/.pi/memory/local/<绝对路径>/` — 同一仓库的 clone、worktree 共享记忆(fork 有自己的 remote,因此拥有独立目录)
21
21
  - **路径穿越防护**:主题文件路径会验证是否逃逸记忆目录
22
22
 
23
23
  ## 安装
@@ -86,13 +86,13 @@ pi install npm:@yandy0725/pi-memory
86
86
  | `memIndexMaxBytes` | `25600` | `MEMORY.md` 最大字节数 |
87
87
  | `defaults.model` | — | 所有子任务共享的模型回退值。per-task `model` 会覆盖 |
88
88
  | `defaults.sessionPersistence.enabled` | `false` | 共享的 session 持久化回退(默认不持久化)。per-task 可覆盖 |
89
- | `defaults.sessionPersistence.sessionDir` | `memoryDir/sessions/` | 自定义持久化目录 |
89
+ | `defaults.sessionPersistence.sessionDir` | `<项目记忆目录>/sessions/` | 自定义持久化目录 |
90
90
  | `dream.nudgeAfterSessions` | `5` | 触发提醒需经过的会话数 |
91
91
  | `dream.nudgeAfterHours` | `24` | 触发提醒需经过的小时数 |
92
92
  | `dream.model` | — | 整理使用的模型(`"provider/id"`)。回退链:per-task → `defaults.model` → 父模型 |
93
93
  | `dream.thinkLevel` | `"high"` | 整理子 agent 的思考深度:`"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
94
94
  | `dream.sessionPersistence.enabled` | `false` | 持久化 dream agent 的 session 到磁盘(调试/审计)。回退到 `defaults.sessionPersistence.enabled` |
95
- | `dream.sessionPersistence.sessionDir` | `memoryDir/sessions/` | dream session 自定义目录 |
95
+ | `dream.sessionPersistence.sessionDir` | `<项目记忆目录>/sessions/` | dream session 自定义目录 |
96
96
  | `sessionSearch.maxSessions` | `10` | 搜索历史时最多扫描的会话数 |
97
97
  | `sessionSearch.maxMatches` | `5` | 历史搜索最多返回的匹配数 |
98
98
  | `autoSurfacing.enabled` | `true` | ⭐ 启用 per-turn topic 文件自动注入 |
@@ -108,6 +108,8 @@ pi install npm:@yandy0725/pi-memory
108
108
  | `extractMemories.sessionPersistence.enabled` | `false` | 持久化 extract agent 的 session。回退到 `defaults.sessionPersistence.enabled` |
109
109
  | `extractMemories.maxContextTokens` | `2000` | ⭐ 分析对话的最大 token 数 |
110
110
 
111
+ 持久化的无头 agent session 默认落在 `<项目记忆目录>/sessions/` —— 位于项目的记忆目录内,而非你的代码工作目录,例如 `~/.pi/memory/git/github.com__owner__repo/sessions/`。
112
+
111
113
  项目级配置(`.pi/memory.json`)仅在项目受信任时加载。
112
114
 
113
115
  ## 工作原理
@@ -228,15 +230,30 @@ memory(action: "add" | "remove" | "search",
228
230
 
229
231
  ```
230
232
  ~/.pi/memory/
231
- <12位sha256哈希>/
232
- MEMORY.md — 紧凑索引:每个 topic 文件一行
233
- .dream-meta.json — 上次整理的时间戳和会话计数
234
- debugging.md — topic 文件(frontmatter + ## 条目)
235
- preferences.md
236
- ...
233
+ git/
234
+ github.com__yandy__pi-packages/ ← https://github.com/yandy/pi-packages.git
235
+ MEMORY.md — 紧凑索引:每个 topic 文件一行
236
+ .dream-meta.json — 上次整理的时间戳和会话计数
237
+ debugging.md — topic 文件(frontmatter + ## 条目)
238
+ preferences.md
239
+ ...
240
+ local/
241
+ home__yandy__workspace__scratch/ ← 非 git 目录 /home/yandy/workspace/scratch
237
242
  ```
238
243
 
239
- 哈希值由项目的 git 根目录(或绝对路径)派生,每个项目拥有独立记忆命名空间。
244
+ 目录名的派生规则:
245
+
246
+ - remote 为 http(s)、ssh(含 scp 式 `[user@]host:owner/repo`,user 可省略)或 `git://`,以及 `git+ssh://` / `git+https://` 别名的 git 仓库 → `git/<host>__<owner>__<repo>`;端口、认证信息、尾部 `/` 与 `.git` 均被剥离,host 转小写
247
+ - remote URL 读取自原始 git 配置(`remote.<name>.url`;`origin` 优先,其余按字母序,取首个可归一化者),因此 `url.*.insteadOf` 重写不会改变映射结果
248
+ - scheme 形式经 WHATWG URL 归一化(IDN host 转 punycode、应用百分号编码与 `.`/`..` 折叠、凭据/query/fragment 被剔除),scp 形式则保留原样路径 —— 因此同一仓库的不同写法可能落到不同目录
249
+ - 其余情况 —— 非 git 目录、无 remote 的 git 仓库、`file://` 或本地路径 remote → `local/<绝对路径>`(git 仓库取仓库根目录;Windows 风格盘符 remote 如 `C:/repos/foo.git` 在 POSIX 上按 scp 式远端处理,与 git 行为一致)
250
+ - `/` 转为 `__`;文件名不安全的字符(`<>:"|?*`、控制字符)转为 `_XX` 十六进制转义
251
+ - 目录名超过 120 UTF-8 字节时按码点截取前 100 字节并追加 `__<hash8>` 后缀
252
+ - 命名面向 POSIX 文件系统:反斜杠视为普通字符,不做 Windows 设备名或结尾点/空格处理
253
+
254
+ 该映射不是单射:下划线保持原样,因此 `/home/a__b` 与 `/home/a/b` 都会映射为 `home__a__b`(共享同一记忆目录)。修改或重命名 remote、新增一个排序在当前使用的 remote 之前的 remote、或移动本地目录,都会改变记忆目录,旧目录将成为孤儿。
255
+
256
+ **旧版布局**:早期版本把记忆存放在 `~/.pi/memory/<12位sha256>/` 下,这些目录不再被读取或写入。如需手动迁移,用 `printf '%s' "$(git rev-parse --show-toplevel)" | sha256sum | cut -c1-12` 计算旧哈希(非 git 项目改用 `$PWD`),把对应目录 `mv` 到新路径(在项目里执行 `/memory` 可查看新路径)。
240
257
 
241
258
  ## 快照语义
242
259
 
package/index.ts CHANGED
@@ -46,7 +46,7 @@ export default function (pi: ExtensionAPI) {
46
46
  config = await loadConfig(ctx);
47
47
  if (!config.enabled) return;
48
48
  memoryDir = await resolveMemoryDir(config, ctx.cwd);
49
- indexSnapshot = await loadIndexSnapshot(memoryDir, config.memIndexMaxLines, config.memIndexMaxBytes);
49
+ indexSnapshot = await loadIndexSnapshot(memoryDir, config.memIndexInjectMaxLines, config.memIndexInjectMaxBytes);
50
50
 
51
51
  // register memory tool once
52
52
  if (!toolRegistered) {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.3",
6
+ "version": "1.4.0",
7
7
  "description": "File-system driven persistent memory layer for pi coding agent",
8
8
  "license": "MIT",
9
9
  "repository": {
@@ -38,6 +38,10 @@
38
38
  "typebox": "*"
39
39
  },
40
40
  "devDependencies": {
41
- "typebox": "^1.3.3"
41
+ "@biomejs/biome": "^2.5.14",
42
+ "@types/node": "^26.6.2",
43
+ "typebox": "^1.3.34",
44
+ "typescript": "~7.0.2",
45
+ "vitest": "^5.0.1"
42
46
  }
43
47
  }
package/src/config.ts CHANGED
@@ -9,7 +9,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
9
9
  export interface SessionPersistenceConfig {
10
10
  /** Enable disk persistence (default: false = in-memory). */
11
11
  enabled: boolean;
12
- /** Custom session directory. Defaults to memoryDir/sessions/. */
12
+ /** Custom session directory. Defaults to `<project memory dir>/sessions/` (the resolved per-project directory under `memoryDir`). */
13
13
  sessionDir?: string;
14
14
  }
15
15
 
@@ -40,8 +40,14 @@ export interface MemoryConfig {
40
40
  /** Shared defaults for model and sessionPersistence. Per-task configs override. */
41
41
  defaults?: DefaultsConfig;
42
42
  memoryDir: string;
43
+ /** Write capacity: max entries in MEMORY.md index. */
43
44
  memIndexMaxLines: number;
45
+ /** Write capacity: max bytes of serialized MEMORY.md index. */
44
46
  memIndexMaxBytes: number;
47
+ /** Injection truncation: max lines of MEMORY.md injected into system prompt. */
48
+ memIndexInjectMaxLines: number;
49
+ /** Injection truncation: max bytes of MEMORY.md injected into system prompt. */
50
+ memIndexInjectMaxBytes: number;
45
51
  dream: {
46
52
  nudgeAfterSessions: number;
47
53
  nudgeAfterHours: number;
@@ -63,14 +69,16 @@ export const DEFAULT_CONFIG: MemoryConfig = {
63
69
  memoryDir: join(homedir(), CONFIG_DIR_NAME, "memory"),
64
70
  memIndexMaxLines: 200,
65
71
  memIndexMaxBytes: 25600,
72
+ memIndexInjectMaxLines: 20,
73
+ memIndexInjectMaxBytes: 3072,
66
74
  dream: { nudgeAfterSessions: 5, nudgeAfterHours: 24, thinkLevel: "high" },
67
75
  sessionSearch: { maxSessions: 10, maxMatches: 5 },
68
76
  autoSurfacing: {
69
77
  enabled: true,
70
78
  thinkLevel: "off",
71
- maxFiles: 5,
72
- maxTopicBytes: 4096,
73
- maxInjectionBytes: 20480,
79
+ maxFiles: 3,
80
+ maxTopicBytes: 3072,
81
+ maxInjectionBytes: 10240,
74
82
  },
75
83
  extractMemories: {
76
84
  enabled: true,
package/src/paths.ts CHANGED
@@ -4,24 +4,176 @@ import { join, normalize, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
 
6
6
  const execFileP = promisify(execFile);
7
+ const GIT_TIMEOUT_MS = 3000;
7
8
 
8
9
  async function gitToplevel(cwd: string): Promise<string | null> {
9
10
  try {
10
- const { stdout } = await execFileP("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 3000 });
11
+ const { stdout } = await execFileP("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: GIT_TIMEOUT_MS });
11
12
  return stdout.trim() || null;
12
13
  } catch {
13
14
  return null;
14
15
  }
15
16
  }
16
17
 
17
- export async function projectHash(cwd: string): Promise<string> {
18
- const key = (await gitToplevel(cwd)) ?? resolve(cwd);
19
- return createHash("sha256").update(key).digest("hex").slice(0, 12);
18
+ const DIR_NAME_MAX_BYTES = 120;
19
+ const DIR_NAME_KEEP_BYTES = 100;
20
+ const HASH_LENGTH = 8;
21
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching C0 control characters for path escaping
22
+ const ILLEGAL_SEGMENT_CHARS = /[<>:"|?*\x00-\x1f]/g;
23
+
24
+ /** Escape characters that are unsafe in a single filesystem path segment. */
25
+ function escapeSegment(segment: string): string {
26
+ return segment.replace(ILLEGAL_SEGMENT_CHARS, (ch) => `_${ch.charCodeAt(0).toString(16).padStart(2, "0")}`);
27
+ }
28
+
29
+ /** Truncate to at most `maxBytes` UTF-8 bytes on grapheme-cluster boundaries. */
30
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", { granularity: "grapheme" });
31
+
32
+ function truncateToBytes(input: string, maxBytes: number): string {
33
+ let kept = "";
34
+ for (const { segment } of GRAPHEME_SEGMENTER.segment(input)) {
35
+ if (Buffer.byteLength(kept + segment, "utf8") > maxBytes) break;
36
+ kept += segment;
37
+ }
38
+ return kept;
39
+ }
40
+
41
+ /**
42
+ * 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.
46
+ */
47
+ export function projectDirName(key: string): string {
48
+ const segments = key
49
+ .split("/")
50
+ .filter((segment) => segment !== "" && segment !== "." && segment !== "..")
51
+ .map(escapeSegment);
52
+ if (segments.length === 0) return "root";
53
+ const joined = segments.join("__");
54
+ // Filesystems cap one name at 255 bytes: count bytes so multi-byte names
55
+ // (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}`;
59
+ }
60
+
61
+ export type ProjectKind = "git" | "local";
62
+
63
+ export interface ProjectIdentity {
64
+ kind: ProjectKind;
65
+ /** git: normalized remote as `host/path`; local: absolute project root */
66
+ key: string;
67
+ }
68
+
69
+ const GIT_PROTOCOLS = new Set(["http", "https", "ssh", "git"]);
70
+ const SCHEME_URL = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
71
+
72
+ /** Lowercase and IDN-normalize a host the way the URL API does for https. */
73
+ function normalizeHost(host: string): string {
74
+ try {
75
+ return new URL(`https://${host}`).hostname;
76
+ } catch {
77
+ return host.toLowerCase();
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Normalize an http(s)/ssh/git remote URL to `host/path`.
83
+ * 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.
85
+ */
86
+ export function normalizeRemoteUrl(url: string): string | null {
87
+ const raw = url.trim();
88
+ if (!raw) return null;
89
+
90
+ let host: string;
91
+ let path: string;
92
+ if (SCHEME_URL.test(raw)) {
93
+ let parsed: URL;
94
+ try {
95
+ parsed = new URL(raw);
96
+ } catch {
97
+ return null;
98
+ }
99
+ const scheme = parsed.protocol.slice(0, -1).toLowerCase().replace(/^git\+/, "");
100
+ if (!GIT_PROTOCOLS.has(scheme)) return null;
101
+ // Non-special schemes (ssh, git) keep an opaque, un-normalized host.
102
+ host = normalizeHost(parsed.hostname);
103
+ path = parsed.pathname;
104
+ } else {
105
+ // scp-style `[user@]host:path` — git-specific syntax, no stdlib parser.
106
+ // Split at the first `:` outside a bracketed IPv6 literal.
107
+ const open = raw.indexOf("[");
108
+ const close = open === -1 ? -1 : raw.indexOf("]", open);
109
+ if (open !== -1 && close === -1) return null;
110
+ const colon = raw.indexOf(":", close === -1 ? 0 : close + 1);
111
+ if (colon === -1) return null;
112
+ const authority = raw.slice(0, colon);
113
+ if (!authority || authority.includes("/")) return null;
114
+ const at = authority.lastIndexOf("@");
115
+ host = normalizeHost(at === -1 ? authority : authority.slice(at + 1));
116
+ path = raw.slice(colon + 1);
117
+ }
118
+
119
+ if (!host) return null;
120
+ path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
121
+ if (!path) return null;
122
+ return `${host}/${path}`;
123
+ }
124
+
125
+ interface RemoteEntry {
126
+ name: string;
127
+ url: string;
128
+ }
129
+
130
+ /**
131
+ * Read every remote URL from the raw git config, in identity order:
132
+ * `origin` first, then alphabetical. `url.*.insteadOf` rewrites do not apply here.
133
+ */
134
+ async function gitRemoteUrls(cwd: string): Promise<RemoteEntry[]> {
135
+ try {
136
+ const { stdout } = await execFileP("git", ["config", "--get-regexp", "^remote\\..*\\.url$"], {
137
+ cwd,
138
+ timeout: GIT_TIMEOUT_MS,
139
+ });
140
+ const byName = new Map<string, string>();
141
+ for (const line of stdout.split("\n")) {
142
+ const sep = line.indexOf(" ");
143
+ if (sep === -1) continue;
144
+ const key = line.slice(0, sep);
145
+ if (!key.startsWith("remote.") || !key.endsWith(".url")) continue;
146
+ const name = key.slice("remote.".length, -".url".length);
147
+ const url = line.slice(sep + 1).trim();
148
+ // First URL per remote wins, matching git's fetch-URL precedence.
149
+ if (!name || !url || byName.has(name)) continue;
150
+ byName.set(name, url);
151
+ }
152
+ const entries = [...byName].map(([name, url]) => ({ name, url }));
153
+ entries.sort((a, b) => {
154
+ if ((a.name === "origin") !== (b.name === "origin")) return a.name === "origin" ? -1 : 1;
155
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
156
+ });
157
+ return entries;
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ /** Resolve the project identity: normalized git remote, or the project root path. */
164
+ export async function projectIdentity(cwd: string): Promise<ProjectIdentity> {
165
+ const toplevel = await gitToplevel(cwd);
166
+ if (!toplevel) return { kind: "local", key: resolve(cwd) };
167
+ for (const { url } of await gitRemoteUrls(cwd)) {
168
+ const key = normalizeRemoteUrl(url);
169
+ if (key) return { kind: "git", key };
170
+ }
171
+ return { kind: "local", key: resolve(toplevel) };
20
172
  }
21
173
 
22
174
  export async function resolveMemoryDir(config: { memoryDir: string }, cwd: string): Promise<string> {
23
- const hash = await projectHash(cwd);
24
- return join(config.memoryDir, hash);
175
+ const { kind, key } = await projectIdentity(cwd);
176
+ return join(config.memoryDir, kind, projectDirName(key));
25
177
  }
26
178
 
27
179
  export function safeTopicPath(memoryDir: string, topic: string): string {