claude-memory-admin 1.9.0 → 1.10.1
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/state.mjs +2 -2
- package/public/store.mjs +8 -4
- package/public/styles.css +1 -1
- package/public/ui.mjs +14 -0
- package/public/views/cost.mjs +231 -0
- package/public/views/environment.mjs +2 -0
- package/public/views/header.mjs +17 -1
- package/public/views/tools.mjs +37 -9
- package/server.mjs +48 -10
- package/src/agents.mjs +268 -0
- package/src/cost.mjs +277 -0
- package/src/mutate.mjs +7 -3
- package/src/settings.mjs +30 -3
package/src/cost.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// The two settings keys that decide what a session costs, and the only two keys
|
|
2
|
+
// this tool ever writes.
|
|
3
|
+
//
|
|
4
|
+
// Everything else in the app reports; this module changes ~/.claude/settings.json.
|
|
5
|
+
// That is a deliberate exception, so the surface is kept as narrow as it can be:
|
|
6
|
+
// a closed list of keys, a closed list of values per key, the user scope only,
|
|
7
|
+
// and a hard refusal to rewrite a settings file that did not parse - overwriting
|
|
8
|
+
// one would silently drop every setting this tool could not read.
|
|
9
|
+
//
|
|
10
|
+
// The two keys:
|
|
11
|
+
//
|
|
12
|
+
// env.CLAUDE_CODE_SUBAGENT_MODEL the model every subagent, agent-team member
|
|
13
|
+
// and workflow agent runs on. It outranks both
|
|
14
|
+
// the model asked for at the call site and the
|
|
15
|
+
// `model:` line in an agent file.
|
|
16
|
+
// outputStyle how Claude writes back in the main
|
|
17
|
+
// conversation. Subagents run their own system
|
|
18
|
+
// prompt and are untouched by it.
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { parseFrontmatter } from './parse.mjs';
|
|
25
|
+
import { writeFileAtomic } from './mutate.mjs';
|
|
26
|
+
import {
|
|
27
|
+
SETTINGS_SEVERITY,
|
|
28
|
+
USER_SETTINGS,
|
|
29
|
+
readJsonDetailed,
|
|
30
|
+
readPath,
|
|
31
|
+
settingsCandidates,
|
|
32
|
+
} from './settings.mjs';
|
|
33
|
+
|
|
34
|
+
export const OUTPUT_STYLES_DIR = path.join(os.homedir(), '.claude', 'output-styles');
|
|
35
|
+
|
|
36
|
+
// A full model name is documented as acceptable wherever an alias is, and this
|
|
37
|
+
// tool has no business deciding which ones exist. The shape is checked so a
|
|
38
|
+
// typo does not land in a settings file unnoticed; the value is not resolved.
|
|
39
|
+
const MODEL_ID = /^claude-[A-Za-z0-9._[\]-]+$/;
|
|
40
|
+
|
|
41
|
+
export const COST_KEYS = [
|
|
42
|
+
{
|
|
43
|
+
key: 'subagentModel',
|
|
44
|
+
path: ['env', 'CLAUDE_CODE_SUBAGENT_MODEL'],
|
|
45
|
+
label: 'CLAUDE_CODE_SUBAGENT_MODEL',
|
|
46
|
+
title: 'Subagent model',
|
|
47
|
+
detail: 'The model every subagent, agent-team member and workflow agent runs on. It overrides the model asked for at the call site and the model: line in an agent file both, so it is the one switch that moves all of them at once. Search and summary work rarely needs more than Haiku.',
|
|
48
|
+
envVar: 'CLAUDE_CODE_SUBAGENT_MODEL',
|
|
49
|
+
unset: 'inherit',
|
|
50
|
+
allowModelId: true,
|
|
51
|
+
custom: null,
|
|
52
|
+
options: [
|
|
53
|
+
{ value: null, label: 'inherit', note: 'unset - each agent resolves its own model' },
|
|
54
|
+
{ value: 'haiku', label: 'haiku', note: 'cheapest' },
|
|
55
|
+
{ value: 'sonnet', label: 'sonnet' },
|
|
56
|
+
{ value: 'opus', label: 'opus' },
|
|
57
|
+
{ value: 'fable', label: 'fable', note: 'dearest' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: 'outputStyle',
|
|
62
|
+
path: ['outputStyle'],
|
|
63
|
+
label: 'outputStyle',
|
|
64
|
+
title: 'Output style',
|
|
65
|
+
detail: 'How Claude writes back. Concise leads with the result and drops the narration, which cuts output tokens on every turn; Explanatory and Learning add to them by design. This is part of the system prompt, so a change lands on /clear or the next session, and it reaches the main conversation only.',
|
|
66
|
+
envVar: null,
|
|
67
|
+
unset: 'Default',
|
|
68
|
+
allowModelId: false,
|
|
69
|
+
custom: 'outputStyles',
|
|
70
|
+
options: [
|
|
71
|
+
{ value: null, label: 'Default', note: 'unset' },
|
|
72
|
+
{ value: 'Concise', label: 'Concise', note: 'fewest output tokens' },
|
|
73
|
+
{ value: 'Proactive', label: 'Proactive' },
|
|
74
|
+
{ value: 'Explanatory', label: 'Explanatory', note: 'longer answers' },
|
|
75
|
+
{ value: 'Learning', label: 'Learning', note: 'longer answers' },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Custom output styles in the user scope. The file name is the style name unless
|
|
82
|
+
* the frontmatter overrides it, which is the rule Claude Code applies, and a
|
|
83
|
+
* directory that is not there is the normal case rather than a failure.
|
|
84
|
+
*/
|
|
85
|
+
export function listOutputStyles({ dir = OUTPUT_STYLES_DIR } = {}) {
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return entries
|
|
94
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
95
|
+
.map((entry) => {
|
|
96
|
+
const file = path.join(dir, entry.name);
|
|
97
|
+
let named = '';
|
|
98
|
+
try {
|
|
99
|
+
named = String(parseFrontmatter(fs.readFileSync(file, 'utf8')).data.name || '').trim();
|
|
100
|
+
} catch { /* an unreadable style still has a name: its file */ }
|
|
101
|
+
return { name: named || entry.name.replace(/\.md$/, ''), file };
|
|
102
|
+
})
|
|
103
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The descriptor's own options, plus any custom output styles found on disk. */
|
|
107
|
+
function optionsFor(descriptor, outputStyles) {
|
|
108
|
+
const options = descriptor.options.map((option) => ({ ...option }));
|
|
109
|
+
if (descriptor.custom === 'outputStyles') {
|
|
110
|
+
for (const style of outputStyles) {
|
|
111
|
+
if (!options.some((option) => option.value === style.name)) {
|
|
112
|
+
options.push({ value: style.name, label: style.name, note: 'custom' });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return options;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a value means once it is checked: a string to write, or null to remove
|
|
121
|
+
* the key. Anything the key does not accept throws rather than being coerced,
|
|
122
|
+
* because a coerced value would be written to the user's settings file.
|
|
123
|
+
*/
|
|
124
|
+
export function normaliseCostValue(descriptor, value, outputStyles = []) {
|
|
125
|
+
if (value === null || value === undefined) return null;
|
|
126
|
+
if (typeof value !== 'string') {
|
|
127
|
+
throw new Error(`${descriptor.label} takes a string, not ${Array.isArray(value) ? 'a list' : typeof value}.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const trimmed = value.trim();
|
|
131
|
+
if (!trimmed || trimmed === descriptor.unset) return null;
|
|
132
|
+
|
|
133
|
+
const allowed = optionsFor(descriptor, outputStyles)
|
|
134
|
+
.map((option) => option.value)
|
|
135
|
+
.filter((option) => option !== null);
|
|
136
|
+
if (allowed.includes(trimmed)) return trimmed;
|
|
137
|
+
if (descriptor.allowModelId && MODEL_ID.test(trimmed)) return trimmed;
|
|
138
|
+
|
|
139
|
+
throw new Error(`"${trimmed}" is not a value ${descriptor.label} accepts.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Set or remove a nested key, pruning a parent object the removal emptied. A
|
|
144
|
+
* leftover `"env": {}` is harmless but reads as a setting that is still there,
|
|
145
|
+
* which is exactly the confusion this panel exists to remove.
|
|
146
|
+
*/
|
|
147
|
+
function setPath(data, keyPath, value) {
|
|
148
|
+
const [head, ...rest] = keyPath;
|
|
149
|
+
|
|
150
|
+
if (!rest.length) {
|
|
151
|
+
if (value === null) delete data[head];
|
|
152
|
+
else data[head] = value;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const child = data[head];
|
|
157
|
+
const usable = child && typeof child === 'object' && !Array.isArray(child);
|
|
158
|
+
|
|
159
|
+
if (value === null) {
|
|
160
|
+
if (!usable) return;
|
|
161
|
+
setPath(child, rest, null);
|
|
162
|
+
if (!Object.keys(child).length) delete data[head];
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!usable) data[head] = {};
|
|
167
|
+
setPath(data[head], rest, value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Every layer's value for both keys, strongest first, in the same shape the
|
|
172
|
+
* Settings report uses so the two render identically.
|
|
173
|
+
*
|
|
174
|
+
* `shadowedByStronger` is the one thing this adds: the user file is the weakest
|
|
175
|
+
* of the five, so a value set anywhere else means a write here changes nothing.
|
|
176
|
+
* Saying so beforehand is cheaper than letting someone watch a setting not take.
|
|
177
|
+
*/
|
|
178
|
+
export function costReport(options = {}) {
|
|
179
|
+
const {
|
|
180
|
+
styleDir = OUTPUT_STYLES_DIR,
|
|
181
|
+
env = process.env,
|
|
182
|
+
userFile = USER_SETTINGS,
|
|
183
|
+
...target
|
|
184
|
+
} = options;
|
|
185
|
+
|
|
186
|
+
const reads = settingsCandidates({ ...target, userFile }).map((candidate) => ({
|
|
187
|
+
...candidate,
|
|
188
|
+
...readJsonDetailed(candidate.file),
|
|
189
|
+
}));
|
|
190
|
+
const layers = reads.map(({ scope, file, status, error }) => ({ scope, file, status, error }));
|
|
191
|
+
const outputStyles = listOutputStyles({ dir: styleDir });
|
|
192
|
+
|
|
193
|
+
const keys = COST_KEYS.map((descriptor) => {
|
|
194
|
+
const values = reads
|
|
195
|
+
.filter((read) => read.status === 'ok' && readPath(read.data, descriptor.path) !== undefined)
|
|
196
|
+
.map((read) => ({
|
|
197
|
+
scope: read.scope,
|
|
198
|
+
file: read.file,
|
|
199
|
+
value: readPath(read.data, descriptor.path),
|
|
200
|
+
wins: false,
|
|
201
|
+
}));
|
|
202
|
+
if (values.length) values[0].wins = true;
|
|
203
|
+
const winner = values[0] || null;
|
|
204
|
+
|
|
205
|
+
const choices = optionsFor(descriptor, outputStyles);
|
|
206
|
+
// A value the picker cannot offer would be silently replaced the moment
|
|
207
|
+
// anyone touched the control, so it is pinned into the list instead.
|
|
208
|
+
if (winner && typeof winner.value === 'string' && winner.value.trim()
|
|
209
|
+
&& !choices.some((option) => option.value === winner.value)) {
|
|
210
|
+
choices.push({ value: winner.value, label: winner.value, note: 'set in your settings' });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const envValue = descriptor.envVar ? env[descriptor.envVar] ?? null : null;
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
key: descriptor.key,
|
|
217
|
+
label: descriptor.label,
|
|
218
|
+
title: descriptor.title,
|
|
219
|
+
detail: descriptor.detail,
|
|
220
|
+
unset: descriptor.unset,
|
|
221
|
+
options: choices,
|
|
222
|
+
values,
|
|
223
|
+
effective: winner ? { value: winner.value, scope: winner.scope, file: winner.file } : null,
|
|
224
|
+
shadowedByStronger: Boolean(winner && winner.scope !== 'user'),
|
|
225
|
+
envVar: descriptor.envVar,
|
|
226
|
+
envValue,
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const userRead = reads.find((read) => read.file === userFile);
|
|
231
|
+
const problems = layers
|
|
232
|
+
.filter((layer) => layer.status !== 'ok' && layer.status !== 'absent')
|
|
233
|
+
.map((layer) => ({
|
|
234
|
+
kind: layer.status,
|
|
235
|
+
severity: SETTINGS_SEVERITY[layer.status] || 'warn',
|
|
236
|
+
scope: layer.scope,
|
|
237
|
+
file: layer.file,
|
|
238
|
+
detail: layer.error,
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
keys,
|
|
243
|
+
layers,
|
|
244
|
+
problems,
|
|
245
|
+
outputStyles,
|
|
246
|
+
userFile,
|
|
247
|
+
// False when the file this panel would write is there but unusable. The
|
|
248
|
+
// controls are disabled rather than allowed to fail on save.
|
|
249
|
+
writable: !userRead || userRead.status === 'ok' || userRead.status === 'absent',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Write one key to the user settings file, atomically, and answer with the value
|
|
255
|
+
* that landed. A file that exists but does not parse is refused outright.
|
|
256
|
+
*/
|
|
257
|
+
export function writeUserSetting(key, value, options = {}) {
|
|
258
|
+
const { file = USER_SETTINGS, styleDir = OUTPUT_STYLES_DIR } = options;
|
|
259
|
+
|
|
260
|
+
const descriptor = COST_KEYS.find((entry) => entry.key === key);
|
|
261
|
+
if (!descriptor) throw new Error(`"${key}" is not a setting this tool writes.`);
|
|
262
|
+
|
|
263
|
+
const next = normaliseCostValue(descriptor, value, listOutputStyles({ dir: styleDir }));
|
|
264
|
+
|
|
265
|
+
const read = readJsonDetailed(file);
|
|
266
|
+
if (read.status !== 'ok' && read.status !== 'absent') {
|
|
267
|
+
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.`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const data = read.status === 'ok' ? read.data : {};
|
|
271
|
+
setPath(data, descriptor.path, next);
|
|
272
|
+
|
|
273
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
274
|
+
writeFileAtomic(path.dirname(file), path.basename(file), `${JSON.stringify(data, null, 2)}\n`);
|
|
275
|
+
|
|
276
|
+
return next;
|
|
277
|
+
}
|
package/src/mutate.mjs
CHANGED
|
@@ -78,11 +78,15 @@ function readIndex(dir) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
* Replace
|
|
81
|
+
* Replace a file atomically: write a sibling temp file, fsync it, rename over
|
|
82
82
|
* the target. A .backup copy is kept for the duration and restored if anything
|
|
83
|
-
* throws, so a crash can never leave a truncated
|
|
83
|
+
* throws, so a crash can never leave a truncated file behind.
|
|
84
|
+
*
|
|
85
|
+
* Exported because the settings and agent writers outside this module need the
|
|
86
|
+
* same guarantee, and a second implementation of it is a second chance to get
|
|
87
|
+
* the crash path wrong.
|
|
84
88
|
*/
|
|
85
|
-
function writeFileAtomic(dir, name, text) {
|
|
89
|
+
export function writeFileAtomic(dir, name, text) {
|
|
86
90
|
const target = path.join(dir, name);
|
|
87
91
|
const tmp = path.join(dir, `.${name}.tmp-${process.pid}`);
|
|
88
92
|
const backup = path.join(dir, `.${name}.backup`);
|
package/src/settings.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export function managedSettingsFiles() {
|
|
|
59
59
|
return files;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function readJsonDetailed(file) {
|
|
62
|
+
export function readJsonDetailed(file) {
|
|
63
63
|
let raw;
|
|
64
64
|
try {
|
|
65
65
|
raw = fs.readFileSync(file, 'utf8');
|
|
@@ -81,7 +81,7 @@ function readJsonDetailed(file) {
|
|
|
81
81
|
return { status: 'ok', data: parsed, error: null };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function settingsCandidates({ projectDir = null, settingsFile = null } = {}) {
|
|
84
|
+
export function settingsCandidates({ projectDir = null, settingsFile = null, userFile = USER_SETTINGS } = {}) {
|
|
85
85
|
const candidates = [];
|
|
86
86
|
for (const file of managedSettingsFiles()) candidates.push({ scope: 'managed', file });
|
|
87
87
|
if (settingsFile) candidates.push({ scope: 'settings-flag', file: settingsFile });
|
|
@@ -89,7 +89,7 @@ function settingsCandidates({ projectDir = null, settingsFile = null } = {}) {
|
|
|
89
89
|
candidates.push({ scope: 'local', file: path.join(projectDir, '.claude', 'settings.local.json') });
|
|
90
90
|
candidates.push({ scope: 'project', file: path.join(projectDir, '.claude', 'settings.json') });
|
|
91
91
|
}
|
|
92
|
-
candidates.push({ scope: 'user', file:
|
|
92
|
+
candidates.push({ scope: 'user', file: userFile });
|
|
93
93
|
return candidates;
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -122,6 +122,33 @@ export function lookup(layers, key) {
|
|
|
122
122
|
return null;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Read a nested key out of one settings object, without ever inventing a level
|
|
127
|
+
* that is not there. `env` in particular is routinely a string or absent in a
|
|
128
|
+
* hand-edited file, and descending into it blindly would throw where the
|
|
129
|
+
* documented behaviour is simply that the layer does not set the key.
|
|
130
|
+
*/
|
|
131
|
+
export function readPath(data, keyPath) {
|
|
132
|
+
let current = data;
|
|
133
|
+
for (const key of keyPath) {
|
|
134
|
+
if (!current || typeof current !== 'object' || Array.isArray(current)) return undefined;
|
|
135
|
+
if (!Object.prototype.hasOwnProperty.call(current, key)) return undefined;
|
|
136
|
+
current = current[key];
|
|
137
|
+
}
|
|
138
|
+
return current;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `lookup` for a nested key, such as ['env', 'CLAUDE_CODE_SUBAGENT_MODEL']. */
|
|
142
|
+
export function lookupPath(layers, keyPath) {
|
|
143
|
+
for (const layer of layers) {
|
|
144
|
+
const value = readPath(layer.data, keyPath);
|
|
145
|
+
if (value !== undefined) {
|
|
146
|
+
return { value, scope: layer.scope, file: layer.file };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
125
152
|
export function expandHome(value) {
|
|
126
153
|
if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
|
|
127
154
|
return value;
|