dsh-claude-memory 0.1.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/src/index.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * dsh-claude-memory — surface Claude Code's existing memory inside DeepSeek Harness.
3
+ *
4
+ * Why this exists: when a Claude Code quota runs out mid-task, the next agent
5
+ * should be able to pick the work up. Claude Code already wrote down what it
6
+ * learned as markdown under `~/.claude/projects/<key>/memory/`. This plugin
7
+ * reads that store and puts a bounded, redacted view of it in front of the DSH
8
+ * model, plus a tool to open any memory on demand.
9
+ *
10
+ * Design constraints, in order:
11
+ * 1. **Read-only.** Nothing under `~/.claude` is ever written or moved.
12
+ * 2. **Confined.** Every read resolves through `realpath` and must land inside
13
+ * the Claude Code home, so a symlinked memory file cannot escape.
14
+ * 3. **Redacted.** Memory notes on this machine contain live credentials, and
15
+ * DSH sends context to a different provider than Claude Code does. Every
16
+ * contributed byte passes the redactor.
17
+ * 4. **Small by default.** The system prompt carries only the index; full
18
+ * bodies arrive through the tool, one file at a time.
19
+ * 5. **Zero dependencies.** No `@deepseek-ai/*` import, so the plugin resolves
20
+ * in a profile whose `node_modules` is empty, and the audit surface stays
21
+ * one small tree.
22
+ *
23
+ * Two harness contracts shape the implementation:
24
+ *
25
+ * - Prompt providers are **synchronous**: `dsh-system-prompt` resolves `text`
26
+ * without awaiting it (`lib/index.js:330,337`), so this plugin keeps a
27
+ * synchronously refreshed cache and never returns a promise from `text`.
28
+ * - The directory that matters is the **session's** working directory, not the
29
+ * server process's. `process.cwd()` is wherever `dsh` was launched, so the
30
+ * plugin registers global sections as a fallback and shadows them per agent
31
+ * from `agent/created` using `agent.session.header.cwd` — the same source the
32
+ * first-party `dsh-agent-instructions` loader reads (`lib/index.js:1111`).
33
+ *
34
+ * @module dsh-claude-memory
35
+ */
36
+
37
+ import { resolve } from 'node:path'
38
+ import { formatIndexDate, findGitRoot, listMemoryProjects, resolveClaudeHome, selectProjects } from './paths.js'
39
+ import { readInstructionsWithImports, readProjectIndex } from './store.js'
40
+ import { renderGlobalBlock, renderMemoryBlock } from './render.js'
41
+ import { redactText } from './redact.js'
42
+ import { createClaudeMemoryTool } from './tool.js'
43
+
44
+ export const name = 'claude-memory'
45
+
46
+ /** Services this plugin needs; absent, the plugin must not activate. */
47
+ export const inject = ['systemPrompt', 'tools']
48
+
49
+ /** Prompt placement: just after the deployment persona, before tool prose. */
50
+ const SECTION_ORDER = { global: 10, memory: 11 }
51
+
52
+ /** Maximum distinct working directories cached at once. */
53
+ const MAX_CACHES = 32
54
+
55
+ const DEFAULTS = {
56
+ includeDescendants: true,
57
+ descendantLimit: 12,
58
+ maxIndexBytes: 24000,
59
+ maxGlobalBytes: 6000,
60
+ enableGlobalInstructions: true,
61
+ enableMemory: true,
62
+ enableTool: true,
63
+ redactMode: 'on',
64
+ refreshMs: 20000,
65
+ }
66
+
67
+ /** Coerce a config value to a boolean with a default. */
68
+ function bool(value, fallback) {
69
+ return typeof value === 'boolean' ? value : fallback
70
+ }
71
+
72
+ /** Coerce a config value to a finite positive number with a default. */
73
+ function num(value, fallback) {
74
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
75
+ }
76
+
77
+ /**
78
+ * Normalize raw plugin configuration.
79
+ *
80
+ * @param {object} raw - configuration from the profile patch.
81
+ * @returns {object} fully defaulted configuration.
82
+ */
83
+ function normalizeConfig(raw = {}) {
84
+ const redactMode = ['on', 'report', 'off'].includes(raw.redactMode) ? raw.redactMode : DEFAULTS.redactMode
85
+ return {
86
+ claudeHome: resolveClaudeHome(raw),
87
+ // Fallback only: a live agent's session cwd wins over this.
88
+ cwd: resolve(String(raw.cwd ?? process.cwd())),
89
+ includeDescendants: bool(raw.includeDescendants, DEFAULTS.includeDescendants),
90
+ descendantLimit: num(raw.descendantLimit, DEFAULTS.descendantLimit),
91
+ maxIndexBytes: num(raw.maxIndexBytes, DEFAULTS.maxIndexBytes),
92
+ maxGlobalBytes: num(raw.maxGlobalBytes, DEFAULTS.maxGlobalBytes),
93
+ enableGlobalInstructions: bool(raw.enableGlobalInstructions, DEFAULTS.enableGlobalInstructions),
94
+ enableMemory: bool(raw.enableMemory, DEFAULTS.enableMemory),
95
+ enableTool: bool(raw.enableTool, DEFAULTS.enableTool),
96
+ redactMode,
97
+ refreshMs: num(raw.refreshMs, DEFAULTS.refreshMs),
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Read the working directory recorded on an agent's session.
103
+ *
104
+ * @param {object} agent - a live DSH agent.
105
+ * @returns {string|null} absolute cwd, or null when unavailable.
106
+ */
107
+ function agentCwd(agent) {
108
+ const cwd = agent?.session?.header?.cwd
109
+ return typeof cwd === 'string' && cwd.length > 0 ? resolve(cwd) : null
110
+ }
111
+
112
+ /**
113
+ * Register the plugin.
114
+ *
115
+ * @param {object} ctx - Cordis context carrying `systemPrompt` and `tools`.
116
+ * @param {object} [rawConfig] - profile configuration.
117
+ */
118
+ export function apply(ctx, rawConfig = {}) {
119
+ const config = normalizeConfig(rawConfig)
120
+
121
+ /** Per-working-directory render cache: cwd → block state. */
122
+ const caches = new Map()
123
+
124
+ /** Create (or fetch) the cache entry for one working directory. */
125
+ function cacheFor(cwd) {
126
+ let entry = caches.get(cwd)
127
+ if (entry !== undefined) return entry
128
+ if (caches.size >= MAX_CACHES) {
129
+ const oldest = caches.keys().next()
130
+ if (oldest.done !== true) caches.delete(oldest.value)
131
+ }
132
+ entry = { memoryBlock: '', globalBlock: '', projects: [], currentKey: null, lastRefreshAt: 0, error: null }
133
+ caches.set(cwd, entry)
134
+ return entry
135
+ }
136
+
137
+ /** Refresh one working directory's cache when it is older than `refreshMs`. */
138
+ function refreshIfStale(cwd) {
139
+ const entry = cacheFor(cwd)
140
+ if (Date.now() - entry.lastRefreshAt >= config.refreshMs) refresh(cwd)
141
+ return entry
142
+ }
143
+
144
+ /** Re-read the store for one working directory and rebuild both blocks. */
145
+ function refresh(cwd) {
146
+ const entry = cacheFor(cwd)
147
+ entry.lastRefreshAt = Date.now()
148
+ try {
149
+ const projects = listMemoryProjects(config.claudeHome)
150
+ // Claude Code files memory under the enclosing git repository root, so a
151
+ // subdirectory of a repo must resolve to the same project Claude Code used.
152
+ const gitRoot = findGitRoot(cwd)
153
+ const { current, related, match } = selectProjects({
154
+ projects,
155
+ cwd,
156
+ gitRoot,
157
+ includeDescendants: config.includeDescendants,
158
+ descendantLimit: config.descendantLimit,
159
+ })
160
+
161
+ // Annotate entry counts and dates so the catalog and the "other projects"
162
+ // list are useful without reading every index body.
163
+ const annotated = projects.map((p) => {
164
+ const read = readProjectIndex(config.claudeHome, p)
165
+ return { ...p, entries: read !== null ? read.entries : 0, date: formatIndexDate(p.mtimeMs) }
166
+ })
167
+ const annotatedByKey = new Map(annotated.map((p) => [p.key, p]))
168
+ const currentProject = current === null ? null : annotatedByKey.get(current.key) ?? current
169
+ const relatedProjects = related.map((p) => annotatedByKey.get(p.key) ?? p)
170
+
171
+ if (config.enableMemory) {
172
+ const index = currentProject === null ? null : readProjectIndex(config.claudeHome, currentProject)
173
+ const rawIndex = index === null ? null : { text: redactText(index.text, { mode: config.redactMode }) }
174
+ const redactedIndex = rawIndex === null ? null : { ...index, text: rawIndex.text.text }
175
+ const hits = rawIndex === null ? {} : rawIndex.text.hits
176
+ entry.memoryBlock = renderMemoryBlock({
177
+ project: currentProject,
178
+ index: redactedIndex,
179
+ related: relatedProjects,
180
+ all: annotated,
181
+ match,
182
+ cwd,
183
+ gitRoot,
184
+ maxBytes: config.maxIndexBytes,
185
+ hits,
186
+ })
187
+ } else {
188
+ entry.memoryBlock = ''
189
+ }
190
+
191
+ if (config.enableGlobalInstructions) {
192
+ const instructions = readInstructionsWithImports(config.claudeHome, resolve(config.claudeHome, 'CLAUDE.md'))
193
+ const redacted = instructions === null ? null : redactText(instructions.text, { mode: config.redactMode })
194
+ entry.globalBlock = renderGlobalBlock(
195
+ instructions === null || redacted === null ? null : { ...instructions, text: redacted.text },
196
+ config.maxGlobalBytes,
197
+ redacted === null ? {} : redacted.hits,
198
+ )
199
+ } else {
200
+ entry.globalBlock = ''
201
+ }
202
+
203
+ entry.projects = annotated
204
+ entry.currentKey = currentProject === null ? null : currentProject.key
205
+ entry.error = null
206
+ } catch (error) {
207
+ entry.error = error instanceof Error ? error.message : String(error)
208
+ entry.memoryBlock = ''
209
+ entry.globalBlock = ''
210
+ }
211
+ }
212
+
213
+ /** Register the two sections into one prompt registry (global or agent-scoped). */
214
+ function registerSections(systemPrompt, cwd) {
215
+ systemPrompt.section({
216
+ name: 'claude-memory:global',
217
+ order: SECTION_ORDER.global,
218
+ text: () => refreshIfStale(cwd).globalBlock,
219
+ })
220
+ systemPrompt.section({
221
+ name: 'claude-memory:memory',
222
+ order: SECTION_ORDER.memory,
223
+ text: () => refreshIfStale(cwd).memoryBlock,
224
+ })
225
+ }
226
+
227
+ // Seed the fallback before the first request so the first prompt is complete.
228
+ refresh(config.cwd)
229
+ registerSections(ctx.systemPrompt, config.cwd)
230
+
231
+ // A session's working directory is authoritative and differs from the server's
232
+ // cwd. Agent-scoped sections shadow the global ones with the same name.
233
+ let shadowed = 0
234
+ ctx.on('agent/created', ({ agent }) => {
235
+ const cwd = agentCwd(agent)
236
+ if (cwd === null || cwd === config.cwd) return
237
+ try {
238
+ registerSections(agent.ctx.systemPrompt, cwd)
239
+ shadowed += 1
240
+ } catch (error) {
241
+ ctx.logger?.warn?.(
242
+ `[claude-memory] could not register agent-scoped sections for ${cwd}: ${error?.message ?? error}`,
243
+ )
244
+ }
245
+ })
246
+
247
+ if (config.enableTool) {
248
+ const tool = createClaudeMemoryTool({
249
+ claudeHome: config.claudeHome,
250
+ redactMode: config.redactMode,
251
+ // Resolve against the calling agent's session so a subagent or a session
252
+ // started elsewhere sees its own project's memory.
253
+ cwd: (exec) => agentCwd(exec?.agent) ?? config.cwd,
254
+ projects: (cwd) => refreshIfStale(cwd).projects,
255
+ current: (cwd) => {
256
+ const entry = refreshIfStale(cwd)
257
+ if (entry.currentKey === null) return null
258
+ return entry.projects.find((p) => p.key === entry.currentKey) ?? null
259
+ },
260
+ })
261
+ try {
262
+ ctx.tools.register(tool)
263
+ } catch (error) {
264
+ ctx.logger?.warn?.(`[claude-memory] could not register ${tool.name}: ${error?.message ?? error}`)
265
+ }
266
+ }
267
+
268
+ ctx.logger?.debug?.(
269
+ `[claude-memory] home=${config.claudeHome} fallbackCwd=${config.cwd} ` +
270
+ `projects=${cacheFor(config.cwd).projects.length}`,
271
+ )
272
+
273
+ /** Test hook: inspect caches and how many agents shadowed the global sections. */
274
+ apply.__debug = { caches, shadowedCount: () => shadowed }
275
+ }
276
+
277
+ export default { name, inject, apply }
package/src/paths.js ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Claude Code project-path encoding and memory-directory discovery.
3
+ *
4
+ * Claude Code stores per-project state under `~/.claude/projects/<key>/`, where
5
+ * `<key>` is the absolute project path with `:` removed and every `/` or `\`
6
+ * replaced by `-`. The encoding is lossy (a real `-` in a path is
7
+ * indistinguishable from a separator), so this module never decodes a key to
8
+ * make security decisions — it only decodes for human-readable display.
9
+ *
10
+ * @module dsh-claude-memory/paths
11
+ */
12
+
13
+ import { homedir } from 'node:os'
14
+ import { basename, dirname, join, resolve, sep } from 'node:path'
15
+ import { existsSync, realpathSync } from 'node:fs'
16
+ import { readdirSync, statSync } from 'node:fs'
17
+
18
+ /** Directory that holds one Claude Code memory set. */
19
+ export const MEMORY_DIRNAME = 'memory'
20
+
21
+ /** Index file inside a memory directory. */
22
+ export const MEMORY_INDEX_FILENAME = 'MEMORY.md'
23
+
24
+ /**
25
+ * Encode an absolute filesystem path into a Claude Code project key.
26
+ *
27
+ * `C:\Users\me` → `C-Users-me`, `/Users/me/proj` → `-Users-me-proj`.
28
+ *
29
+ * @param {string} absolutePath - absolute path to encode.
30
+ * @returns {string} the encoded project key.
31
+ */
32
+ export function encodeProjectKey(absolutePath) {
33
+ return absolutePath.replace(/:/g, '').replace(/[/\\]/g, '-')
34
+ }
35
+
36
+ /**
37
+ * Resolve the Claude Code home directory from configuration.
38
+ *
39
+ * @param {{claudeHome?: string}} config - plugin configuration.
40
+ * @returns {string} absolute Claude Code home.
41
+ */
42
+ export function resolveClaudeHome(config = {}) {
43
+ const raw = config.claudeHome ?? join(homedir(), '.claude')
44
+ return resolve(expandHome(raw))
45
+ }
46
+
47
+ /**
48
+ * Expand a leading `~` to the current user's home directory.
49
+ *
50
+ * @param {string} p - possibly home-relative path.
51
+ * @returns {string} expanded path.
52
+ */
53
+ export function expandHome(p) {
54
+ if (p === '~') return homedir()
55
+ if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2))
56
+ return p
57
+ }
58
+
59
+ /**
60
+ * Assert that a resolved path stays inside a root directory.
61
+ *
62
+ * Compares real (symlink-resolved) paths so a symlink cannot smuggle a target
63
+ * out of the tree. Returns the real path when it is confined.
64
+ *
65
+ * @param {string} root - directory that must contain the target.
66
+ * @param {string} target - path to check.
67
+ * @returns {string|null} the real path, or null when it escapes or is missing.
68
+ */
69
+ export function confinedRealPath(root, target) {
70
+ let realRoot
71
+ try {
72
+ realRoot = realpathSync(root)
73
+ } catch {
74
+ return null
75
+ }
76
+ let realTarget
77
+ try {
78
+ realTarget = realpathSync(target)
79
+ } catch {
80
+ return null
81
+ }
82
+ if (realTarget === realRoot) return realTarget
83
+ return realTarget.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep) ? realTarget : null
84
+ }
85
+
86
+ /**
87
+ * Count index entries in a `MEMORY.md` body.
88
+ *
89
+ * Claude Code's index is a list of `- [title](file.md) — summary` lines; other
90
+ * lines (headings, blanks) are ignored.
91
+ *
92
+ * @param {string} text - index file contents.
93
+ * @returns {number} number of pointer lines.
94
+ */
95
+ export function countIndexEntries(text) {
96
+ let n = 0
97
+ for (const line of text.split('\n')) {
98
+ if (/^\s*[-*]\s+\[[^\]]+\]\([^)]+\)/.test(line)) n += 1
99
+ }
100
+ return n
101
+ }
102
+
103
+ /**
104
+ * List every project that has a memory directory under a Claude Code home.
105
+ *
106
+ * A project is included only when `<home>/projects/<key>/memory/MEMORY.md`
107
+ * exists and is a regular file inside the home tree. The scan is one level deep
108
+ * and never follows a memory directory out of the tree.
109
+ *
110
+ * @param {string} claudeHome - resolved Claude Code home.
111
+ * @returns {Array<{key: string, memoryDir: string, indexFile: string, mtimeMs: number, size: number, entries: number}>}
112
+ */
113
+ export function listMemoryProjects(claudeHome) {
114
+ const projectsRoot = join(claudeHome, 'projects')
115
+ let keys
116
+ try {
117
+ keys = readdirSync(projectsRoot, { withFileTypes: true })
118
+ } catch {
119
+ return []
120
+ }
121
+
122
+ const found = []
123
+ for (const dirent of keys) {
124
+ if (!dirent.isDirectory()) continue
125
+ const key = dirent.name
126
+ const memoryDir = join(projectsRoot, key, MEMORY_DIRNAME)
127
+ const indexFile = join(memoryDir, MEMORY_INDEX_FILENAME)
128
+
129
+ // The index must resolve inside the Claude home; this rejects a symlinked
130
+ // memory directory pointing somewhere else on disk.
131
+ const real = confinedRealPath(claudeHome, indexFile)
132
+ if (real === null) continue
133
+
134
+ let stat
135
+ try {
136
+ stat = statSync(real)
137
+ } catch {
138
+ continue
139
+ }
140
+ if (!stat.isFile()) continue
141
+
142
+ found.push({
143
+ key,
144
+ memoryDir,
145
+ indexFile,
146
+ mtimeMs: stat.mtimeMs,
147
+ size: stat.size,
148
+ entries: 0, // filled lazily by the store when the index is read
149
+ })
150
+ }
151
+
152
+ found.sort((a, b) => a.key.localeCompare(b.key))
153
+ return found
154
+ }
155
+
156
+ /**
157
+ * Find the enclosing git repository root for a directory.
158
+ *
159
+ * Claude Code files memory under the **git repository root**, not the raw
160
+ * working directory: a session launched in a subdirectory that has no `.git` of
161
+ * its own (for example a docs hub inside a larger repo) writes to the enclosing
162
+ * repo's key, not the subdirectory's. Both a `.git` directory and a `.git` file
163
+ * (worktrees and submodules) mark a root.
164
+ *
165
+ * @param {string} startDir - directory to walk up from.
166
+ * @returns {string|null} the repository root, or null when there is none.
167
+ */
168
+ export function findGitRoot(startDir) {
169
+ let current = resolve(expandHome(startDir))
170
+ for (;;) {
171
+ if (existsSync(join(current, '.git'))) return current
172
+ const parent = dirname(current)
173
+ if (parent === current) return null
174
+ current = parent
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Select the project whose memory is most relevant to a working directory.
180
+ *
181
+ * Resolution order mirrors how Claude Code itself files memory:
182
+ * 1. **exact** — the encoded key for `cwd` has memory;
183
+ * 2. **git-root** — the encoded key for the enclosing git repository root has
184
+ * memory (the common case for a subdirectory of a repo);
185
+ * 3. **freshest** — otherwise, the most recently updated memory set among the
186
+ * containing ancestors and the child projects.
187
+ *
188
+ * Rule 3 is deliberately recency-based rather than "nearest ancestor": a hub
189
+ * directory's own memory is often stale while the work that just ran is in one
190
+ * of its children. The chosen rule is reported back so the model knows whether
191
+ * the memory is certain or a best guess.
192
+ *
193
+ * @param {object} options - selection inputs.
194
+ * @param {Array<object>} options.projects - result of {@link listMemoryProjects}.
195
+ * @param {string} options.cwd - session working directory.
196
+ * @param {string|null} [options.gitRoot] - enclosing git root, from {@link findGitRoot}.
197
+ * @param {boolean} [options.includeDescendants] - also return sibling candidates.
198
+ * @param {number} [options.descendantLimit] - maximum other candidates to return.
199
+ * @returns {{current: object|null, related: Array<object>, match: 'exact'|'git-root'|'freshest'|'none'}} selection.
200
+ */
201
+ export function selectProjects({
202
+ projects,
203
+ cwd,
204
+ gitRoot = null,
205
+ includeDescendants = true,
206
+ descendantLimit = 12,
207
+ }) {
208
+ const cwdKey = encodeProjectKey(resolve(expandHome(cwd)))
209
+ const byKey = new Map(projects.map((p) => [p.key, p]))
210
+
211
+ const exact = byKey.get(cwdKey) ?? null
212
+ const descendants = projects.filter((p) => p.key.startsWith(cwdKey + '-'))
213
+ const ancestors = projects.filter((p) => cwdKey.startsWith(p.key + '-'))
214
+
215
+ const byRecency = (a, b) => b.mtimeMs - a.mtimeMs || a.key.localeCompare(b.key)
216
+
217
+ const relatedFor = () =>
218
+ includeDescendants ? descendants.sort(byRecency).slice(0, Math.max(0, descendantLimit)) : []
219
+
220
+ if (exact !== null) return { current: exact, related: relatedFor(), match: 'exact' }
221
+
222
+ if (gitRoot !== null) {
223
+ const gitKey = encodeProjectKey(resolve(expandHome(gitRoot)))
224
+ const fromRoot = byKey.get(gitKey) ?? null
225
+ if (fromRoot !== null) {
226
+ return {
227
+ current: fromRoot,
228
+ related: includeDescendants
229
+ ? [...ancestors, ...descendants]
230
+ .filter((p) => p.key !== fromRoot.key)
231
+ .sort(byRecency)
232
+ .slice(0, Math.max(0, descendantLimit))
233
+ : [],
234
+ match: 'git-root',
235
+ }
236
+ }
237
+ }
238
+
239
+ const candidates = [...ancestors, ...descendants].sort(byRecency)
240
+ if (candidates.length === 0) return { current: null, related: [], match: 'none' }
241
+
242
+ const [current, ...rest] = candidates
243
+ return {
244
+ current,
245
+ related: includeDescendants ? rest.slice(0, Math.max(0, descendantLimit)) : [],
246
+ match: 'freshest',
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Format an index mtime as a short local date for prompt display.
252
+ *
253
+ * @param {number} mtimeMs - modification time in milliseconds.
254
+ * @returns {string} `YYYY-MM-DD`, or `?` when unknown.
255
+ */
256
+ export function formatIndexDate(mtimeMs) {
257
+ if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs) || mtimeMs <= 0) return '?'
258
+ const d = new Date(mtimeMs)
259
+ const pad = (n) => String(n).padStart(2, '0')
260
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
261
+ }
262
+
263
+ /**
264
+ * Recover the original directory path from a project key by walking the filesystem.
265
+ *
266
+ * The encoding is lossy — `-` is both the separator and a legal character in a
267
+ * directory name — so a naive decode turns `...-agent-harness-quality-pilot`
268
+ * into `.../agent/harness/quality/pilot`. Walking the real tree and preferring
269
+ * the longest matching join recovers the true path when it still exists.
270
+ *
271
+ * @param {string} key - encoded project key.
272
+ * @returns {string|null} the real directory, or null when it cannot be recovered.
273
+ */
274
+ export function resolveKeyToPath(key) {
275
+ if (!key.startsWith('-')) return null // Windows drive keys have no leading dash
276
+ const segments = key.slice(1).split('-')
277
+ let current = sep
278
+ let index = 0
279
+
280
+ while (index < segments.length) {
281
+ let matched = false
282
+ for (let take = segments.length - index; take >= 1; take -= 1) {
283
+ const candidate = join(current, segments.slice(index, index + take).join('-'))
284
+ try {
285
+ if (statSync(candidate).isDirectory()) {
286
+ current = candidate
287
+ index += take
288
+ matched = true
289
+ break
290
+ }
291
+ } catch {
292
+ // not a directory: try a shorter join
293
+ }
294
+ }
295
+ if (!matched) return null
296
+ }
297
+
298
+ return current
299
+ }
300
+
301
+ /**
302
+ * Short, stable label for a project key.
303
+ *
304
+ * Prefers the real directory name recovered from disk; falls back to the last
305
+ * two raw key segments, which is the best a lossy encoding allows.
306
+ *
307
+ * @param {string} key - encoded project key.
308
+ * @returns {string} display label.
309
+ */
310
+ export function projectLabel(key) {
311
+ const real = resolveKeyToPath(key)
312
+ if (real !== null) return basename(real) || key
313
+ const segments = key.replace(/^-/, '').split('-')
314
+ return segments.slice(-2).join('-')
315
+ }