claude-code-session-manager 0.113.0 → 0.115.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 (49) hide show
  1. package/dist/assets/{AgentLibrary-wvrcLGGS.js → AgentLibrary-CjsZLl5e.js} +1 -1
  2. package/dist/assets/{Button-B6h0F0XT.js → Button-mj0vvcdc.js} +1 -1
  3. package/dist/assets/{DataModel-C5pW9E3u.js → DataModel-CG6ycpfG.js} +1 -1
  4. package/dist/assets/{History-BV4e8YOM.js → History-DDqLQiAN.js} +1 -1
  5. package/dist/assets/{Hooks-CrFSv4x1.js → Hooks-dRje24Nk.js} +1 -1
  6. package/dist/assets/{Library-D-GIt3EY.js → Library-Dl71nylN.js} +1 -1
  7. package/dist/assets/{ListDetail-Bco_wfs6.js → ListDetail-DcAD_cuA.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-C-yHSXNV.js → MarkdownEditor-D7JTNOOp.js} +1 -1
  9. package/dist/assets/{McpServers-CfDekadG.js → McpServers-BbvbhemH.js} +1 -1
  10. package/dist/assets/Memory-dyz8ZU1e.js +8 -0
  11. package/dist/assets/{Panel-DGMEuYYG.js → Panel-BkfK-W74.js} +1 -1
  12. package/dist/assets/{Permissions-Cx2zjtYZ.js → Permissions-BnjsXblr.js} +1 -1
  13. package/dist/assets/{Plugins-Bc_W3IyR.js → Plugins-DSyTJtTp.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DiKAUR1u.js → ProvenanceBadge-CQXSiPHq.js} +1 -1
  15. package/dist/assets/{RemoteMachines-8_77-WPD.js → RemoteMachines-li6EBPAt.js} +1 -1
  16. package/dist/assets/{SaveBar-Blpzdq1R.js → SaveBar-0aiuy1L2.js} +1 -1
  17. package/dist/assets/{Scheduler-Bd70AM4X.js → Scheduler-Ul4jiK7q.js} +1 -1
  18. package/dist/assets/{ScopeSwitcher-wxN0h4YW.js → ScopeSwitcher-DR262W_R.js} +1 -1
  19. package/dist/assets/{Settings-CQ7DA3zW.js → Settings-BwUcGuar.js} +1 -1
  20. package/dist/assets/{SkillReferenceGraph-DH1NLOiS.js → SkillReferenceGraph-fv2FJUNK.js} +1 -1
  21. package/dist/assets/{Skills-CmyRyu4_.js → Skills-BA2I-iRE.js} +1 -1
  22. package/dist/assets/{SystemPrompt-KAc3UMCd.js → SystemPrompt-BgvyrAKx.js} +1 -1
  23. package/dist/assets/{TagLibrary-C8EvHnpg.js → TagLibrary-B5rKEDVT.js} +1 -1
  24. package/dist/assets/{TiptapBody-d423mH6D.js → TiptapBody-CBxxEulT.js} +1 -1
  25. package/dist/assets/{Toggle-D1wsGE9S.js → Toggle-W944m375.js} +1 -1
  26. package/dist/assets/{ViewTabs-CjHw3kGY.js → ViewTabs-BLQrBE2n.js} +1 -1
  27. package/dist/assets/{historyTotals-DFHzqLt7.js → historyTotals-BUeyjlNY.js} +1 -1
  28. package/dist/assets/{index-Dutcsly-.css → index-HpEbvDiE.css} +1 -1
  29. package/dist/assets/{index-Ci1u018F.js → index-t37UuEZu.js} +101 -101
  30. package/dist/assets/{settingsSchema-Ctf3pZVI.js → settingsSchema-HwiOXtC7.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-B1NBmOAe.js → useAgentsDirWatch-D0rI9G_h.js} +1 -1
  32. package/dist/assets/{useRemotePeers-BHjSJs9D.js → useRemotePeers-BipAySOB.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/chatRunner.cjs +8 -2
  37. package/src/main/index.cjs +0 -2
  38. package/src/main/ipcSchemas.cjs +50 -35
  39. package/src/main/lib/memoryDirResolve.cjs +139 -0
  40. package/src/main/lib/memoryIndex.cjs +98 -0
  41. package/src/main/lib/remoteDrilldown.cjs +91 -0
  42. package/src/main/lib/remoteTranscriptTail.cjs +90 -0
  43. package/src/main/memoryAggregate.cjs +15 -8
  44. package/src/main/memoryTool.cjs +151 -22
  45. package/src/main/webRemote.cjs +90 -57
  46. package/src/preload/api.d.ts +44 -52
  47. package/src/preload/index.cjs +19 -21
  48. package/dist/assets/Memory-CGGLexkR.js +0 -8
  49. package/src/main/agentMemory.cjs +0 -246
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ // Pure builders for the phone remote's drill-down lists: projects (open tabs),
4
+ // then one project's Epics in every lifecycle status. readIndex is injected
5
+ // (production passes epicMint's readActiveIndex) so this module stays pure.
6
+
7
+ const path = require('node:path');
8
+
9
+ const STATUS_RANK = { active: 0, proposed: 1, completed: 2 };
10
+
11
+ function readSessions(readIndex, cwd) {
12
+ try {
13
+ const sessions = readIndex(cwd)?.sessions;
14
+ return sessions && typeof sessions === 'object' ? sessions : {};
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ function tabTitle(tab) {
21
+ return tab.label || path.basename(tab.cwd);
22
+ }
23
+
24
+ function validTabs(tabs) {
25
+ return (Array.isArray(tabs) ? tabs : []).filter((t) => t && typeof t.cwd === 'string' && t.cwd);
26
+ }
27
+
28
+ function toMs(value) {
29
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
30
+ if (typeof value === 'string' && value) {
31
+ const ms = Date.parse(value);
32
+ return Number.isNaN(ms) ? null : ms;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function epicUpdatedAt(epic) {
38
+ for (const v of [epic.updatedAt, epic.completedAt, epic.createdAt]) {
39
+ if (v !== undefined && v !== null && v !== '') return toMs(v);
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function listRemoteProjects(tabs, readIndex) {
45
+ const seen = new Set();
46
+ const out = [];
47
+ for (const tab of validTabs(tabs)) {
48
+ if (seen.has(tab.cwd)) continue;
49
+ seen.add(tab.cwd);
50
+ const counts = { proposed: 0, active: 0, completed: 0 };
51
+ for (const epic of Object.values(readSessions(readIndex, tab.cwd))) {
52
+ if (epic && Object.prototype.hasOwnProperty.call(counts, epic.status)) counts[epic.status] += 1;
53
+ }
54
+ out.push({ cwd: tab.cwd, title: tabTitle(tab), mainTabId: tab.id ?? tab.sessionId, counts });
55
+ }
56
+ return out.sort((a, b) => a.title.localeCompare(b.title));
57
+ }
58
+
59
+ function listRemoteProjectSessions(tabs, readIndex, cwd, { completedLimit = 20 } = {}) {
60
+ const tab = validTabs(tabs).find((t) => t.cwd === cwd);
61
+ if (!tab) return null;
62
+ const project = { cwd: tab.cwd, title: tabTitle(tab), mainTabId: tab.id ?? tab.sessionId };
63
+ const rows = [];
64
+ for (const epic of Object.values(readSessions(readIndex, tab.cwd))) {
65
+ if (!epic || !Object.prototype.hasOwnProperty.call(STATUS_RANK, epic.status)) continue;
66
+ const firstLine = typeof epic.goalText === 'string' ? epic.goalText.split('\n')[0].trim().slice(0, 80).trim() : '';
67
+ rows.push({
68
+ epicId: epic.id,
69
+ claudeSessionId: typeof epic.claudeSessionId === 'string' && epic.claudeSessionId ? epic.claudeSessionId : null,
70
+ title: firstLine || epic.id,
71
+ status: epic.status,
72
+ tag: epic.tag,
73
+ agentType: epic.agentType,
74
+ spawnCwd: epic.worktree?.dir || epic.cwd || tab.cwd,
75
+ updatedAt: epicUpdatedAt(epic),
76
+ });
77
+ }
78
+ rows.sort((a, b) => {
79
+ const r = STATUS_RANK[a.status] - STATUS_RANK[b.status];
80
+ if (r !== 0) return r;
81
+ if (a.updatedAt === b.updatedAt) return 0;
82
+ if (a.updatedAt === null) return 1;
83
+ if (b.updatedAt === null) return -1;
84
+ return b.updatedAt - a.updatedAt;
85
+ });
86
+ let completed = 0;
87
+ const sessions = rows.filter((s) => s.status !== 'completed' || ++completed <= completedLimit);
88
+ return { project, sessions };
89
+ }
90
+
91
+ module.exports = { listRemoteProjects, listRemoteProjectSessions };
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ // Bounded-tail reader for Claude Code JSONL transcripts: the last N human-readable
4
+ // chat messages (user prompts + assistant text) without reading the whole file.
5
+ // Top-level (no domain fits: epic/ transcripts are path-only; util/ is domain-free).
6
+ // Transcripts reach hundreds of MB, so cost is O(maxBytes), never O(file).
7
+
8
+ const fsp = require('node:fs/promises');
9
+
10
+ const DEFAULT_LIMIT = 30;
11
+ const MAX_LIMIT = 100;
12
+ const DEFAULT_MAX_BYTES = 524288;
13
+ const MAX_TEXT_CHARS = 4000;
14
+
15
+ /** Join the plain-text parts of a message `content` (string or block array). */
16
+ function textOf(content) {
17
+ if (typeof content === 'string') return content;
18
+ if (!Array.isArray(content)) return '';
19
+ return content
20
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
21
+ .map((b) => b.text)
22
+ .join('\n');
23
+ }
24
+
25
+ function truncate(text) {
26
+ return text.length > MAX_TEXT_CHARS ? text.slice(0, MAX_TEXT_CHARS - 1) + '…' : text;
27
+ }
28
+
29
+ /** Parse one JSONL line into a chat message, or null when it is not one. */
30
+ function toChatMessage(line) {
31
+ let entry;
32
+ try {
33
+ entry = JSON.parse(line);
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (!entry || typeof entry !== 'object') return null;
38
+ if (entry.isMeta || entry.isSidechain) return null;
39
+ const role = entry.type;
40
+ if (role !== 'user' && role !== 'assistant') return null;
41
+ const text = textOf(entry.message?.content).trim();
42
+ if (!text) return null;
43
+ return {
44
+ role,
45
+ text: truncate(text),
46
+ ts: typeof entry.timestamp === 'string' ? entry.timestamp : null,
47
+ uuid: typeof entry.uuid === 'string' ? entry.uuid : null,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @param {string} filePath
53
+ * @param {{ limit?: number, maxBytes?: number }} [opts]
54
+ * @returns {Promise<Array<{ role: 'user'|'assistant', text: string, ts: string|null, uuid: string|null }>>}
55
+ * oldest first; [] on any read failure.
56
+ */
57
+ async function readRecentChatMessages(filePath, { limit = DEFAULT_LIMIT, maxBytes = DEFAULT_MAX_BYTES } = {}) {
58
+ if (typeof filePath !== 'string' || !filePath) return [];
59
+ const n = Math.floor(Number(limit));
60
+ const lim = Number.isFinite(n) ? Math.min(MAX_LIMIT, Math.max(1, n)) : DEFAULT_LIMIT;
61
+ const cap = Math.max(1, Math.floor(Number(maxBytes)) || DEFAULT_MAX_BYTES);
62
+ let fd;
63
+ try {
64
+ fd = await fsp.open(filePath, 'r');
65
+ const { size } = await fd.stat();
66
+ if (size === 0) return [];
67
+ const start = Math.max(0, size - cap);
68
+ // From mid-file, read one extra leading byte so the first split part is
69
+ // always a fragment (empty when start sits on a line boundary) — drop it.
70
+ const from = start > 0 ? start - 1 : 0;
71
+ const len = size - from;
72
+ const buf = Buffer.alloc(len);
73
+ const { bytesRead } = await fd.read(buf, 0, len, from);
74
+ const parts = buf.toString('utf8', 0, bytesRead).split('\n');
75
+ if (start > 0) parts.shift();
76
+ const out = [];
77
+ for (const line of parts) {
78
+ if (!line) continue;
79
+ const msg = toChatMessage(line);
80
+ if (msg) out.push(msg);
81
+ }
82
+ return out.slice(-lim);
83
+ } catch {
84
+ return [];
85
+ } finally {
86
+ await fd?.close().catch(() => {});
87
+ }
88
+ }
89
+
90
+ module.exports = { readRecentChatMessages };
@@ -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
  };