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.
- package/dist/assets/{AgentLibrary-wvrcLGGS.js → AgentLibrary-CjsZLl5e.js} +1 -1
- package/dist/assets/{Button-B6h0F0XT.js → Button-mj0vvcdc.js} +1 -1
- package/dist/assets/{DataModel-C5pW9E3u.js → DataModel-CG6ycpfG.js} +1 -1
- package/dist/assets/{History-BV4e8YOM.js → History-DDqLQiAN.js} +1 -1
- package/dist/assets/{Hooks-CrFSv4x1.js → Hooks-dRje24Nk.js} +1 -1
- package/dist/assets/{Library-D-GIt3EY.js → Library-Dl71nylN.js} +1 -1
- package/dist/assets/{ListDetail-Bco_wfs6.js → ListDetail-DcAD_cuA.js} +1 -1
- package/dist/assets/{MarkdownEditor-C-yHSXNV.js → MarkdownEditor-D7JTNOOp.js} +1 -1
- package/dist/assets/{McpServers-CfDekadG.js → McpServers-BbvbhemH.js} +1 -1
- package/dist/assets/Memory-dyz8ZU1e.js +8 -0
- package/dist/assets/{Panel-DGMEuYYG.js → Panel-BkfK-W74.js} +1 -1
- package/dist/assets/{Permissions-Cx2zjtYZ.js → Permissions-BnjsXblr.js} +1 -1
- package/dist/assets/{Plugins-Bc_W3IyR.js → Plugins-DSyTJtTp.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DiKAUR1u.js → ProvenanceBadge-CQXSiPHq.js} +1 -1
- package/dist/assets/{RemoteMachines-8_77-WPD.js → RemoteMachines-li6EBPAt.js} +1 -1
- package/dist/assets/{SaveBar-Blpzdq1R.js → SaveBar-0aiuy1L2.js} +1 -1
- package/dist/assets/{Scheduler-Bd70AM4X.js → Scheduler-Ul4jiK7q.js} +1 -1
- package/dist/assets/{ScopeSwitcher-wxN0h4YW.js → ScopeSwitcher-DR262W_R.js} +1 -1
- package/dist/assets/{Settings-CQ7DA3zW.js → Settings-BwUcGuar.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DH1NLOiS.js → SkillReferenceGraph-fv2FJUNK.js} +1 -1
- package/dist/assets/{Skills-CmyRyu4_.js → Skills-BA2I-iRE.js} +1 -1
- package/dist/assets/{SystemPrompt-KAc3UMCd.js → SystemPrompt-BgvyrAKx.js} +1 -1
- package/dist/assets/{TagLibrary-C8EvHnpg.js → TagLibrary-B5rKEDVT.js} +1 -1
- package/dist/assets/{TiptapBody-d423mH6D.js → TiptapBody-CBxxEulT.js} +1 -1
- package/dist/assets/{Toggle-D1wsGE9S.js → Toggle-W944m375.js} +1 -1
- package/dist/assets/{ViewTabs-CjHw3kGY.js → ViewTabs-BLQrBE2n.js} +1 -1
- package/dist/assets/{historyTotals-DFHzqLt7.js → historyTotals-BUeyjlNY.js} +1 -1
- package/dist/assets/{index-Dutcsly-.css → index-HpEbvDiE.css} +1 -1
- package/dist/assets/{index-Ci1u018F.js → index-t37UuEZu.js} +101 -101
- package/dist/assets/{settingsSchema-Ctf3pZVI.js → settingsSchema-HwiOXtC7.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-B1NBmOAe.js → useAgentsDirWatch-D0rI9G_h.js} +1 -1
- package/dist/assets/{useRemotePeers-BHjSJs9D.js → useRemotePeers-BipAySOB.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +8 -2
- package/src/main/index.cjs +0 -2
- package/src/main/ipcSchemas.cjs +50 -35
- package/src/main/lib/memoryDirResolve.cjs +139 -0
- package/src/main/lib/memoryIndex.cjs +98 -0
- package/src/main/lib/remoteDrilldown.cjs +91 -0
- package/src/main/lib/remoteTranscriptTail.cjs +90 -0
- package/src/main/memoryAggregate.cjs +15 -8
- package/src/main/memoryTool.cjs +151 -22
- package/src/main/webRemote.cjs +90 -57
- package/src/preload/api.d.ts +44 -52
- package/src/preload/index.cjs +19 -21
- package/dist/assets/Memory-CGGLexkR.js +0 -8
- 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
|
-
|
|
37
|
-
|
|
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 = [];
|
package/src/main/memoryTool.cjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|