claude-memory-admin 1.10.1 → 1.11.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 +57 -9
- package/package.json +1 -1
- package/public/state.mjs +2 -2
- package/public/styles.css +1 -1
- package/public/views/attribution.mjs +178 -0
- package/public/views/cost.mjs +53 -10
- package/public/views/environment.mjs +2 -0
- package/public/views/header.mjs +9 -0
- package/public/views/issue.mjs +24 -0
- package/public/views/stores.mjs +22 -0
- package/server.mjs +94 -9
- package/src/agents.mjs +94 -18
- package/src/attribution.mjs +152 -0
- package/src/checks.mjs +69 -0
- package/src/config.mjs +130 -0
- package/src/cost.mjs +3 -3
- package/src/instructions.mjs +51 -20
- package/src/liveSessions.mjs +3 -2
- package/src/model.mjs +2 -1
- package/src/parse.mjs +44 -10
- package/src/pathcheck.mjs +5 -0
- package/src/projects.mjs +43 -6
- package/src/settings.mjs +83 -17
- package/src/stores.mjs +78 -11
- package/src/toolrun.mjs +49 -9
package/public/views/header.mjs
CHANGED
|
@@ -23,6 +23,7 @@ const NAVIGATION = [
|
|
|
23
23
|
{ id: 'instructions', label: 'Instructions' },
|
|
24
24
|
{ id: 'settings', label: 'Settings' },
|
|
25
25
|
{ id: 'cost', label: 'Cost' },
|
|
26
|
+
{ id: 'attribution', label: 'Attribution' },
|
|
26
27
|
{ id: 'sessions', label: 'Sessions' },
|
|
27
28
|
{ id: 'tools', label: 'Tools' },
|
|
28
29
|
],
|
|
@@ -41,6 +42,7 @@ function segmentVisible(tab, segment, store) {
|
|
|
41
42
|
}
|
|
42
43
|
if (segment === 'instructions') return global || hasProjectDir(store);
|
|
43
44
|
if (segment === 'cost') return global;
|
|
45
|
+
if (segment === 'attribution') return global;
|
|
44
46
|
if (segment === 'tools') return state.tools.length > 0;
|
|
45
47
|
if (global) return false;
|
|
46
48
|
if (segment === 'sessions') return Boolean(store.sessions?.count);
|
|
@@ -56,11 +58,17 @@ export const costProblems = () => {
|
|
|
56
58
|
];
|
|
57
59
|
};
|
|
58
60
|
|
|
61
|
+
export const attributionProblems = () => state.aux.attribution?.settings?.problems || [];
|
|
62
|
+
|
|
59
63
|
function segmentBadge(id) {
|
|
60
64
|
if (id === 'cost') {
|
|
61
65
|
const problems = costProblems();
|
|
62
66
|
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
63
67
|
}
|
|
68
|
+
if (id === 'attribution') {
|
|
69
|
+
const problems = attributionProblems();
|
|
70
|
+
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
71
|
+
}
|
|
64
72
|
if (id === 'instructions') {
|
|
65
73
|
const problems = state.aux.instructions?.problems || [];
|
|
66
74
|
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
@@ -107,6 +115,7 @@ function tabBadge(tab, store) {
|
|
|
107
115
|
...(state.aux.instructions?.problems || []),
|
|
108
116
|
...(state.aux.settings?.problems || []),
|
|
109
117
|
...costProblems(),
|
|
118
|
+
...attributionProblems(),
|
|
110
119
|
];
|
|
111
120
|
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
112
121
|
}
|
package/public/views/issue.mjs
CHANGED
|
@@ -258,5 +258,29 @@ export function renderIssue(item, memories) {
|
|
|
258
258
|
);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
if (item.kind === 'agent-memory-inert') {
|
|
262
|
+
return issue(
|
|
263
|
+
'Auto memory is off, so this store is frozen',
|
|
264
|
+
`subagent memory is part of auto memory${item.setBy ? `, and ${item.setBy} turns it off` : ' and it is turned off'}. The memory: field on ${item.agentName} has no effect while that is the case: the agent starts with no memory instructions and no file tools, so nothing here is read and nothing new is written.`,
|
|
265
|
+
{ bad },
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (item.kind === 'agent-store-orphan') {
|
|
270
|
+
return issue(
|
|
271
|
+
'Nothing declares this store any more',
|
|
272
|
+
`${item.defined ? `${item.agentName} exists but no longer carries a memory: field` : `no agent named ${item.agentName} was found in any scope`}. A store is created by that field and outlives it, so this directory is still holding what it learned and no session will read it again.`,
|
|
273
|
+
{ bad },
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (item.kind === 'agent-store-scope-mismatch') {
|
|
278
|
+
return issue(
|
|
279
|
+
`${item.agentName} keeps its memory in the ${item.declaredScope} scope now`,
|
|
280
|
+
`this store is the ${item.scope} one${item.declaredBy ? `, but ${item.declaredBy} declares memory: ${item.declaredScope}` : ''}. The live store is the ${item.declaredScope} one; this is what the agent wrote before the field changed.`,
|
|
281
|
+
{ bad },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
261
285
|
return issue(item.kind, JSON.stringify(item));
|
|
262
286
|
}
|
package/public/views/stores.mjs
CHANGED
|
@@ -11,6 +11,26 @@ function storeSubtitle(store) {
|
|
|
11
11
|
return `${scope} · ${store.sublabel}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function agentMarker(store) {
|
|
15
|
+
if (!String(store.kind).startsWith('agent-') || !store.linkage) return null;
|
|
16
|
+
if (store.inert) {
|
|
17
|
+
return { text: 'inert', title: `Auto memory is off${store.inertBy ? ` (${store.inertBy})` : ''}, so the memory: field has no effect and this store is frozen.` };
|
|
18
|
+
}
|
|
19
|
+
if (store.linked) return null;
|
|
20
|
+
if (store.declaredScope) {
|
|
21
|
+
return {
|
|
22
|
+
text: 'moved',
|
|
23
|
+
title: `${store.agentName} now declares memory: ${store.declaredScope}, so the live store is elsewhere and this one is stale.`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
text: 'orphan',
|
|
28
|
+
title: store.defined
|
|
29
|
+
? `${store.agentName} exists but declares no memory: field any more, so nothing loads this store.`
|
|
30
|
+
: `No agent named ${store.agentName} was found in any scope, so nothing loads this store.`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
function issueTitle(store) {
|
|
15
35
|
const parts = [];
|
|
16
36
|
if (store.issueCount) parts.push(`${store.issueCount} to fix in Cleanup`);
|
|
@@ -32,6 +52,7 @@ function storeButton(store) {
|
|
|
32
52
|
const global = store.kind === 'global';
|
|
33
53
|
const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
|
|
34
54
|
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
55
|
+
const marker = agentMarker(store);
|
|
35
56
|
const active = state.activeSessions.filter((s) => s.storeId === store.id);
|
|
36
57
|
return node('button', {
|
|
37
58
|
class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
|
|
@@ -43,6 +64,7 @@ function storeButton(store) {
|
|
|
43
64
|
node('span', { class: ui.storeName, text: store.label }),
|
|
44
65
|
active.length ? node('span', { class: ui.dot('ok'), title: activeTitle(active) }) : null,
|
|
45
66
|
off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
|
|
67
|
+
marker ? node('span', { class: ui.offMarker, text: marker.text, title: marker.title }) : null,
|
|
46
68
|
global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
47
69
|
]),
|
|
48
70
|
node('span', { class: ui.storePath, text: storeSubtitle(store) }),
|
package/server.mjs
CHANGED
|
@@ -13,7 +13,8 @@ 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
15
|
import { costReport, writeUserSetting } from './src/cost.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { attributionReport, writeAttributionSetting } from './src/attribution.mjs';
|
|
17
|
+
import { AGENTS_DIR, AGENT_FIELDS, agentsDirExists, listAllAgents, setAgentField } from './src/agents.mjs';
|
|
17
18
|
import { listStores } from './src/stores.mjs';
|
|
18
19
|
import { forgetPath, rememberPath } from './src/pathcache.mjs';
|
|
19
20
|
import { searchAll } from './src/search.mjs';
|
|
@@ -140,6 +141,67 @@ const VERSION = (() => {
|
|
|
140
141
|
}
|
|
141
142
|
})();
|
|
142
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Every agent definition on the machine: the user directory plus the agents
|
|
146
|
+
* directory of each repository auto memory has already resolved a path for.
|
|
147
|
+
* Nothing here guesses at a repository that was not confirmed somewhere else.
|
|
148
|
+
*/
|
|
149
|
+
function allAgents() {
|
|
150
|
+
const projectPaths = listStores(ROOT)
|
|
151
|
+
.filter((store) => store.kind === 'auto' && store.pathExists)
|
|
152
|
+
.flatMap((store) => [store.path, ...(store.workingDirs || [])]);
|
|
153
|
+
return listAllAgents({ projectPaths });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Each subagent memory store next to the definition that asks for it, for the agents panel. */
|
|
157
|
+
function agentStoreLinks() {
|
|
158
|
+
return listStores(ROOT)
|
|
159
|
+
.filter((store) => String(store.kind).startsWith('agent-'))
|
|
160
|
+
.map((store) => ({
|
|
161
|
+
id: store.id,
|
|
162
|
+
kind: store.kind,
|
|
163
|
+
agentName: store.agentName,
|
|
164
|
+
projectPath: store.projectPath,
|
|
165
|
+
memoryCount: store.memoryCount,
|
|
166
|
+
declaredBy: store.declaredBy ?? null,
|
|
167
|
+
declaredScope: store.declaredScope ?? null,
|
|
168
|
+
linked: Boolean(store.linked),
|
|
169
|
+
defined: Boolean(store.defined),
|
|
170
|
+
inert: Boolean(store.inert),
|
|
171
|
+
inertBy: store.inertBy ?? null,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Hand the address to whatever opens a URL here, and never let that decide
|
|
177
|
+
* whether the server runs.
|
|
178
|
+
*
|
|
179
|
+
* `start` is a cmd.exe builtin rather than a program, so spawning it by name
|
|
180
|
+
* fails on Windows; the documented form is `cmd /c start "" <url>`, where the
|
|
181
|
+
* empty string is the window title `start` would otherwise read the URL as. On
|
|
182
|
+
* Linux `xdg-open` is simply missing on a minimal install and inside some
|
|
183
|
+
* containers. Either way the failure arrives as an async 'error' event, which
|
|
184
|
+
* with no listener is an uncaught exception that would take down a server that
|
|
185
|
+
* had already printed its address and was working perfectly well.
|
|
186
|
+
*/
|
|
187
|
+
function openBrowser(address) {
|
|
188
|
+
const [command, args] = process.platform === 'darwin'
|
|
189
|
+
? ['open', [address]]
|
|
190
|
+
: process.platform === 'win32'
|
|
191
|
+
? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', 'start', '""', address.replace(/&/g, '^&')]]
|
|
192
|
+
: ['xdg-open', [address]];
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
|
196
|
+
child.on('error', () => {
|
|
197
|
+
console.log('Could not open a browser automatically - open the address above yourself.');
|
|
198
|
+
});
|
|
199
|
+
child.unref();
|
|
200
|
+
} catch {
|
|
201
|
+
console.log('Could not open a browser automatically - open the address above yourself.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
143
205
|
function storeProjectDir(store) {
|
|
144
206
|
// The global store is the user scope itself, which no project owns.
|
|
145
207
|
if (store.kind === 'global') return null;
|
|
@@ -160,7 +222,7 @@ function storeProjectDir(store) {
|
|
|
160
222
|
* frontmatter fields in a named file inside ~/.claude/agents. Neither can reach
|
|
161
223
|
* an instruction file, which is what the rest of this guard exists to protect.
|
|
162
224
|
*/
|
|
163
|
-
const GLOBAL_WRITE_ACTIONS = new Set(['cost/setting', 'cost/agent']);
|
|
225
|
+
const GLOBAL_WRITE_ACTIONS = new Set(['cost/setting', 'cost/agent', 'attribution/setting']);
|
|
164
226
|
|
|
165
227
|
export function refuseWritesToGlobal(store, method, action = null) {
|
|
166
228
|
if (store.kind !== 'global' || method === 'GET') return;
|
|
@@ -328,13 +390,24 @@ async function handleApi(req, res, url) {
|
|
|
328
390
|
return sendJson(res, 400, { error: 'The cost settings are user-scope, and are edited from the Global entry.' });
|
|
329
391
|
}
|
|
330
392
|
|
|
393
|
+
// The Attribution segment. User scope only, for the same reason as Cost:
|
|
394
|
+
// it edits ~/.claude/settings.json, which applies to every session.
|
|
395
|
+
if ((action === 'attribution' || action.startsWith('attribution/')) && store.kind !== 'global') {
|
|
396
|
+
return sendJson(res, 400, { error: 'The attribution settings are user-scope, and are edited from the Global entry.' });
|
|
397
|
+
}
|
|
398
|
+
|
|
331
399
|
if (action === 'cost' && req.method === 'GET') {
|
|
332
400
|
return sendJson(res, 200, {
|
|
333
401
|
settings: costReport(),
|
|
334
|
-
|
|
402
|
+
// Project-scope definitions come along read-only: an agent with
|
|
403
|
+
// `memory: project` lives in a repository rather than in the user
|
|
404
|
+
// directory, and leaving it out would show its memory store as belonging
|
|
405
|
+
// to no agent at all.
|
|
406
|
+
agents: allAgents(),
|
|
335
407
|
agentsDir: AGENTS_DIR,
|
|
336
408
|
agentsDirExists: agentsDirExists(),
|
|
337
409
|
agentFields: AGENT_FIELDS,
|
|
410
|
+
agentStores: agentStoreLinks(),
|
|
338
411
|
});
|
|
339
412
|
}
|
|
340
413
|
|
|
@@ -346,8 +419,23 @@ async function handleApi(req, res, url) {
|
|
|
346
419
|
|
|
347
420
|
if (action === 'cost/agent' && req.method === 'POST') {
|
|
348
421
|
const body = await readBody(req);
|
|
349
|
-
|
|
350
|
-
return sendJson(res, 200, {
|
|
422
|
+
setAgentField(body.file, body.field, body.value ?? null);
|
|
423
|
+
return sendJson(res, 200, {
|
|
424
|
+
agents: allAgents(),
|
|
425
|
+
agentsDir: AGENTS_DIR,
|
|
426
|
+
agentsDirExists: agentsDirExists(),
|
|
427
|
+
agentStores: agentStoreLinks(),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (action === 'attribution' && req.method === 'GET') {
|
|
432
|
+
return sendJson(res, 200, { settings: attributionReport() });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (action === 'attribution/setting' && req.method === 'POST') {
|
|
436
|
+
const body = await readBody(req);
|
|
437
|
+
writeAttributionSetting(body.key, body.value ?? null);
|
|
438
|
+
return sendJson(res, 200, { settings: attributionReport() });
|
|
351
439
|
}
|
|
352
440
|
|
|
353
441
|
if (action === 'delete-preview' && req.method === 'POST') {
|
|
@@ -484,10 +572,7 @@ export function startServer({ port, root, open = true } = {}) {
|
|
|
484
572
|
const address = `http://localhost:${listenPort}`;
|
|
485
573
|
console.log(`Memory Admin -> ${address}`);
|
|
486
574
|
console.log(`Reading -> ${ROOT}`);
|
|
487
|
-
if (open && !process.env.NO_OPEN)
|
|
488
|
-
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
489
|
-
spawn(opener, [address], { stdio: 'ignore', detached: true }).unref();
|
|
490
|
-
}
|
|
575
|
+
if (open && !process.env.NO_OPEN) openBrowser(address);
|
|
491
576
|
});
|
|
492
577
|
return server;
|
|
493
578
|
}
|
package/src/agents.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Subagent definitions: agents/*.md, in the user scope and in each repository.
|
|
2
2
|
//
|
|
3
|
-
// Not to be confused with
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Not to be confused with agent-memory (src/stores.mjs), which is what those
|
|
4
|
+
// agents *remember*. This module is about what they *are*: a markdown file whose
|
|
5
|
+
// frontmatter names the agent and, optionally, pins the model and effort it runs
|
|
6
|
+
// at, and declares with `memory:` whether it keeps a memory directory at all.
|
|
7
|
+
// That last field is the link between the two: a store exists because some file
|
|
8
|
+
// here asked for it, and a store no file asks for any more is a store nothing
|
|
9
|
+
// loads. Pinning a summariser to Haiku while a reviewer stays on Opus is the
|
|
7
10
|
// finer-grained version of the CLAUDE_CODE_SUBAGENT_MODEL switch in src/cost.mjs,
|
|
8
11
|
// which - worth remembering when both are set - outranks everything here.
|
|
9
12
|
//
|
|
@@ -12,13 +15,21 @@
|
|
|
12
15
|
// somebody wrote on purpose, and this tool has no view on it.
|
|
13
16
|
|
|
14
17
|
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
16
18
|
import path from 'node:path';
|
|
17
19
|
|
|
20
|
+
import { canonicalPath, configPath } from './config.mjs';
|
|
18
21
|
import { parseFrontmatter } from './parse.mjs';
|
|
19
22
|
import { writeFileAtomic } from './mutate.mjs';
|
|
20
23
|
|
|
21
|
-
export const AGENTS_DIR =
|
|
24
|
+
export const AGENTS_DIR = configPath('agents');
|
|
25
|
+
export const PROJECT_AGENTS_DIR = path.join('.claude', 'agents');
|
|
26
|
+
|
|
27
|
+
/** The scopes `memory:` accepts, and the store kind each one produces. */
|
|
28
|
+
export const MEMORY_SCOPES = {
|
|
29
|
+
user: 'agent-user',
|
|
30
|
+
project: 'agent-project',
|
|
31
|
+
local: 'agent-local',
|
|
32
|
+
};
|
|
22
33
|
|
|
23
34
|
const MODEL_ID = /^claude-[A-Za-z0-9._[\]-]+$/;
|
|
24
35
|
|
|
@@ -55,6 +66,7 @@ export const AGENT_PROBLEM_SEVERITY = {
|
|
|
55
66
|
'name-mismatch': 'warn',
|
|
56
67
|
'unknown-model': 'warn',
|
|
57
68
|
'unknown-effort': 'warn',
|
|
69
|
+
'unknown-memory-scope': 'warn',
|
|
58
70
|
};
|
|
59
71
|
|
|
60
72
|
function known(field, value) {
|
|
@@ -92,9 +104,9 @@ export function safeAgentPath(dir, file) {
|
|
|
92
104
|
return full;
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
function describeAgent(dir, file) {
|
|
107
|
+
function describeAgent(dir, file, { scope = 'user', writable = true, projectPath = null } = {}) {
|
|
96
108
|
const full = path.join(dir, file);
|
|
97
|
-
const stem = file.replace(/\.md$/, '');
|
|
109
|
+
const stem = path.basename(file).replace(/\.md$/, '');
|
|
98
110
|
|
|
99
111
|
let text;
|
|
100
112
|
try {
|
|
@@ -102,11 +114,15 @@ function describeAgent(dir, file) {
|
|
|
102
114
|
} catch (err) {
|
|
103
115
|
return {
|
|
104
116
|
file,
|
|
117
|
+
scope,
|
|
118
|
+
writable: false,
|
|
119
|
+
projectPath,
|
|
105
120
|
name: stem,
|
|
106
121
|
description: '',
|
|
107
122
|
tools: '',
|
|
108
123
|
model: null,
|
|
109
124
|
effort: null,
|
|
125
|
+
memory: null,
|
|
110
126
|
bytes: 0,
|
|
111
127
|
problems: [{ kind: 'no-frontmatter', severity: 'bad', detail: err.message }],
|
|
112
128
|
};
|
|
@@ -117,6 +133,7 @@ function describeAgent(dir, file) {
|
|
|
117
133
|
const name = scalar('name') || stem;
|
|
118
134
|
const model = scalar('model') || null;
|
|
119
135
|
const effort = scalar('effort') || null;
|
|
136
|
+
const memory = scalar('memory') || null;
|
|
120
137
|
|
|
121
138
|
const problems = [];
|
|
122
139
|
const flag = (kind, detail) => problems.push({ kind, severity: AGENT_PROBLEM_SEVERITY[kind], detail });
|
|
@@ -129,26 +146,39 @@ function describeAgent(dir, file) {
|
|
|
129
146
|
if (scalar('name') && scalar('name') !== stem) flag('name-mismatch', `Named "${scalar('name')}" in a file called "${file}".`);
|
|
130
147
|
if (model && !known('model', model)) flag('unknown-model', `model: ${model} is neither an alias nor a claude- model name.`);
|
|
131
148
|
if (effort && !known('effort', effort)) flag('unknown-effort', `effort: ${effort} is not one of low, medium, high, xhigh or max.`);
|
|
149
|
+
if (memory && !Object.prototype.hasOwnProperty.call(MEMORY_SCOPES, memory)) {
|
|
150
|
+
flag('unknown-memory-scope', `memory: ${memory} is not one of user, project or local, so this agent keeps no memory directory.`);
|
|
151
|
+
}
|
|
132
152
|
}
|
|
133
153
|
|
|
134
154
|
return {
|
|
135
155
|
file,
|
|
156
|
+
scope,
|
|
157
|
+
// Only a plain .md at the top of the user directory is rewritable: writes go
|
|
158
|
+
// through safeAgentPath, which takes a bare basename, and nothing here has
|
|
159
|
+
// any business editing a file inside somebody's repository.
|
|
160
|
+
writable: writable && file === path.basename(file),
|
|
161
|
+
projectPath,
|
|
136
162
|
name,
|
|
137
163
|
description: scalar('description'),
|
|
138
164
|
tools: scalar('tools'),
|
|
139
165
|
model,
|
|
140
166
|
effort,
|
|
167
|
+
memory: memory && Object.prototype.hasOwnProperty.call(MEMORY_SCOPES, memory) ? memory : null,
|
|
168
|
+
memoryRaw: memory,
|
|
141
169
|
bytes: Buffer.byteLength(text, 'utf8'),
|
|
142
170
|
problems,
|
|
143
171
|
};
|
|
144
172
|
}
|
|
145
173
|
|
|
146
174
|
/**
|
|
147
|
-
* Every
|
|
148
|
-
*
|
|
149
|
-
*
|
|
175
|
+
* Every .md under an agents directory, including the subfolders people use to
|
|
176
|
+
* group them. Claude Code scans both agent roots recursively and identifies an
|
|
177
|
+
* agent by its `name` rather than by where the file sits, so a scan that stopped
|
|
178
|
+
* at the top level would miss agents that are running.
|
|
150
179
|
*/
|
|
151
|
-
|
|
180
|
+
function agentFiles(dir, prefix = '', depth = 0) {
|
|
181
|
+
if (depth > 4) return [];
|
|
152
182
|
let entries;
|
|
153
183
|
try {
|
|
154
184
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -156,12 +186,54 @@ export function listAgents({ dir = AGENTS_DIR } = {}) {
|
|
|
156
186
|
return [];
|
|
157
187
|
}
|
|
158
188
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
189
|
+
const files = [];
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
if (entry.name.startsWith('.')) continue;
|
|
192
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
193
|
+
if (entry.isDirectory()) files.push(...agentFiles(path.join(dir, entry.name), rel, depth + 1));
|
|
194
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) files.push(rel);
|
|
195
|
+
}
|
|
196
|
+
return files;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Every user-scope agent definition. A directory that is not there is the
|
|
201
|
+
* ordinary state of a machine whose owner has never written one, not a failure,
|
|
202
|
+
* so it answers with an empty list.
|
|
203
|
+
*/
|
|
204
|
+
export function listAgents({ dir = AGENTS_DIR } = {}) {
|
|
205
|
+
return agentFiles(dir)
|
|
206
|
+
.map((file) => describeAgent(dir, file, { scope: 'user', writable: true }))
|
|
207
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Project-scope definitions, from <repo>/.claude/agents. These are read and
|
|
212
|
+
* never written: they belong to a repository somebody else may share, and an
|
|
213
|
+
* agent with `memory: project` is normally defined here rather than in the user
|
|
214
|
+
* scope, so leaving them out would orphan the very stores this exists to explain.
|
|
215
|
+
*/
|
|
216
|
+
export function listProjectAgents(projectPath, { relative = PROJECT_AGENTS_DIR } = {}) {
|
|
217
|
+
if (!projectPath || !path.isAbsolute(projectPath)) return [];
|
|
218
|
+
const dir = path.join(projectPath, relative);
|
|
219
|
+
return agentFiles(dir)
|
|
220
|
+
.map((file) => describeAgent(dir, file, { scope: 'project', writable: false, projectPath }))
|
|
162
221
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
163
222
|
}
|
|
164
223
|
|
|
224
|
+
/** Every definition on the machine this tool can see: user scope plus each repository. */
|
|
225
|
+
export function listAllAgents({ dir = AGENTS_DIR, projectPaths = [] } = {}) {
|
|
226
|
+
const agents = listAgents({ dir });
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
for (const projectPath of projectPaths) {
|
|
229
|
+
const key = canonicalPath(projectPath || '');
|
|
230
|
+
if (!key || seen.has(key)) continue;
|
|
231
|
+
seen.add(key);
|
|
232
|
+
agents.push(...listProjectAgents(projectPath));
|
|
233
|
+
}
|
|
234
|
+
return agents;
|
|
235
|
+
}
|
|
236
|
+
|
|
165
237
|
/** Whether the directory itself exists, which is what tells an empty list from a missing one. */
|
|
166
238
|
export function agentsDirExists({ dir = AGENTS_DIR } = {}) {
|
|
167
239
|
try {
|
|
@@ -205,7 +277,11 @@ function insertionIndex(block) {
|
|
|
205
277
|
* that changed, never re-emit the document from a parse of it.
|
|
206
278
|
*/
|
|
207
279
|
export function rewriteAgentField(text, field, value) {
|
|
208
|
-
|
|
280
|
+
// Rejoin with whatever the file already used. A checkout on Windows is CRLF
|
|
281
|
+
// throughout, and splitting on \n then rejoining with it would leave every
|
|
282
|
+
// line this function did not touch ending \r\n and the one it did ending \n.
|
|
283
|
+
const eol = /\r\n/.test(text) ? '\r\n' : '\n';
|
|
284
|
+
const lines = text.split(/\r?\n/);
|
|
209
285
|
if (lines[0]?.trim() !== '---') {
|
|
210
286
|
throw new Error('This file has no frontmatter block, so there is nothing to set.');
|
|
211
287
|
}
|
|
@@ -230,7 +306,7 @@ export function rewriteAgentField(text, field, value) {
|
|
|
230
306
|
block[at] = `${field}: ${value}`;
|
|
231
307
|
}
|
|
232
308
|
|
|
233
|
-
return [lines[0], ...block, ...lines.slice(end)].join(
|
|
309
|
+
return [lines[0], ...block, ...lines.slice(end)].join(eol);
|
|
234
310
|
}
|
|
235
311
|
|
|
236
312
|
function normaliseAgentValue(field, value) {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The `attribution` block in ~/.claude/settings.json: the trailer Claude Code
|
|
2
|
+
// adds to commits, the line it adds to PR descriptions, and the session URL it
|
|
3
|
+
// appends to cloud/Remote Control commits. The second panel this tool writes to
|
|
4
|
+
// a settings file, alongside the Cost panel in cost.mjs.
|
|
5
|
+
//
|
|
6
|
+
// Unlike Cost's keys, `commit` and `pr` take arbitrary text rather than one of
|
|
7
|
+
// a handful of options, so there is no fixed option list here: a value is either
|
|
8
|
+
// unset (default text), `false` (hidden), or any string (a replacement).
|
|
9
|
+
// `sessionUrl` only ever takes `false` - there is no custom text for it.
|
|
10
|
+
|
|
11
|
+
import { setPath } from './cost.mjs';
|
|
12
|
+
import { writeFileAtomic } from './mutate.mjs';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
SETTINGS_SEVERITY,
|
|
18
|
+
USER_SETTINGS,
|
|
19
|
+
readJsonDetailed,
|
|
20
|
+
readPath,
|
|
21
|
+
settingsCandidates,
|
|
22
|
+
} from './settings.mjs';
|
|
23
|
+
|
|
24
|
+
export const ATTRIBUTION_KEYS = [
|
|
25
|
+
{
|
|
26
|
+
key: 'commit',
|
|
27
|
+
path: ['attribution', 'commit'],
|
|
28
|
+
label: 'attribution.commit',
|
|
29
|
+
title: 'Commit trailer',
|
|
30
|
+
detail: 'The co-authored-by trailer Claude Code adds to commits it makes. Hide it, or replace it with your own text.',
|
|
31
|
+
kind: 'text-or-hide',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: 'pr',
|
|
35
|
+
path: ['attribution', 'pr'],
|
|
36
|
+
label: 'attribution.pr',
|
|
37
|
+
title: 'Pull request line',
|
|
38
|
+
detail: 'The attribution line Claude Code adds to pull request descriptions it writes. Hide it, or replace it with your own text.',
|
|
39
|
+
kind: 'text-or-hide',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: 'sessionUrl',
|
|
43
|
+
path: ['attribution', 'sessionUrl'],
|
|
44
|
+
label: 'attribution.sessionUrl',
|
|
45
|
+
title: 'Session link',
|
|
46
|
+
detail: 'The claude.ai session link Claude Code appends to commits made from the cloud or Remote Control. It cannot be replaced, only omitted.',
|
|
47
|
+
kind: 'hide-only',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What a value means once it is checked: a string or `false` to write, or null
|
|
53
|
+
* to remove the key. Anything the key does not accept throws rather than being
|
|
54
|
+
* coerced, because a coerced value would be written to the user's settings file.
|
|
55
|
+
*/
|
|
56
|
+
export function normaliseAttributionValue(descriptor, value) {
|
|
57
|
+
if (value === null || value === undefined) return null;
|
|
58
|
+
|
|
59
|
+
if (descriptor.kind === 'hide-only') {
|
|
60
|
+
if (value === false) return false;
|
|
61
|
+
throw new Error(`${descriptor.label} only accepts false (to hide it) or unset.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (value === false) return false;
|
|
65
|
+
if (typeof value !== 'string') {
|
|
66
|
+
throw new Error(`${descriptor.label} takes a string or false, not ${Array.isArray(value) ? 'a list' : typeof value}.`);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every layer's value for all three keys, strongest first, in the same shape
|
|
73
|
+
* the Cost report uses so the two render identically.
|
|
74
|
+
*/
|
|
75
|
+
export function attributionReport(options = {}) {
|
|
76
|
+
const { userFile = USER_SETTINGS, ...target } = options;
|
|
77
|
+
|
|
78
|
+
const reads = settingsCandidates({ ...target, userFile }).map((candidate) => ({
|
|
79
|
+
...candidate,
|
|
80
|
+
...readJsonDetailed(candidate.file),
|
|
81
|
+
}));
|
|
82
|
+
const layers = reads.map(({ scope, file, status, error }) => ({ scope, file, status, error }));
|
|
83
|
+
|
|
84
|
+
const keys = ATTRIBUTION_KEYS.map((descriptor) => {
|
|
85
|
+
const values = reads
|
|
86
|
+
.filter((read) => read.status === 'ok' && readPath(read.data, descriptor.path) !== undefined)
|
|
87
|
+
.map((read) => ({
|
|
88
|
+
scope: read.scope,
|
|
89
|
+
file: read.file,
|
|
90
|
+
value: readPath(read.data, descriptor.path),
|
|
91
|
+
wins: false,
|
|
92
|
+
}));
|
|
93
|
+
if (values.length) values[0].wins = true;
|
|
94
|
+
const winner = values[0] || null;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
key: descriptor.key,
|
|
98
|
+
label: descriptor.label,
|
|
99
|
+
title: descriptor.title,
|
|
100
|
+
detail: descriptor.detail,
|
|
101
|
+
kind: descriptor.kind,
|
|
102
|
+
values,
|
|
103
|
+
effective: winner ? { value: winner.value, scope: winner.scope, file: winner.file } : null,
|
|
104
|
+
shadowedByStronger: Boolean(winner && winner.scope !== 'user'),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const userRead = reads.find((read) => read.file === userFile);
|
|
109
|
+
const problems = layers
|
|
110
|
+
.filter((layer) => layer.status !== 'ok' && layer.status !== 'absent')
|
|
111
|
+
.map((layer) => ({
|
|
112
|
+
kind: layer.status,
|
|
113
|
+
severity: SETTINGS_SEVERITY[layer.status] || 'warn',
|
|
114
|
+
scope: layer.scope,
|
|
115
|
+
file: layer.file,
|
|
116
|
+
detail: layer.error,
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
keys,
|
|
121
|
+
layers,
|
|
122
|
+
problems,
|
|
123
|
+
userFile,
|
|
124
|
+
writable: !userRead || userRead.status === 'ok' || userRead.status === 'absent',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Write one key to the user settings file, atomically, and answer with the value
|
|
130
|
+
* that landed. A file that exists but does not parse is refused outright.
|
|
131
|
+
*/
|
|
132
|
+
export function writeAttributionSetting(key, value, options = {}) {
|
|
133
|
+
const { file = USER_SETTINGS } = options;
|
|
134
|
+
|
|
135
|
+
const descriptor = ATTRIBUTION_KEYS.find((entry) => entry.key === key);
|
|
136
|
+
if (!descriptor) throw new Error(`"${key}" is not a setting this tool writes.`);
|
|
137
|
+
|
|
138
|
+
const next = normaliseAttributionValue(descriptor, value);
|
|
139
|
+
|
|
140
|
+
const read = readJsonDetailed(file);
|
|
141
|
+
if (read.status !== 'ok' && read.status !== 'absent') {
|
|
142
|
+
throw new Error(`Refusing to write ${file}: ${read.error || read.status}. Rewriting a file this tool cannot parse would drop the settings it cannot see, so fix the file by hand first.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const data = read.status === 'ok' ? read.data : {};
|
|
146
|
+
setPath(data, descriptor.path, next);
|
|
147
|
+
|
|
148
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
149
|
+
writeFileAtomic(path.dirname(file), path.basename(file), `${JSON.stringify(data, null, 2)}\n`);
|
|
150
|
+
|
|
151
|
+
return next;
|
|
152
|
+
}
|