claude-code-session-manager 0.113.0 → 0.114.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.
Files changed (45) hide show
  1. package/dist/assets/{AgentLibrary-wvrcLGGS.js → AgentLibrary-DZFM7Lto.js} +1 -1
  2. package/dist/assets/{Button-B6h0F0XT.js → Button-BJVFKdGH.js} +1 -1
  3. package/dist/assets/{DataModel-C5pW9E3u.js → DataModel-CebvcP_y.js} +1 -1
  4. package/dist/assets/{History-BV4e8YOM.js → History-BwISEUhR.js} +1 -1
  5. package/dist/assets/{Hooks-CrFSv4x1.js → Hooks-Dac3smYP.js} +1 -1
  6. package/dist/assets/{Library-D-GIt3EY.js → Library-DTjsUr9W.js} +1 -1
  7. package/dist/assets/{ListDetail-Bco_wfs6.js → ListDetail-B1L5TbjS.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-C-yHSXNV.js → MarkdownEditor-BFRhWZD4.js} +1 -1
  9. package/dist/assets/{McpServers-CfDekadG.js → McpServers-CBjGCMg0.js} +1 -1
  10. package/dist/assets/Memory-DdLi5fqe.js +8 -0
  11. package/dist/assets/{Panel-DGMEuYYG.js → Panel-BQkkkjex.js} +1 -1
  12. package/dist/assets/{Permissions-Cx2zjtYZ.js → Permissions-CaACwY0B.js} +1 -1
  13. package/dist/assets/{Plugins-Bc_W3IyR.js → Plugins-DJeBwVoh.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DiKAUR1u.js → ProvenanceBadge--N5j8xiK.js} +1 -1
  15. package/dist/assets/{RemoteMachines-8_77-WPD.js → RemoteMachines-WjWaDHEA.js} +1 -1
  16. package/dist/assets/{SaveBar-Blpzdq1R.js → SaveBar-DFmOjb4_.js} +1 -1
  17. package/dist/assets/{Scheduler-Bd70AM4X.js → Scheduler-Miap6_aY.js} +1 -1
  18. package/dist/assets/{ScopeSwitcher-wxN0h4YW.js → ScopeSwitcher-CiEujp4L.js} +1 -1
  19. package/dist/assets/{Settings-CQ7DA3zW.js → Settings-UGpW4P0j.js} +1 -1
  20. package/dist/assets/{SkillReferenceGraph-DH1NLOiS.js → SkillReferenceGraph-BgyAnxGW.js} +1 -1
  21. package/dist/assets/{Skills-CmyRyu4_.js → Skills-BQUpnNfx.js} +1 -1
  22. package/dist/assets/{SystemPrompt-KAc3UMCd.js → SystemPrompt-CQjOgHGT.js} +1 -1
  23. package/dist/assets/{TagLibrary-C8EvHnpg.js → TagLibrary-CTVpoLl0.js} +1 -1
  24. package/dist/assets/{TiptapBody-d423mH6D.js → TiptapBody-BtOcrdRR.js} +1 -1
  25. package/dist/assets/{Toggle-D1wsGE9S.js → Toggle-Chri4G19.js} +1 -1
  26. package/dist/assets/{ViewTabs-CjHw3kGY.js → ViewTabs-BIenDGVv.js} +1 -1
  27. package/dist/assets/{historyTotals-DFHzqLt7.js → historyTotals-CX8mUxJY.js} +1 -1
  28. package/dist/assets/{index-Ci1u018F.js → index-AVrhGmUO.js} +86 -86
  29. package/dist/assets/{index-Dutcsly-.css → index-HpEbvDiE.css} +1 -1
  30. package/dist/assets/{settingsSchema-Ctf3pZVI.js → settingsSchema-uqwlznbH.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-B1NBmOAe.js → useAgentsDirWatch-BaezRtWw.js} +1 -1
  32. package/dist/assets/{useRemotePeers-BHjSJs9D.js → useRemotePeers-BuFbBqDt.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/package.json +1 -1
  35. package/src/main/build-info.json +4 -4
  36. package/src/main/index.cjs +0 -2
  37. package/src/main/ipcSchemas.cjs +24 -34
  38. package/src/main/lib/memoryDirResolve.cjs +139 -0
  39. package/src/main/lib/memoryIndex.cjs +98 -0
  40. package/src/main/memoryAggregate.cjs +15 -8
  41. package/src/main/memoryTool.cjs +151 -22
  42. package/src/preload/api.d.ts +41 -52
  43. package/src/preload/index.cjs +19 -21
  44. package/dist/assets/Memory-CGGLexkR.js +0 -8
  45. package/src/main/agentMemory.cjs +0 -246
@@ -0,0 +1,139 @@
1
+ /**
2
+ * memoryDirResolve.cjs — where Claude Code itself keeps memory.
3
+ *
4
+ * Pure resolver (all IO injected) so the Memory tab reads and writes exactly the native locations:
5
+ * - workspace memory: ~/.claude/projects/<encoded git-repo root>/memory/ (all worktrees of a
6
+ * repo share it), unless the `autoMemoryDirectory` setting overrides it.
7
+ * - subagent memory: enabled by a `memory:` frontmatter field on the agent.
8
+ *
9
+ * Top-level in lib/ because it spans settings, git and agent files (no single domain folder fits).
10
+ */
11
+ 'use strict';
12
+
13
+ const path = require('node:path');
14
+ const { encodeCwd } = require('./util/encodeCwd.cjs');
15
+
16
+ const SCOPES = ['user', 'project', 'local'];
17
+ const NAME_RE = /^[A-Za-z0-9._-]{1,128}$/;
18
+
19
+ /** Highest-precedence value (local > project > user) of `key` passing `pred`. */
20
+ function pick(settings, key, pred) {
21
+ for (const layer of ['local', 'project', 'user']) {
22
+ const s = settings && settings[layer];
23
+ if (s && typeof s === 'object' && pred(s[key])) return s[key];
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ function expandDir(value, home, cwd) {
29
+ if (value === '~') return home;
30
+ if (value.startsWith('~/')) return path.join(home, value.slice(2));
31
+ return path.resolve(cwd, value);
32
+ }
33
+
34
+ function resolveWorkspaceMemoryDir({ home, cwd, settings, repoRoot }) {
35
+ const override = pick(settings, 'autoMemoryDirectory', (v) => typeof v === 'string' && v.trim() !== '');
36
+ if (override !== undefined) {
37
+ return { dir: expandDir(override.trim(), home, cwd), source: 'autoMemoryDirectory' };
38
+ }
39
+ return {
40
+ dir: path.join(home, '.claude', 'projects', encodeCwd(repoRoot || cwd), 'memory'),
41
+ source: 'default',
42
+ };
43
+ }
44
+
45
+ function autoMemoryEnabled(settings) {
46
+ return pick(settings, 'autoMemoryEnabled', (v) => typeof v === 'boolean') !== false;
47
+ }
48
+
49
+ function resolveAgentMemoryDir({ home, root, name, scope }) {
50
+ if (typeof name !== 'string' || !NAME_RE.test(name) || name === '.' || name === '..') {
51
+ throw new Error(`invalid agent name: ${JSON.stringify(name)}`);
52
+ }
53
+ if (!SCOPES.includes(scope)) throw new Error(`unknown memory scope: ${JSON.stringify(scope)}`);
54
+ if (scope === 'user') return path.join(home, '.claude', 'agent-memory', name);
55
+ if (typeof root !== 'string' || !root) throw new Error(`scope '${scope}' requires a project root`);
56
+ return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', name);
57
+ }
58
+
59
+ /** Parse the leading `---` block into a flat key -> string map. */
60
+ function parseFrontmatter(text) {
61
+ const out = {};
62
+ if (typeof text !== 'string') return out;
63
+ const lines = text.replace(/^/, '').split(/\r?\n/);
64
+ if (lines[0].trim() !== '---') return out;
65
+ for (let i = 1; i < lines.length; i++) {
66
+ if (lines[i].trim() === '---') return out;
67
+ const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(lines[i]);
68
+ if (m) out[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
69
+ }
70
+ return {}; // unterminated block: not frontmatter
71
+ }
72
+
73
+ function scanAgents(dir, origin, readDir, readText) {
74
+ let names;
75
+ try {
76
+ names = readDir(dir);
77
+ } catch {
78
+ return [];
79
+ }
80
+ const out = [];
81
+ for (const file of names || []) {
82
+ if (typeof file !== 'string' || !file.endsWith('.md')) continue;
83
+ const agentFile = path.join(dir, file);
84
+ let text;
85
+ try {
86
+ text = readText(agentFile);
87
+ } catch {
88
+ continue;
89
+ }
90
+ if (text == null) continue;
91
+ const fm = parseFrontmatter(text);
92
+ out.push({
93
+ name: fm.name || file.slice(0, -3),
94
+ origin,
95
+ memoryScope: SCOPES.includes(fm.memory) ? fm.memory : null,
96
+ agentFile,
97
+ });
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function listMemoryAgents({ home, root, readDir, readText }) {
103
+ const byName = new Map();
104
+ for (const a of scanAgents(path.join(home, '.claude', 'agents'), 'user', readDir, readText)) byName.set(a.name, a);
105
+ if (root) {
106
+ for (const a of scanAgents(path.join(root, '.claude', 'agents'), 'project', readDir, readText)) byName.set(a.name, a);
107
+ }
108
+ return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
109
+ }
110
+
111
+ const rootCache = new Map();
112
+
113
+ function gitRepoRoot(cwd, execFileSync) {
114
+ if (rootCache.has(cwd)) return rootCache.get(cwd);
115
+ let root = null;
116
+ try {
117
+ const out = String(
118
+ execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
119
+ cwd,
120
+ timeout: 5000,
121
+ encoding: 'utf8',
122
+ stdio: ['ignore', 'pipe', 'ignore'],
123
+ }),
124
+ ).trim();
125
+ if (out.endsWith('/.git')) root = out.slice(0, -'/.git'.length) || null;
126
+ } catch {
127
+ root = null;
128
+ }
129
+ rootCache.set(cwd, root);
130
+ return root;
131
+ }
132
+
133
+ module.exports = {
134
+ resolveWorkspaceMemoryDir,
135
+ autoMemoryEnabled,
136
+ resolveAgentMemoryDir,
137
+ listMemoryAgents,
138
+ gitRepoRoot,
139
+ };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure helpers for Claude Code's auto-memory index file (MEMORY.md): one line
5
+ * per memory, `- [Title](file.md) — hook`. Claude Code loads only the first
6
+ * 200 lines / 25KB of it per session, so an unlisted memory is invisible.
7
+ * No fs/electron access; callers pass text in and write text out.
8
+ *
9
+ * Top-level (not in a lib/ subfolder) to sit beside its sibling memoryStale.cjs.
10
+ */
11
+
12
+ const MAX_LINES = 200;
13
+ const MAX_BYTES = 25600;
14
+ const NEAR_LINES = 180;
15
+ const NEAR_BYTES = 23040;
16
+
17
+ // `- [Title](name.md)` with optional ` — hook` / ` - hook`. Bare file names only
18
+ // (no path separators), so links into subfolders or URLs never match.
19
+ const LINE_RE = /^\s*[-*]\s+\[([^\]]*)\]\(([^)\s/\\]+\.md)\)(?:\s+(?:—|-)\s+(.*?))?\s*$/;
20
+
21
+ /** Split into lines; a trailing newline does not add a line. */
22
+ function splitLines(text) {
23
+ if (typeof text !== 'string' || text === '') return [];
24
+ const lines = text.split('\n');
25
+ if (lines[lines.length - 1] === '') lines.pop();
26
+ return lines;
27
+ }
28
+
29
+ function matchLine(raw) {
30
+ return LINE_RE.exec(raw.replace(/\r$/, ''));
31
+ }
32
+
33
+ /**
34
+ * Parse index link lines. Complexity: O(lines).
35
+ * @param {string|null} text
36
+ * @returns {{line: string, lineNo: number, title: string, file: string, hook: string}[]}
37
+ */
38
+ function parseIndex(text) {
39
+ const out = [];
40
+ splitLines(text).forEach((raw, i) => {
41
+ const m = matchLine(raw);
42
+ if (!m) return;
43
+ out.push({ line: raw.replace(/\r$/, ''), lineNo: i + 1, title: m[1], file: m[2], hook: m[3] || '' });
44
+ });
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Append an index line unless `file` is already linked. Complexity: O(lines).
50
+ * @param {string|null} text
51
+ * @param {{file: string, title: string, hook?: string}} entry
52
+ * @returns {string}
53
+ */
54
+ function addIndexLine(text, { file, title, hook }) {
55
+ const base = typeof text === 'string' ? text : '';
56
+ if (parseIndex(base).some((e) => e.file === file)) return base;
57
+ const line = `- [${title}](${file})${hook ? ` — ${hook}` : ''}`;
58
+ const trimmed = base.replace(/(\r?\n)+$/, '');
59
+ return `${trimmed === '' ? '' : `${trimmed}\n`}${line}\n`;
60
+ }
61
+
62
+ /**
63
+ * Remove every line linking to `file`; other lines are untouched.
64
+ * Complexity: O(lines).
65
+ * @param {string|null} text
66
+ * @param {string} file
67
+ * @returns {string}
68
+ */
69
+ function removeIndexLines(text, file) {
70
+ if (typeof text !== 'string' || text === '') return '';
71
+ const parts = text.split('\n');
72
+ const hadTrailing = parts[parts.length - 1] === '';
73
+ if (hadTrailing) parts.pop();
74
+ const kept = parts.filter((raw) => {
75
+ const m = matchLine(raw);
76
+ return !(m && m[2] === file);
77
+ });
78
+ return kept.length === 0 ? '' : kept.join('\n') + (hadTrailing ? '\n' : '');
79
+ }
80
+
81
+ /**
82
+ * Index budget + consistency report. Complexity: O((lines + files) log n).
83
+ * @param {{indexText: string|null, files: string[]}} args
84
+ */
85
+ function indexHealth({ indexText, files }) {
86
+ const text = typeof indexText === 'string' ? indexText : '';
87
+ const lines = splitLines(text).length;
88
+ const bytes = Buffer.byteLength(text, 'utf8');
89
+ const overBudget = lines > MAX_LINES || bytes > MAX_BYTES;
90
+ const nearBudget = !overBudget && (lines >= NEAR_LINES || bytes >= NEAR_BYTES);
91
+ const present = new Set((files || []).filter((f) => f !== 'MEMORY.md'));
92
+ const linked = new Set(parseIndex(text).map((e) => e.file));
93
+ const orphans = [...present].filter((f) => !linked.has(f)).sort();
94
+ const dangling = [...linked].filter((f) => f !== 'MEMORY.md' && !present.has(f)).sort();
95
+ return { lines, bytes, maxLines: MAX_LINES, maxBytes: MAX_BYTES, overBudget, nearBudget, orphans, dangling };
96
+ }
97
+
98
+ module.exports = { parseIndex, addIndexLine, removeIndexLines, indexHealth };
@@ -33,8 +33,15 @@ const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
33
33
  const HOME = os.homedir();
34
34
  const CACHE_WRITER = 'memory-clusters';
35
35
 
36
- function memoryDir(workspace) {
37
- return path.join(HOME, '.claude', 'projects', workspace, 'memory');
36
+ // Resolved through memoryTool.cjs so cwd-addressed callers read Claude Code's
37
+ // native dir (git root + autoMemoryDirectory); no cwd -> legacy workspace dir.
38
+ async function memoryDir(workspace, cwd) {
39
+ const { resolveDir } = require('./memoryTool.cjs');
40
+ try {
41
+ return (await resolveDir({ workspace, cwd })).dir;
42
+ } catch {
43
+ return path.join(HOME, '.claude', 'projects', workspace, 'memory');
44
+ }
38
45
  }
39
46
 
40
47
  function clustersCachePath(cwd) {
@@ -94,15 +101,15 @@ function parseFrontmatter(text) {
94
101
  return { type, description, body: m[2] };
95
102
  }
96
103
 
97
- async function listMemoryFiles(workspace) {
98
- const dir = memoryDir(workspace);
104
+ async function listMemoryFiles(workspace, cwd) {
105
+ const dir = await memoryDir(workspace, cwd);
99
106
  const r = await config.listDir(dir, { filesOnly: true });
100
107
  if (!r.ok) return [];
101
108
  return r.entries.filter((e) => MEMORY_SLUG_RE.test(e.name)).map((e) => e.name).sort();
102
109
  }
103
110
 
104
- async function readMemories(workspace, names) {
105
- const dir = memoryDir(workspace);
111
+ async function readMemories(workspace, names, cwd) {
112
+ const dir = await memoryDir(workspace, cwd);
106
113
  const out = [];
107
114
  for (const name of names) {
108
115
  const abs = path.join(dir, name);
@@ -178,8 +185,8 @@ async function aggregate({ workspace, refresh, cwd }) {
178
185
  return { workspace: ws, generatedAt: null, clusters: [], orphans: [], cached: false };
179
186
  }
180
187
 
181
- const names = await listMemoryFiles(ws);
182
- const memories = await readMemories(ws, names);
188
+ const names = await listMemoryFiles(ws, cwd);
189
+ const memories = await readMemories(ws, names, cwd);
183
190
  const slugs = memories.map((m) => m.slug);
184
191
 
185
192
  let clusters = [];
@@ -32,6 +32,14 @@ const config = require('./config.cjs');
32
32
 
33
33
  const { MEMORY_SLUG_RE: SLUG_RE } = require('./lib/memorySlug.cjs');
34
34
  const { scoreMemories } = require('./lib/memoryStale.cjs');
35
+ const { addIndexLine, removeIndexLines, indexHealth } = require('./lib/memoryIndex.cjs');
36
+ const {
37
+ resolveWorkspaceMemoryDir,
38
+ autoMemoryEnabled,
39
+ resolveAgentMemoryDir,
40
+ listMemoryAgents,
41
+ gitRepoRoot,
42
+ } = require('./lib/memoryDirResolve.cjs');
35
43
 
36
44
  const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB
37
45
  const MAX_ENTRIES = 1000;
@@ -52,13 +60,59 @@ function workspaceDir(workspace) {
52
60
  return path.join(memoryRoot(), workspace, 'memory');
53
61
  }
54
62
 
63
+ /** Read + parse a settings file; missing/malformed -> null. */
64
+ async function readSettings(abs) {
65
+ try {
66
+ const r = await config.readText(abs);
67
+ if (!r.exists) return null;
68
+ const parsed = JSON.parse(r.text);
69
+ return parsed && typeof parsed === 'object' ? parsed : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The one place a memory directory is chosen.
77
+ * agent -> native subagent memory dir (root = git root of cwd, or cwd)
78
+ * cwd -> Claude Code's native workspace dir (git root + autoMemoryDirectory)
79
+ * else -> legacy ~/.claude/projects/<workspace>/memory
80
+ * Returns { dir, autoMemoryEnabled }. Throws on an invalid cwd/agent; every path
81
+ * the callers derive from `dir` still goes through config.validatePath/validateWrite.
82
+ */
83
+ async function resolveDir({ workspace, cwd, agent } = {}) {
84
+ const home = os.homedir();
85
+ const ws = validWorkspaceName(workspace) ? workspace : 'default';
86
+ if (typeof cwd !== 'string' || !cwd) {
87
+ if (agent) throw new Error('agent memory requires a cwd');
88
+ return { dir: workspaceDir(ws), autoMemoryEnabled: true };
89
+ }
90
+ const realCwd = config.validatePath(cwd);
91
+ const root = gitRepoRoot(realCwd, require('node:child_process').execFileSync) || realCwd;
92
+ const realRoot = config.validatePath(root);
93
+ // Project-scoped native dirs live under <root>/.claude; validateWrite only
94
+ // permits that subtree for registered roots.
95
+ config.addAllowedRoot(realRoot);
96
+ const settings = {
97
+ user: await readSettings(path.join(home, '.claude', 'settings.json')),
98
+ project: await readSettings(path.join(realRoot, '.claude', 'settings.json')),
99
+ local: await readSettings(path.join(realRoot, '.claude', 'settings.local.json')),
100
+ };
101
+ const enabled = autoMemoryEnabled(settings);
102
+ if (agent) {
103
+ return { dir: resolveAgentMemoryDir({ home, root: realRoot, name: agent.name, scope: agent.scope }), autoMemoryEnabled: enabled };
104
+ }
105
+ const r = resolveWorkspaceMemoryDir({ home, cwd: realCwd, settings, repoRoot: realRoot });
106
+ return { dir: r.dir, autoMemoryEnabled: enabled };
107
+ }
108
+
55
109
  /**
56
110
  * Reject NUL chars, '..' segments, absolute paths, or anything that would
57
111
  * resolve outside the workspace dir. Returns the resolved absolute path.
58
112
  * The downstream config.cjs helpers add a second layer of validation, but
59
113
  * this lets us return a clean error message before we get there.
60
114
  */
61
- function resolveEntryPath(workspace, name) {
115
+ function resolveEntryPath(dir, name) {
62
116
  if (typeof name !== 'string' || !name) {
63
117
  throw new Error('invalid entry name');
64
118
  }
@@ -66,7 +120,6 @@ function resolveEntryPath(workspace, name) {
66
120
  if (!SLUG_RE.test(name)) {
67
121
  throw new Error(`invalid slug (must match ${SLUG_RE.source}): ${name}`);
68
122
  }
69
- const dir = workspaceDir(workspace);
70
123
  const full = path.resolve(dir, name);
71
124
  // path.resolve normalizes; verify the result is still under dir to guard
72
125
  // against bizarre name inputs.
@@ -84,12 +137,15 @@ function validWorkspaceName(name) {
84
137
  return typeof name === 'string' && /^[a-zA-Z0-9-_]{1,256}$/.test(name);
85
138
  }
86
139
 
87
- async function list({ workspace }) {
140
+ async function list({ workspace, cwd, agent }) {
88
141
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
89
- const dir = workspaceDir(ws);
142
+ let resolved;
143
+ try { resolved = await resolveDir({ workspace: ws, cwd, agent }); }
144
+ catch (e) { return { entries: [], workspace: ws, dir: null, autoMemoryEnabled: true, error: e.message }; }
145
+ const { dir } = resolved;
90
146
  const r = await config.listDir(dir, { filesOnly: true });
91
147
  if (!r.ok) {
92
- return { entries: [], workspace: ws, error: r.error };
148
+ return { entries: [], workspace: ws, dir, autoMemoryEnabled: resolved.autoMemoryEnabled, error: r.error };
93
149
  }
94
150
  const entries = r.entries
95
151
  .filter((e) => e.name.endsWith('.md'))
@@ -100,12 +156,14 @@ async function list({ workspace }) {
100
156
  bytes: e.size,
101
157
  }))
102
158
  .sort((a, b) => a.name.localeCompare(b.name));
103
- return { entries, workspace: ws, error: null };
159
+ return { entries, workspace: ws, dir, autoMemoryEnabled: resolved.autoMemoryEnabled, error: null };
104
160
  }
105
161
 
106
- async function read({ workspace, name }) {
162
+ async function read({ workspace, name, cwd, agent }) {
107
163
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
108
- const abs = resolveEntryPath(ws, name);
164
+ let abs;
165
+ try { abs = resolveEntryPath((await resolveDir({ workspace: ws, cwd, agent })).dir, name); }
166
+ catch (e) { return { content: '', exists: false, mtimeMs: 0, bytes: 0, error: e.message }; }
109
167
  const r = await config.readText(abs);
110
168
  if (!r.exists) {
111
169
  return { content: '', exists: false, mtimeMs: 0, bytes: 0, error: r.error };
@@ -119,7 +177,7 @@ async function read({ workspace, name }) {
119
177
  };
120
178
  }
121
179
 
122
- async function write({ workspace, name, content }) {
180
+ async function write({ workspace, name, content, cwd, agent }) {
123
181
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
124
182
  if (typeof content !== 'string') {
125
183
  return { ok: false, error: 'content must be a string' };
@@ -128,7 +186,9 @@ async function write({ workspace, name, content }) {
128
186
  if (bytes > MAX_FILE_BYTES) {
129
187
  return { ok: false, error: `file exceeds 1 MiB cap (${bytes} bytes)` };
130
188
  }
131
- const abs = resolveEntryPath(ws, name);
189
+ let abs;
190
+ try { abs = resolveEntryPath((await resolveDir({ workspace: ws, cwd, agent })).dir, name); }
191
+ catch (e) { return { ok: false, error: e.message }; }
132
192
  // Enforce per-workspace entry cap on creates (writes to existing files are
133
193
  // always allowed regardless of count). Cheap fs.stat check rather than a
134
194
  // full directory enumeration where possible.
@@ -136,7 +196,7 @@ async function write({ workspace, name, content }) {
136
196
  try { await fsp.access(abs, fs.constants.F_OK); }
137
197
  catch { exists = false; }
138
198
  if (!exists) {
139
- const l = await list({ workspace: ws });
199
+ const l = await list({ workspace: ws, cwd, agent });
140
200
  if (l.entries.length >= MAX_ENTRIES) {
141
201
  return { ok: false, error: `workspace at ${MAX_ENTRIES}-entry cap` };
142
202
  }
@@ -149,9 +209,22 @@ async function write({ workspace, name, content }) {
149
209
  }
150
210
  }
151
211
 
152
- async function deleteEntry({ workspace, name }) {
212
+ const INDEX_NAME = 'MEMORY.md';
213
+
214
+ /** Read <dir>/MEMORY.md; missing/unreadable -> null. */
215
+ async function readIndexText(dir) {
216
+ const r = await config.readText(path.join(dir, INDEX_NAME));
217
+ return r.exists ? r.text : null;
218
+ }
219
+
220
+ async function deleteEntry({ workspace, name, cwd, agent }) {
153
221
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
154
- const abs = resolveEntryPath(ws, name);
222
+ let abs;
223
+ let dir;
224
+ try {
225
+ dir = (await resolveDir({ workspace: ws, cwd, agent })).dir;
226
+ abs = resolveEntryPath(dir, name);
227
+ } catch (e) { return { ok: false, error: e.message }; }
155
228
  // Route through config.cjs::validatePath (home-boundary pin, symlink-safe)
156
229
  // plus validateWrite (write-prefix pin) — same contract every other
157
230
  // mutating call in the codebase follows. Complexity: O(1) per call.
@@ -164,16 +237,30 @@ async function deleteEntry({ workspace, name }) {
164
237
  }
165
238
  try {
166
239
  await fsp.unlink(real);
167
- return { ok: true, error: null };
168
240
  } catch (e) {
169
- if (e.code === 'ENOENT') return { ok: true, error: null };
170
- return { ok: false, error: e.message };
241
+ if (e.code !== 'ENOENT') return { ok: false, error: e.message };
242
+ }
243
+ if (name === INDEX_NAME) return { ok: true, error: null };
244
+ try {
245
+ const text = await readIndexText(dir);
246
+ if (text !== null) {
247
+ const next = removeIndexLines(text, name);
248
+ if (next !== text) await config.writeTextAtomic(path.join(dir, INDEX_NAME), next);
249
+ }
250
+ } catch (e) {
251
+ return { ok: true, error: null, indexError: e.message };
171
252
  }
253
+ return { ok: true, error: null };
172
254
  }
173
255
 
174
- async function create({ workspace, name, description }) {
256
+ async function create({ workspace, name, description, cwd, agent }) {
175
257
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
176
- const abs = resolveEntryPath(ws, name);
258
+ let abs;
259
+ let dir;
260
+ try {
261
+ dir = (await resolveDir({ workspace: ws, cwd, agent })).dir;
262
+ abs = resolveEntryPath(dir, name);
263
+ } catch (e) { return { ok: false, error: e.message }; }
177
264
  // Refuse to overwrite — create is strictly "new file".
178
265
  try {
179
266
  await fsp.access(abs, fs.constants.F_OK);
@@ -190,7 +277,16 @@ async function create({ workspace, name, description }) {
190
277
  desc || `# ${stem}`,
191
278
  '',
192
279
  ].join('\n');
193
- return await write({ workspace: ws, name, content: fm });
280
+ const w = await write({ workspace: ws, name, content: fm, cwd, agent });
281
+ if (!w.ok || name === INDEX_NAME) return w;
282
+ try {
283
+ const text = (await readIndexText(dir)) || '';
284
+ const next = addIndexLine(text, { file: name, title: stem, hook: desc.replace(/\n/g, ' ') });
285
+ if (next !== text) await config.writeTextAtomic(path.join(dir, INDEX_NAME), next);
286
+ } catch (e) {
287
+ return { ...w, indexError: e.message };
288
+ }
289
+ return w;
194
290
  }
195
291
 
196
292
  /**
@@ -200,9 +296,11 @@ async function create({ workspace, name, description }) {
200
296
  * the dead-ref existence check. Absent/invalid cwd disables dead-ref
201
297
  * detection entirely rather than mass-flagging memories.
202
298
  */
203
- async function stale({ workspace, cwd }) {
299
+ async function stale({ workspace, cwd, agent }) {
204
300
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
205
- const dir = workspaceDir(ws);
301
+ let dir;
302
+ try { dir = (await resolveDir({ workspace: ws, cwd, agent })).dir; }
303
+ catch (e) { return { entries: [], workspace: ws, error: e.message }; }
206
304
  const r = await config.listDir(dir, { filesOnly: true });
207
305
  if (!r.ok) {
208
306
  return { entries: [], workspace: ws, error: r.error };
@@ -236,7 +334,30 @@ async function stale({ workspace, cwd }) {
236
334
  entries.push({ name, mtimeMs: r2.mtimeMs, body: r2.text });
237
335
  }
238
336
 
239
- return { entries: scoreMemories({ entries, now: Date.now(), existsPath }), workspace: ws, error: null };
337
+ const index = indexHealth({ indexText: await readIndexText(dir), files: r.entries.map((e) => e.name).filter((n) => n.endsWith('.md')) });
338
+ return { entries: scoreMemories({ entries, now: Date.now(), existsPath }), workspace: ws, error: null, index };
339
+ }
340
+
341
+ /** Agents (user + project) with their `memory:` frontmatter scope. */
342
+ async function agents({ cwd } = {}) {
343
+ const home = os.homedir();
344
+ let root = null;
345
+ if (typeof cwd === 'string' && cwd) {
346
+ try {
347
+ const realCwd = config.validatePath(cwd);
348
+ root = config.validatePath(gitRepoRoot(realCwd, require('node:child_process').execFileSync) || realCwd);
349
+ } catch {
350
+ root = null;
351
+ }
352
+ }
353
+ return {
354
+ agents: listMemoryAgents({
355
+ home,
356
+ root,
357
+ readDir: (d) => fs.readdirSync(d),
358
+ readText: (f) => fs.readFileSync(f, 'utf8'),
359
+ }),
360
+ };
240
361
  }
241
362
 
242
363
  function registerMemoryHandlers() {
@@ -247,6 +368,7 @@ function registerMemoryHandlers() {
247
368
  ipcMain.handle('memory:delete', v(s.memoryDelete, deleteEntry));
248
369
  ipcMain.handle('memory:create', v(s.memoryCreate, create));
249
370
  ipcMain.handle('memory:stale', v(s.memoryStale, stale));
371
+ ipcMain.handle('memory:agents', v(s.memoryAgents, agents));
250
372
  }
251
373
 
252
374
  module.exports = {
@@ -255,5 +377,12 @@ module.exports = {
255
377
  // exported for tests
256
378
  memoryRoot,
257
379
  workspaceDir,
380
+ resolveDir,
381
+ list,
382
+ read,
383
+ write,
384
+ create,
385
+ deleteEntry,
386
+ agents,
258
387
  stale,
259
388
  };