claude-memory-admin 1.8.0 → 1.10.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/README.md +41 -6
- package/package.json +1 -1
- package/public/app.mjs +15 -1
- package/public/state.mjs +3 -2
- package/public/store.mjs +8 -4
- package/public/styles.css +1 -1
- package/public/ui.mjs +12 -0
- package/public/views/cost.mjs +231 -0
- package/public/views/environment.mjs +2 -0
- package/public/views/header.mjs +20 -1
- package/public/views/sessions.mjs +42 -2
- package/public/views/stores.mjs +8 -0
- package/server.mjs +61 -10
- package/src/agents.mjs +268 -0
- package/src/cost.mjs +277 -0
- package/src/liveSessions.mjs +46 -0
- package/src/mutate.mjs +7 -3
- package/src/settings.mjs +30 -3
package/public/views/header.mjs
CHANGED
|
@@ -22,6 +22,7 @@ const NAVIGATION = [
|
|
|
22
22
|
segments: [
|
|
23
23
|
{ id: 'instructions', label: 'Instructions' },
|
|
24
24
|
{ id: 'settings', label: 'Settings' },
|
|
25
|
+
{ id: 'cost', label: 'Cost' },
|
|
25
26
|
{ id: 'sessions', label: 'Sessions' },
|
|
26
27
|
{ id: 'tools', label: 'Tools' },
|
|
27
28
|
],
|
|
@@ -39,13 +40,27 @@ function segmentVisible(tab, segment, store) {
|
|
|
39
40
|
return segment === 'index' ? Boolean(store.hasIndex) : true;
|
|
40
41
|
}
|
|
41
42
|
if (segment === 'instructions') return global || hasProjectDir(store);
|
|
43
|
+
if (segment === 'cost') return global;
|
|
42
44
|
if (segment === 'tools') return state.tools.some((tool) => tool.found);
|
|
43
45
|
if (global) return false;
|
|
44
46
|
if (segment === 'sessions') return Boolean(store.sessions?.count);
|
|
45
47
|
return true;
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
export const costProblems = () => {
|
|
51
|
+
const data = state.aux.cost;
|
|
52
|
+
if (!data) return [];
|
|
53
|
+
return [
|
|
54
|
+
...(data.settings?.problems || []),
|
|
55
|
+
...(data.agents || []).flatMap((agent) => agent.problems || []),
|
|
56
|
+
];
|
|
57
|
+
};
|
|
58
|
+
|
|
48
59
|
function segmentBadge(id) {
|
|
60
|
+
if (id === 'cost') {
|
|
61
|
+
const problems = costProblems();
|
|
62
|
+
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
63
|
+
}
|
|
49
64
|
if (id === 'instructions') {
|
|
50
65
|
const problems = state.aux.instructions?.problems || [];
|
|
51
66
|
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
@@ -56,7 +71,10 @@ function segmentBadge(id) {
|
|
|
56
71
|
}
|
|
57
72
|
if (id === 'sessions') {
|
|
58
73
|
const sessions = state.store.sessions;
|
|
59
|
-
|
|
74
|
+
if (!sessions) return {};
|
|
75
|
+
const live = state.activeSessions.some((s) => s.storeId === state.store.id);
|
|
76
|
+
const badge = live ? `● ${sessions.count}` : String(sessions.count);
|
|
77
|
+
return { badge, tone: live ? 'ok' : sessions.expiringCount ? 'warn' : 'neutral' };
|
|
60
78
|
}
|
|
61
79
|
return {};
|
|
62
80
|
}
|
|
@@ -88,6 +106,7 @@ function tabBadge(tab, store) {
|
|
|
88
106
|
const problems = [
|
|
89
107
|
...(state.aux.instructions?.problems || []),
|
|
90
108
|
...(state.aux.settings?.problems || []),
|
|
109
|
+
...costProblems(),
|
|
91
110
|
];
|
|
92
111
|
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
93
112
|
}
|
|
@@ -20,11 +20,26 @@ export function focusSession(id) {
|
|
|
20
20
|
goTo('environment', 'sessions');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function
|
|
23
|
+
function agoLabel(ms) {
|
|
24
|
+
const seconds = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
25
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
26
|
+
const minutes = Math.round(seconds / 60);
|
|
27
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
28
|
+
return `${Math.round(minutes / 60)}h ago`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sessionRow(session, memoriesFrom, focused, live) {
|
|
24
32
|
const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
|
|
25
33
|
const caret = node('span', { class: ui.contextCaret, text: focused ? '\u25be' : '\u25b8' });
|
|
26
34
|
|
|
27
35
|
const tags = [];
|
|
36
|
+
if (live) {
|
|
37
|
+
tags.push(node('span', {
|
|
38
|
+
class: ui.badge('ok'),
|
|
39
|
+
text: live.status === 'waiting' ? 'waiting' : live.status || 'active',
|
|
40
|
+
title: live.waitingFor || `updated ${agoLabel(live.updatedAt)}`,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
28
43
|
if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
|
|
29
44
|
if (session.model) tags.push(node('span', { class: ui.badge('neutral'), text: session.model }));
|
|
30
45
|
if (session.expiresInDays !== null) {
|
|
@@ -196,8 +211,33 @@ export async function renderSessions(container) {
|
|
|
196
211
|
paint('tab');
|
|
197
212
|
};
|
|
198
213
|
|
|
214
|
+
const active = state.activeSessions.filter((s) => s.storeId === state.storeId);
|
|
215
|
+
const byId = new Map(active.map((s) => [s.sessionId, s]));
|
|
216
|
+
|
|
199
217
|
container.append(node('p', { class: ui.noteTight, text: 'The transcripts sitting beside this store\u2019s memory. Claude Code deletes them once they pass the retention period and never touches memory/, so this is the evidence that expires while the memories stay. This tab only reads, and only the head of each file - nothing here deletes a transcript.' }));
|
|
200
218
|
|
|
219
|
+
if (active.length) {
|
|
220
|
+
const list = node('div', { class: ui.card });
|
|
221
|
+
for (const session of active) {
|
|
222
|
+
list.append(node('button', {
|
|
223
|
+
class: ui.contextRowButton,
|
|
224
|
+
onclick: () => focusSession(session.sessionId),
|
|
225
|
+
}, [
|
|
226
|
+
node('div', { class: ui.contextMain }, [
|
|
227
|
+
node('div', { class: ui.contextTags }, [
|
|
228
|
+
node('span', {
|
|
229
|
+
class: ui.badge('ok'),
|
|
230
|
+
text: session.status === 'waiting' ? 'waiting' : session.status || 'active',
|
|
231
|
+
}),
|
|
232
|
+
]),
|
|
233
|
+
node('div', { class: ui.contextFile, text: session.name || session.sessionId }),
|
|
234
|
+
]),
|
|
235
|
+
node('div', { class: ui.contextSize, text: session.waitingFor || agoLabel(session.updatedAt) }),
|
|
236
|
+
]));
|
|
237
|
+
}
|
|
238
|
+
container.append(meterPanel('Active now', null, list));
|
|
239
|
+
}
|
|
240
|
+
|
|
201
241
|
container.append(node('div', { class: ui.meter }, [
|
|
202
242
|
node('div', { class: ui.meterTop }, [
|
|
203
243
|
node('span', { class: ui.meterValue, text: String(data.count) }),
|
|
@@ -241,7 +281,7 @@ export async function renderSessions(container) {
|
|
|
241
281
|
const card = node('div', { class: ui.card });
|
|
242
282
|
let focused = null;
|
|
243
283
|
for (const session of visible) {
|
|
244
|
-
const row = sessionRow(session, byOrigin.get(session.id) || [], session.id === focus);
|
|
284
|
+
const row = sessionRow(session, byOrigin.get(session.id) || [], session.id === focus, byId.get(session.id));
|
|
245
285
|
if (session.id === focus) focused = row;
|
|
246
286
|
card.append(row);
|
|
247
287
|
}
|
package/public/views/stores.mjs
CHANGED
|
@@ -22,10 +22,17 @@ function issueTitle(store) {
|
|
|
22
22
|
return parts.length ? `${store.dir}\n${parts.join(', ')}` : store.dir;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function activeTitle(active) {
|
|
26
|
+
const waiting = active.filter((s) => s.status === 'waiting').length;
|
|
27
|
+
const label = active.length === 1 ? '1 session' : `${active.length} sessions`;
|
|
28
|
+
return waiting ? `${label} - ${waiting} waiting for input` : `${label} ${active.length === 1 ? 'is' : 'are'} busy`;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
function storeButton(store) {
|
|
26
32
|
const global = store.kind === 'global';
|
|
27
33
|
const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
|
|
28
34
|
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
35
|
+
const active = state.activeSessions.filter((s) => s.storeId === store.id);
|
|
29
36
|
return node('button', {
|
|
30
37
|
class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
|
|
31
38
|
onclick: () => openStore(store.id),
|
|
@@ -34,6 +41,7 @@ function storeButton(store) {
|
|
|
34
41
|
node('span', { class: ui.storeRow }, [
|
|
35
42
|
node('span', { class: ui.dot(health) }),
|
|
36
43
|
node('span', { class: ui.storeName, text: store.label }),
|
|
44
|
+
active.length ? node('span', { class: ui.dot('ok'), title: activeTitle(active) }) : null,
|
|
37
45
|
off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
|
|
38
46
|
global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
39
47
|
]),
|
package/server.mjs
CHANGED
|
@@ -12,12 +12,15 @@ import { projectsRoot, resolveRoot } from './src/projects.mjs';
|
|
|
12
12
|
import { buildStore } from './src/model.mjs';
|
|
13
13
|
import { resolveGlobalInstructions, resolveInstructions, summarise } from './src/instructions.mjs';
|
|
14
14
|
import { settingsReport, summariseSettings } from './src/settings.mjs';
|
|
15
|
+
import { costReport, writeUserSetting } from './src/cost.mjs';
|
|
16
|
+
import { AGENTS_DIR, AGENT_FIELDS, agentsDirExists, listAgents, setAgentField } from './src/agents.mjs';
|
|
15
17
|
import { listStores } from './src/stores.mjs';
|
|
16
18
|
import { forgetPath, rememberPath } from './src/pathcache.mjs';
|
|
17
19
|
import { searchAll } from './src/search.mjs';
|
|
18
20
|
import { verifyPaths } from './src/pathcheck.mjs';
|
|
19
21
|
import { detectTools, toolReport } from './src/tools.mjs';
|
|
20
22
|
import { sessionsWithSummaries, transcriptDir } from './src/sessions.mjs';
|
|
23
|
+
import { readLiveSessions } from './src/liveSessions.mjs';
|
|
21
24
|
import {
|
|
22
25
|
addIndexEntry,
|
|
23
26
|
addIndexEntryPreview,
|
|
@@ -146,15 +149,23 @@ function storeProjectDir(store) {
|
|
|
146
149
|
|
|
147
150
|
/**
|
|
148
151
|
* The global store holds instructions, not memory, and its directory is ~/.claude
|
|
149
|
-
* itself. Every write endpoint below resolves a target inside `store.dir`,
|
|
150
|
-
* without this guard a delete aimed at it would land on the user's CLAUDE.md
|
|
151
|
-
* settings rather than on a memory file. It is
|
|
152
|
-
*
|
|
152
|
+
* itself. Every memory write endpoint below resolves a target inside `store.dir`,
|
|
153
|
+
* so without this guard a delete aimed at it would land on the user's CLAUDE.md
|
|
154
|
+
* and settings rather than on a memory file. It is refused here once rather than
|
|
155
|
+
* in each of the fifteen handlers.
|
|
156
|
+
*
|
|
157
|
+
* The Cost handlers are the two exceptions, and the only writes this app makes
|
|
158
|
+
* outside a memory store. They do not take a path from the request at all: one
|
|
159
|
+
* writes an allowlisted key to ~/.claude/settings.json, the other one of two
|
|
160
|
+
* frontmatter fields in a named file inside ~/.claude/agents. Neither can reach
|
|
161
|
+
* an instruction file, which is what the rest of this guard exists to protect.
|
|
153
162
|
*/
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
163
|
+
const GLOBAL_WRITE_ACTIONS = new Set(['cost/setting', 'cost/agent']);
|
|
164
|
+
|
|
165
|
+
export function refuseWritesToGlobal(store, method, action = null) {
|
|
166
|
+
if (store.kind !== 'global' || method === 'GET') return;
|
|
167
|
+
if (action && GLOBAL_WRITE_ACTIONS.has(action)) return;
|
|
168
|
+
throw new Error('The global store is read-only: it holds instruction files, not memory.');
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
/** A store with nothing to build a memory model from, in the shape the frontend expects. */
|
|
@@ -242,14 +253,26 @@ async function handleApi(req, res, url) {
|
|
|
242
253
|
return sendJson(res, 200, { stores: issueSweep() });
|
|
243
254
|
}
|
|
244
255
|
|
|
256
|
+
// Same "ahead of requireStore" reasoning as the issues sweep above: real
|
|
257
|
+
// store ids always carry a "kind:" prefix, so "active" can never collide.
|
|
258
|
+
if (segments.length === 3 && segments[1] === 'stores' && segments[2] === 'active' && req.method === 'GET') {
|
|
259
|
+
const live = readLiveSessions();
|
|
260
|
+
const projectStores = listStores(ROOT).filter((s) => s.kind === 'auto' && s.pathExists);
|
|
261
|
+
const sessions = live.map((session) => ({
|
|
262
|
+
...session,
|
|
263
|
+
storeId: projectStores.find((s) => s.path === session.cwd)?.id ?? null,
|
|
264
|
+
}));
|
|
265
|
+
return sendJson(res, 200, { sessions });
|
|
266
|
+
}
|
|
267
|
+
|
|
245
268
|
if (segments[1] !== 'stores' || segments.length < 3) {
|
|
246
269
|
return sendJson(res, 404, { error: 'Unknown endpoint' });
|
|
247
270
|
}
|
|
248
271
|
|
|
249
272
|
const store = requireStore(decodeURIComponent(segments[2]));
|
|
250
|
-
refuseWritesToGlobal(store, req.method);
|
|
251
|
-
const dir = store.dir;
|
|
252
273
|
const action = segments.slice(3).join('/');
|
|
274
|
+
refuseWritesToGlobal(store, req.method, action);
|
|
275
|
+
const dir = store.dir;
|
|
253
276
|
|
|
254
277
|
if (!action && req.method === 'GET') {
|
|
255
278
|
// buildStore reads every .md in the directory as a memory file, which for
|
|
@@ -299,6 +322,34 @@ async function handleApi(req, res, url) {
|
|
|
299
322
|
return sendJson(res, 200, settingsReport({ projectDir: storeProjectDir(store) }));
|
|
300
323
|
}
|
|
301
324
|
|
|
325
|
+
// The Cost segment. User scope only: the knobs it writes are about every
|
|
326
|
+
// session on the machine, and the file it writes them to is ~/.claude/settings.json.
|
|
327
|
+
if ((action === 'cost' || action.startsWith('cost/')) && store.kind !== 'global') {
|
|
328
|
+
return sendJson(res, 400, { error: 'The cost settings are user-scope, and are edited from the Global entry.' });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (action === 'cost' && req.method === 'GET') {
|
|
332
|
+
return sendJson(res, 200, {
|
|
333
|
+
settings: costReport(),
|
|
334
|
+
agents: listAgents(),
|
|
335
|
+
agentsDir: AGENTS_DIR,
|
|
336
|
+
agentsDirExists: agentsDirExists(),
|
|
337
|
+
agentFields: AGENT_FIELDS,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (action === 'cost/setting' && req.method === 'POST') {
|
|
342
|
+
const body = await readBody(req);
|
|
343
|
+
writeUserSetting(body.key, body.value ?? null);
|
|
344
|
+
return sendJson(res, 200, { settings: costReport() });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (action === 'cost/agent' && req.method === 'POST') {
|
|
348
|
+
const body = await readBody(req);
|
|
349
|
+
const agents = setAgentField(body.file, body.field, body.value ?? null);
|
|
350
|
+
return sendJson(res, 200, { agents, agentsDir: AGENTS_DIR, agentsDirExists: agentsDirExists() });
|
|
351
|
+
}
|
|
352
|
+
|
|
302
353
|
if (action === 'delete-preview' && req.method === 'POST') {
|
|
303
354
|
const body = await readBody(req);
|
|
304
355
|
return sendJson(res, 200, deletePreview(dir, body.file));
|
package/src/agents.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// User-scope subagent definitions: ~/.claude/agents/*.md.
|
|
2
|
+
//
|
|
3
|
+
// Not to be confused with ~/.claude/agent-memory (src/stores.mjs), which is what
|
|
4
|
+
// those agents *remember*. This module is about what they *are*: a markdown file
|
|
5
|
+
// whose frontmatter names the agent and, optionally, pins the model and effort
|
|
6
|
+
// it runs at. Pinning a summariser to Haiku while a reviewer stays on Opus is the
|
|
7
|
+
// finer-grained version of the CLAUDE_CODE_SUBAGENT_MODEL switch in src/cost.mjs,
|
|
8
|
+
// which - worth remembering when both are set - outranks everything here.
|
|
9
|
+
//
|
|
10
|
+
// Only `model` and `effort` are writable. The prompt body, the tool lists and
|
|
11
|
+
// every other field are read and shown but never rewritten: a body is prose
|
|
12
|
+
// somebody wrote on purpose, and this tool has no view on it.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { parseFrontmatter } from './parse.mjs';
|
|
19
|
+
import { writeFileAtomic } from './mutate.mjs';
|
|
20
|
+
|
|
21
|
+
export const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
|
|
22
|
+
|
|
23
|
+
const MODEL_ID = /^claude-[A-Za-z0-9._[\]-]+$/;
|
|
24
|
+
|
|
25
|
+
export const AGENT_FIELDS = {
|
|
26
|
+
model: {
|
|
27
|
+
unset: 'inherit',
|
|
28
|
+
allowModelId: true,
|
|
29
|
+
options: [
|
|
30
|
+
{ value: null, label: 'inherit', note: 'the session model' },
|
|
31
|
+
{ value: 'haiku', label: 'haiku' },
|
|
32
|
+
{ value: 'sonnet', label: 'sonnet' },
|
|
33
|
+
{ value: 'opus', label: 'opus' },
|
|
34
|
+
{ value: 'fable', label: 'fable' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
effort: {
|
|
38
|
+
unset: 'default',
|
|
39
|
+
allowModelId: false,
|
|
40
|
+
options: [
|
|
41
|
+
{ value: null, label: 'default', note: 'the session effort' },
|
|
42
|
+
{ value: 'low', label: 'low' },
|
|
43
|
+
{ value: 'medium', label: 'medium' },
|
|
44
|
+
{ value: 'high', label: 'high' },
|
|
45
|
+
{ value: 'xhigh', label: 'xhigh' },
|
|
46
|
+
{ value: 'max', label: 'max' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const AGENT_PROBLEM_SEVERITY = {
|
|
52
|
+
'no-frontmatter': 'bad',
|
|
53
|
+
'missing-name': 'warn',
|
|
54
|
+
'missing-description': 'warn',
|
|
55
|
+
'name-mismatch': 'warn',
|
|
56
|
+
'unknown-model': 'warn',
|
|
57
|
+
'unknown-effort': 'warn',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function known(field, value) {
|
|
61
|
+
const spec = AGENT_FIELDS[field];
|
|
62
|
+
if (spec.options.some((option) => option.value === value)) return true;
|
|
63
|
+
return spec.allowModelId && MODEL_ID.test(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A filename this module is willing to touch, resolved inside the agents
|
|
68
|
+
* directory. The same shape as safeMemoryPath in src/mutate.mjs: a bare .md
|
|
69
|
+
* basename, no dotfiles, and a realpath check so a symlink cannot point the
|
|
70
|
+
* write somewhere else.
|
|
71
|
+
*/
|
|
72
|
+
export function safeAgentPath(dir, file) {
|
|
73
|
+
if (typeof file !== 'string' || !file || file.includes('\0')) {
|
|
74
|
+
throw new Error('Invalid filename');
|
|
75
|
+
}
|
|
76
|
+
if (file !== path.basename(file) || file.startsWith('.') || !file.endsWith('.md')) {
|
|
77
|
+
throw new Error(`Refusing to touch "${file}": must be a plain .md filename`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const full = path.join(dir, file);
|
|
81
|
+
const rel = path.relative(dir, full);
|
|
82
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
83
|
+
throw new Error('Path escapes the agents directory');
|
|
84
|
+
}
|
|
85
|
+
if (fs.existsSync(full)) {
|
|
86
|
+
const realDir = fs.realpathSync(dir);
|
|
87
|
+
const realFull = fs.realpathSync(full);
|
|
88
|
+
if (path.relative(realDir, realFull).startsWith('..')) {
|
|
89
|
+
throw new Error('Path escapes the agents directory');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return full;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function describeAgent(dir, file) {
|
|
96
|
+
const full = path.join(dir, file);
|
|
97
|
+
const stem = file.replace(/\.md$/, '');
|
|
98
|
+
|
|
99
|
+
let text;
|
|
100
|
+
try {
|
|
101
|
+
text = fs.readFileSync(full, 'utf8');
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return {
|
|
104
|
+
file,
|
|
105
|
+
name: stem,
|
|
106
|
+
description: '',
|
|
107
|
+
tools: '',
|
|
108
|
+
model: null,
|
|
109
|
+
effort: null,
|
|
110
|
+
bytes: 0,
|
|
111
|
+
problems: [{ kind: 'no-frontmatter', severity: 'bad', detail: err.message }],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { data, hasFrontmatter } = parseFrontmatter(text);
|
|
116
|
+
const scalar = (key) => (typeof data[key] === 'string' ? data[key].trim() : '');
|
|
117
|
+
const name = scalar('name') || stem;
|
|
118
|
+
const model = scalar('model') || null;
|
|
119
|
+
const effort = scalar('effort') || null;
|
|
120
|
+
|
|
121
|
+
const problems = [];
|
|
122
|
+
const flag = (kind, detail) => problems.push({ kind, severity: AGENT_PROBLEM_SEVERITY[kind], detail });
|
|
123
|
+
|
|
124
|
+
if (!hasFrontmatter) {
|
|
125
|
+
flag('no-frontmatter', 'The file has no --- frontmatter block, so Claude Code does not read it as an agent definition.');
|
|
126
|
+
} else {
|
|
127
|
+
if (!scalar('name')) flag('missing-name', `No name: field, so the agent is addressed as "${stem}" after its file.`);
|
|
128
|
+
if (!scalar('description')) flag('missing-description', 'No description: field. Claude decides when to delegate from that description, so an agent without one is never chosen on its own.');
|
|
129
|
+
if (scalar('name') && scalar('name') !== stem) flag('name-mismatch', `Named "${scalar('name')}" in a file called "${file}".`);
|
|
130
|
+
if (model && !known('model', model)) flag('unknown-model', `model: ${model} is neither an alias nor a claude- model name.`);
|
|
131
|
+
if (effort && !known('effort', effort)) flag('unknown-effort', `effort: ${effort} is not one of low, medium, high, xhigh or max.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
file,
|
|
136
|
+
name,
|
|
137
|
+
description: scalar('description'),
|
|
138
|
+
tools: scalar('tools'),
|
|
139
|
+
model,
|
|
140
|
+
effort,
|
|
141
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
142
|
+
problems,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every user-scope agent definition. A directory that is not there is the
|
|
148
|
+
* ordinary state of a machine whose owner has never written one, not a failure,
|
|
149
|
+
* so it answers with an empty list.
|
|
150
|
+
*/
|
|
151
|
+
export function listAgents({ dir = AGENTS_DIR } = {}) {
|
|
152
|
+
let entries;
|
|
153
|
+
try {
|
|
154
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return entries
|
|
160
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.'))
|
|
161
|
+
.map((entry) => describeAgent(dir, entry.name))
|
|
162
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Whether the directory itself exists, which is what tells an empty list from a missing one. */
|
|
166
|
+
export function agentsDirExists({ dir = AGENTS_DIR } = {}) {
|
|
167
|
+
try {
|
|
168
|
+
return fs.statSync(dir).isDirectory();
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fieldLine = (field) => new RegExp(`^${field}[ \\t]*:`);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Where a field that is not there yet should go: straight after the name or
|
|
178
|
+
* description when that line carries its value inline, and otherwise at the end
|
|
179
|
+
* of the block. Appending is always valid - a top-level key ends whatever nested
|
|
180
|
+
* block preceded it - so the nicer placement is only taken when it is provably
|
|
181
|
+
* safe, which rules out a folded or block scalar the insertion would land inside.
|
|
182
|
+
*/
|
|
183
|
+
function inlineScalarAt(block, key) {
|
|
184
|
+
const index = block.findIndex((line) => fieldLine(key).test(line));
|
|
185
|
+
if (index === -1) return -1;
|
|
186
|
+
const rest = block[index].slice(block[index].indexOf(':') + 1).trim();
|
|
187
|
+
if (!rest || rest.startsWith('|') || rest.startsWith('>')) return -1;
|
|
188
|
+
return index;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function insertionIndex(block) {
|
|
192
|
+
const description = block.findIndex((line) => fieldLine('description').test(line));
|
|
193
|
+
const inline = inlineScalarAt(block, 'description');
|
|
194
|
+
if (inline !== -1) return inline + 1;
|
|
195
|
+
if (description !== -1) return block.length;
|
|
196
|
+
|
|
197
|
+
const name = inlineScalarAt(block, 'name');
|
|
198
|
+
return name === -1 ? block.length : name + 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Rewrite one frontmatter field, leaving every other byte of the file alone:
|
|
203
|
+
* the body, the other fields, their order, their spacing and any comments among
|
|
204
|
+
* them. The same principle src/parse.mjs applies to MEMORY.md - edit the line
|
|
205
|
+
* that changed, never re-emit the document from a parse of it.
|
|
206
|
+
*/
|
|
207
|
+
export function rewriteAgentField(text, field, value) {
|
|
208
|
+
const lines = text.split('\n');
|
|
209
|
+
if (lines[0]?.trim() !== '---') {
|
|
210
|
+
throw new Error('This file has no frontmatter block, so there is nothing to set.');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let end = -1;
|
|
214
|
+
for (let i = 1; i < lines.length; i++) {
|
|
215
|
+
if (lines[i].trim() === '---') { end = i; break; }
|
|
216
|
+
}
|
|
217
|
+
if (end === -1) {
|
|
218
|
+
throw new Error('This file has an unterminated frontmatter block.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const block = lines.slice(1, end);
|
|
222
|
+
const at = block.findIndex((line) => fieldLine(field).test(line));
|
|
223
|
+
|
|
224
|
+
if (value === null) {
|
|
225
|
+
if (at === -1) return text;
|
|
226
|
+
block.splice(at, 1);
|
|
227
|
+
} else if (at === -1) {
|
|
228
|
+
block.splice(insertionIndex(block), 0, `${field}: ${value}`);
|
|
229
|
+
} else {
|
|
230
|
+
block[at] = `${field}: ${value}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return [lines[0], ...block, ...lines.slice(end)].join('\n');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normaliseAgentValue(field, value) {
|
|
237
|
+
const spec = AGENT_FIELDS[field];
|
|
238
|
+
if (value === null || value === undefined) return null;
|
|
239
|
+
if (typeof value !== 'string') throw new Error(`${field} takes a string, not ${typeof value}.`);
|
|
240
|
+
|
|
241
|
+
const trimmed = value.trim();
|
|
242
|
+
if (!trimmed || trimmed === spec.unset) return null;
|
|
243
|
+
if (known(field, trimmed)) return trimmed;
|
|
244
|
+
|
|
245
|
+
throw new Error(`"${trimmed}" is not a value ${field} accepts.`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Set or clear `model` or `effort` on one agent file, atomically, and answer with
|
|
250
|
+
* the refreshed list so the caller renders what is on disk rather than what it
|
|
251
|
+
* hoped it wrote.
|
|
252
|
+
*/
|
|
253
|
+
export function setAgentField(file, field, value, { dir = AGENTS_DIR } = {}) {
|
|
254
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_FIELDS, field)) {
|
|
255
|
+
throw new Error(`"${field}" is not a field this tool writes. Only model and effort are editable here.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const full = safeAgentPath(dir, file);
|
|
259
|
+
if (!fs.existsSync(full)) throw new Error(`${file} is no longer there - reload and try again.`);
|
|
260
|
+
|
|
261
|
+
const next = normaliseAgentValue(field, value);
|
|
262
|
+
const original = fs.readFileSync(full, 'utf8');
|
|
263
|
+
const rewritten = rewriteAgentField(original, field, next);
|
|
264
|
+
|
|
265
|
+
if (rewritten !== original) writeFileAtomic(dir, file, rewritten);
|
|
266
|
+
|
|
267
|
+
return listAgents({ dir });
|
|
268
|
+
}
|