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/LICENSE +21 -0
- package/README.md +209 -0
- package/README.zh.md +187 -0
- package/cordis.patch.yml +21 -0
- package/package.json +53 -0
- package/src/index.js +277 -0
- package/src/paths.js +315 -0
- package/src/redact.js +122 -0
- package/src/render.js +161 -0
- package/src/store.js +218 -0
- package/src/tool.js +206 -0
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
|
+
}
|