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/src/settings.mjs
CHANGED
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
// -> .claude/settings.json -> ~/.claude/settings.json
|
|
12
12
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
|
-
import os from 'node:os';
|
|
15
14
|
import path from 'node:path';
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
import { configPath, configSource, expandHome, fixedProjectDirName, isAbsolutePath } from './config.mjs';
|
|
17
|
+
|
|
18
|
+
export const USER_SETTINGS = configPath('settings.json');
|
|
19
|
+
|
|
20
|
+
// Re-exported because this is where every caller has always imported it from,
|
|
21
|
+
// and because settings values are the main thing that arrives `~/`-prefixed.
|
|
22
|
+
export { expandHome };
|
|
18
23
|
|
|
19
24
|
/** Default when nothing sets cleanupPeriodDays; transcripts older than this are swept. */
|
|
20
25
|
export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
|
|
@@ -24,6 +29,7 @@ export const SETTINGS_SEVERITY = {
|
|
|
24
29
|
unreadable: 'bad',
|
|
25
30
|
'not-object': 'bad',
|
|
26
31
|
'invalid-auto-memory-directory': 'bad',
|
|
32
|
+
'invalid-config-dir': 'bad',
|
|
27
33
|
};
|
|
28
34
|
|
|
29
35
|
export function summariseSettings(problems) {
|
|
@@ -149,11 +155,6 @@ export function lookupPath(layers, keyPath) {
|
|
|
149
155
|
return null;
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
export function expandHome(value) {
|
|
153
|
-
if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
|
|
154
|
-
return value;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
158
|
/**
|
|
158
159
|
* Where the auto memory store lives, per settings.
|
|
159
160
|
*
|
|
@@ -171,12 +172,55 @@ export function resolveMemoryDirectory(options = {}) {
|
|
|
171
172
|
|
|
172
173
|
const raw = typeof found.value === 'string' ? found.value.trim() : '';
|
|
173
174
|
if (!raw) return null;
|
|
174
|
-
|
|
175
|
+
// Accepted on every platform, not only the one this is running on: a settings
|
|
176
|
+
// file is routinely shared between machines, and a Windows path read on macOS
|
|
177
|
+
// is a path this tool cannot open but must still report as intentional.
|
|
178
|
+
const rooted = raw.startsWith('~/') || raw.startsWith('~\\')
|
|
179
|
+
|| isAbsolutePath(raw, 'win32') || isAbsolutePath(raw, 'linux');
|
|
180
|
+
if (!rooted) {
|
|
175
181
|
return { ...found, raw, path: null, invalid: 'not an absolute or ~/ path' };
|
|
176
182
|
}
|
|
177
183
|
return { ...found, raw, path: expandHome(raw), invalid: null };
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
export const DISABLE_AUTO_MEMORY = 'CLAUDE_CODE_DISABLE_AUTO_MEMORY';
|
|
187
|
+
|
|
188
|
+
/** The env-var convention Claude Code uses: set, and not "0" or "false". */
|
|
189
|
+
function truthyEnv(value) {
|
|
190
|
+
if (value === null || value === undefined) return false;
|
|
191
|
+
const text = String(value).trim();
|
|
192
|
+
return Boolean(text) && text !== '0' && text !== 'false';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* CLAUDE_CODE_DISABLE_AUTO_MEMORY, from either place it can be set.
|
|
197
|
+
*
|
|
198
|
+
* The process environment is the obvious one, but settings files carry an `env`
|
|
199
|
+
* block whose entries a session exports before it starts, so the same switch is
|
|
200
|
+
* equally settable in any of the five layers. Reading only process.env reported
|
|
201
|
+
* auto memory as on for anyone who had turned it off in a file - the reverse of
|
|
202
|
+
* what this tool is for.
|
|
203
|
+
*/
|
|
204
|
+
export function disableAutoMemoryEnv({ layers = null, projectDir = null, env = process.env } = {}) {
|
|
205
|
+
const raw = env[DISABLE_AUTO_MEMORY];
|
|
206
|
+
if (truthyEnv(raw)) {
|
|
207
|
+
return { disabling: true, value: raw, scope: 'env', file: null };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const found = lookupPath(layers || settingsLayers({ projectDir }), ['env', DISABLE_AUTO_MEMORY]);
|
|
211
|
+
if (found && truthyEnv(found.value)) {
|
|
212
|
+
return { disabling: true, value: found.value, scope: found.scope, file: found.file };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const value = raw ?? (found ? found.value : null);
|
|
216
|
+
return {
|
|
217
|
+
disabling: false,
|
|
218
|
+
value: value === undefined ? null : value,
|
|
219
|
+
scope: raw !== undefined && raw !== null ? 'env' : found?.scope ?? null,
|
|
220
|
+
file: raw !== undefined && raw !== null ? null : found?.file ?? null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
180
224
|
/**
|
|
181
225
|
* Whether auto memory is on for a project, and what decided it.
|
|
182
226
|
*
|
|
@@ -186,12 +230,14 @@ export function resolveMemoryDirectory(options = {}) {
|
|
|
186
230
|
* local layer to read, so the answer is unknown rather than assumed.
|
|
187
231
|
*/
|
|
188
232
|
export function autoMemoryState({ projectDir = null } = {}) {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
233
|
+
const layers = settingsLayers({ projectDir });
|
|
234
|
+
|
|
235
|
+
const disabled = disableAutoMemoryEnv({ layers });
|
|
236
|
+
if (disabled.disabling) {
|
|
237
|
+
return { enabled: false, setBy: disabled.file || DISABLE_AUTO_MEMORY, scope: disabled.scope, known: true };
|
|
192
238
|
}
|
|
193
239
|
|
|
194
|
-
const found = lookup(
|
|
240
|
+
const found = lookup(layers, 'autoMemoryEnabled');
|
|
195
241
|
if (found && typeof found.value === 'boolean') {
|
|
196
242
|
return { enabled: found.value, setBy: found.file, scope: found.scope, known: true };
|
|
197
243
|
}
|
|
@@ -263,8 +309,8 @@ export function settingsReport(options = {}) {
|
|
|
263
309
|
const cleanup = keys.find((entry) => entry.key === 'cleanupPeriodDays');
|
|
264
310
|
cleanup.normalized = cleanupPeriodDays(options);
|
|
265
311
|
|
|
266
|
-
const
|
|
267
|
-
const
|
|
312
|
+
const disable = disableAutoMemoryEnv({ layers: reads.filter((read) => read.status === 'ok') });
|
|
313
|
+
const config = configSource();
|
|
268
314
|
|
|
269
315
|
const problems = layers
|
|
270
316
|
.filter((layer) => layer.status !== 'ok' && layer.status !== 'absent')
|
|
@@ -276,6 +322,16 @@ export function settingsReport(options = {}) {
|
|
|
276
322
|
detail: layer.error,
|
|
277
323
|
}));
|
|
278
324
|
|
|
325
|
+
if (config.invalid) {
|
|
326
|
+
problems.push({
|
|
327
|
+
kind: 'invalid-config-dir',
|
|
328
|
+
severity: SETTINGS_SEVERITY['invalid-config-dir'],
|
|
329
|
+
scope: 'env',
|
|
330
|
+
file: null,
|
|
331
|
+
detail: config.invalid,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
279
335
|
const directory = resolveMemoryDirectory(options);
|
|
280
336
|
if (directory && directory.invalid) {
|
|
281
337
|
problems.push({
|
|
@@ -292,9 +348,19 @@ export function settingsReport(options = {}) {
|
|
|
292
348
|
layers,
|
|
293
349
|
keys,
|
|
294
350
|
env: {
|
|
295
|
-
name:
|
|
296
|
-
value:
|
|
297
|
-
|
|
351
|
+
name: DISABLE_AUTO_MEMORY,
|
|
352
|
+
value: disable.value,
|
|
353
|
+
scope: disable.scope,
|
|
354
|
+
file: disable.file,
|
|
355
|
+
overrides: disable.disabling ? 'autoMemoryEnabled' : null,
|
|
356
|
+
},
|
|
357
|
+
configDir: {
|
|
358
|
+
name: 'CLAUDE_CONFIG_DIR',
|
|
359
|
+
path: config.path,
|
|
360
|
+
source: config.source,
|
|
361
|
+
value: config.raw,
|
|
362
|
+
invalid: config.invalid,
|
|
363
|
+
fixedProjectDirName: fixedProjectDirName(),
|
|
298
364
|
},
|
|
299
365
|
problems,
|
|
300
366
|
};
|
package/src/stores.mjs
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
// somewhere else first.
|
|
16
16
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
|
-
import os from 'node:os';
|
|
19
18
|
import path from 'node:path';
|
|
19
|
+
import { listAllAgents, MEMORY_SCOPES } from './agents.mjs';
|
|
20
|
+
import { canonicalPath, configDir, configPath, DEFAULT_CONFIG_DIR_NAME } from './config.mjs';
|
|
20
21
|
import { listProjects, memoryDir, projectsRoot, shortLabel } from './projects.mjs';
|
|
22
|
+
import { autoMemoryState } from './settings.mjs';
|
|
21
23
|
|
|
22
|
-
export const AGENT_USER_DIR =
|
|
24
|
+
export const AGENT_USER_DIR = configPath('agent-memory');
|
|
23
25
|
export const AGENT_PROJECT_DIR = path.join('.claude', 'agent-memory');
|
|
24
26
|
export const AGENT_LOCAL_DIR = path.join('.claude', 'agent-memory-local');
|
|
25
27
|
|
|
@@ -96,10 +98,15 @@ export function listAgentStores({ userDir = AGENT_USER_DIR, projectPaths = [] }
|
|
|
96
98
|
|
|
97
99
|
// A repository can be reached through more than one project entry (a worktree
|
|
98
100
|
// and its root), so the same directory must not be listed twice.
|
|
101
|
+
// Keyed on the canonical form: Windows and a default macOS volume are
|
|
102
|
+
// case-insensitive, so two spellings of one repository are one repository and
|
|
103
|
+
// would otherwise contribute the same agent store twice.
|
|
99
104
|
const seen = new Set();
|
|
100
105
|
for (const projectPath of projectPaths) {
|
|
101
|
-
if (!projectPath || !path.isAbsolute(projectPath)
|
|
102
|
-
|
|
106
|
+
if (!projectPath || !path.isAbsolute(projectPath)) continue;
|
|
107
|
+
const key = canonicalPath(projectPath);
|
|
108
|
+
if (seen.has(key)) continue;
|
|
109
|
+
seen.add(key);
|
|
103
110
|
for (const [kind, relative] of [['agent-project', AGENT_PROJECT_DIR], ['agent-local', AGENT_LOCAL_DIR]]) {
|
|
104
111
|
const parent = path.join(projectPath, relative);
|
|
105
112
|
for (const name of agentDirs(parent)) {
|
|
@@ -113,6 +120,60 @@ export function listAgentStores({ userDir = AGENT_USER_DIR, projectPaths = [] }
|
|
|
113
120
|
|| a.sublabel.localeCompare(b.sublabel));
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Join each subagent store to the definition that asks for it.
|
|
125
|
+
*
|
|
126
|
+
* A store directory on its own says nothing about whether a session still loads
|
|
127
|
+
* it: the `memory:` field in an agent file is what creates one, and that field
|
|
128
|
+
* can be changed to another scope or removed entirely without the directory it
|
|
129
|
+
* created ever going away. So the store is annotated with what the definitions
|
|
130
|
+
* actually say, and the cleanup checks in src/checks.mjs read those annotations
|
|
131
|
+
* rather than re-deriving them.
|
|
132
|
+
*
|
|
133
|
+
* The one rule that outranks all of it: subagent memory is part of auto memory,
|
|
134
|
+
* so when auto memory is off the `memory:` field has no effect at all - the
|
|
135
|
+
* agent launches with no memory instructions and no file tools, and every store
|
|
136
|
+
* on the machine is frozen where it stands.
|
|
137
|
+
*/
|
|
138
|
+
export function linkAgentStores(stores, agents, { autoMemory = null } = {}) {
|
|
139
|
+
const byName = new Map();
|
|
140
|
+
for (const agent of agents || []) {
|
|
141
|
+
const key = agent.name.toLowerCase();
|
|
142
|
+
if (!byName.has(key)) byName.set(key, []);
|
|
143
|
+
byName.get(key).push(agent);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return stores.map((store) => {
|
|
147
|
+
if (!store.kind.startsWith('agent-')) return store;
|
|
148
|
+
|
|
149
|
+
const candidates = (byName.get(store.agentName.toLowerCase()) || []).filter((agent) => {
|
|
150
|
+
// A project-scope definition only speaks for stores in its own repository.
|
|
151
|
+
if (agent.scope !== 'project' || !store.projectPath) return true;
|
|
152
|
+
return canonicalPath(agent.projectPath) === canonicalPath(store.projectPath);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const declaring = candidates.find((agent) => MEMORY_SCOPES[agent.memory] === store.kind) || null;
|
|
156
|
+
const other = declaring ? null : candidates.find((agent) => agent.memory) || null;
|
|
157
|
+
const inert = autoMemory ? autoMemory.enabled === false : false;
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
...store,
|
|
161
|
+
// Marks that the join actually ran. A store that was never linked knows
|
|
162
|
+
// nothing about its definitions, and "nothing is declared" and "nobody
|
|
163
|
+
// asked" have to stay distinguishable or the checks below report an
|
|
164
|
+
// orphan every time a caller builds a store on its own.
|
|
165
|
+
linkage: true,
|
|
166
|
+
declaredBy: declaring ? declaring.file : other ? other.file : null,
|
|
167
|
+
declaredScope: declaring ? declaring.memory : other ? other.memory : null,
|
|
168
|
+
declaringScope: declaring ? declaring.scope : other ? other.scope : null,
|
|
169
|
+
defined: candidates.length > 0,
|
|
170
|
+
linked: Boolean(declaring),
|
|
171
|
+
inert,
|
|
172
|
+
inertBy: inert ? autoMemory.setBy : null,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
116
177
|
/** An auto-memory project, in the shape the rest of the app expects of a store. */
|
|
117
178
|
function autoStore(project, root) {
|
|
118
179
|
return {
|
|
@@ -133,8 +194,7 @@ function autoStore(project, root) {
|
|
|
133
194
|
* `hasMemoryDir` false is what keeps it out of full-text search, which skips
|
|
134
195
|
* stores without one, and out of everything else that reads memory files.
|
|
135
196
|
*/
|
|
136
|
-
function globalStore(
|
|
137
|
-
const dir = path.join(home, '.claude');
|
|
197
|
+
function globalStore(dir) {
|
|
138
198
|
return {
|
|
139
199
|
id: storeId('global', dir),
|
|
140
200
|
kind: 'global',
|
|
@@ -154,19 +214,26 @@ function globalStore(home) {
|
|
|
154
214
|
* found under the project paths auto memory already resolved, so a repository this
|
|
155
215
|
* tool has never seen a session for contributes nothing.
|
|
156
216
|
*
|
|
157
|
-
*
|
|
158
|
-
* rather than the memory root, which
|
|
217
|
+
* The config directory is a parameter because the global entry is about where
|
|
218
|
+
* Claude Code keeps its own files rather than about the memory root, which
|
|
219
|
+
* --root can point somewhere else entirely. `home` is the older spelling of the
|
|
220
|
+
* same idea and still accepted, since a home directory names exactly one config
|
|
221
|
+
* directory when CLAUDE_CONFIG_DIR is not set.
|
|
159
222
|
*/
|
|
160
|
-
export function listStores(root = projectsRoot(), { home =
|
|
223
|
+
export function listStores(root = projectsRoot(), { home = null, dir = null, agents = null } = {}) {
|
|
224
|
+
const global = dir || (home ? path.join(home, DEFAULT_CONFIG_DIR_NAME) : configDir());
|
|
161
225
|
const projects = listProjects(root);
|
|
162
226
|
const projectPaths = projects
|
|
163
227
|
.filter((project) => project.pathExists)
|
|
164
228
|
.flatMap((project) => [project.path, ...(project.workingDirs || [])]);
|
|
165
229
|
|
|
230
|
+
const agentStores = listAgentStores({ projectPaths });
|
|
231
|
+
const definitions = agents || listAllAgents({ projectPaths });
|
|
232
|
+
|
|
166
233
|
return [
|
|
167
|
-
globalStore(
|
|
234
|
+
globalStore(global),
|
|
168
235
|
...projects.map((project) => autoStore(project, root)),
|
|
169
|
-
...
|
|
236
|
+
...linkAgentStores(agentStores, definitions, { autoMemory: autoMemoryState() }),
|
|
170
237
|
];
|
|
171
238
|
}
|
|
172
239
|
|
package/src/toolrun.mjs
CHANGED
|
@@ -9,20 +9,25 @@ export const num = (value) => (typeof value === 'number' && Number.isFinite(valu
|
|
|
9
9
|
export const text = (value) => (typeof value === 'string' ? value : '');
|
|
10
10
|
export const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : 0);
|
|
11
11
|
|
|
12
|
-
function candidateNames(binary, env) {
|
|
13
|
-
if (
|
|
14
|
-
return text(env.PATHEXT || '.EXE').split(';').filter(Boolean).map((ext) => binary + ext.toLowerCase());
|
|
12
|
+
function candidateNames(binary, env, platform) {
|
|
13
|
+
if (platform !== 'win32') return [binary];
|
|
14
|
+
return text(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map((ext) => binary + ext.toLowerCase());
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function findBinary(binary, env = process.env) {
|
|
17
|
+
export function findBinary(binary, env = process.env, platform = process.platform) {
|
|
18
|
+
// PATH is split on this process's delimiter, not the target platform's, since
|
|
19
|
+
// the PATH being searched is the one this process actually has.
|
|
18
20
|
const dirs = text(env.PATH).split(path.delimiter).filter(Boolean);
|
|
19
|
-
const names = candidateNames(binary, env);
|
|
21
|
+
const names = candidateNames(binary, env, platform);
|
|
20
22
|
for (const dir of dirs) {
|
|
21
23
|
for (const name of names) {
|
|
22
24
|
const candidate = path.join(dir, name);
|
|
23
25
|
try {
|
|
24
26
|
if (!fs.statSync(candidate).isFile()) continue;
|
|
25
|
-
|
|
27
|
+
// Windows has no execute bit, and Node answers X_OK there from the
|
|
28
|
+
// file's existence alone. Asking for it is meaningless rather than
|
|
29
|
+
// harmless: it is the check that would reject a perfectly good .cmd.
|
|
30
|
+
if (platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK);
|
|
26
31
|
return { found: true, path: candidate };
|
|
27
32
|
} catch {
|
|
28
33
|
continue;
|
|
@@ -32,10 +37,45 @@ export function findBinary(binary, env = process.env) {
|
|
|
32
37
|
return { found: false, path: null };
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
/**
|
|
41
|
+
* How to actually start a program on Windows.
|
|
42
|
+
*
|
|
43
|
+
* npm installs `rtk` and `ccusage` as `rtk.cmd`, and a batch file is not a
|
|
44
|
+
* program: execFile refuses to run one outright, and would not find it by bare
|
|
45
|
+
* name in the first place, since only the shell applies PATHEXT. So the resolved
|
|
46
|
+
* file is handed to cmd.exe the way Node's own shell support does - `/d /s /c`
|
|
47
|
+
* with one pre-quoted command string and verbatim arguments - and the arguments
|
|
48
|
+
* are quoted here rather than trusted to survive a second round of parsing.
|
|
49
|
+
*/
|
|
50
|
+
export function windowsInvocation(binary, args, env = process.env) {
|
|
51
|
+
const found = findBinary(binary, env, 'win32');
|
|
52
|
+
const target = found.found ? found.path : binary;
|
|
53
|
+
if (!/\.(cmd|bat)$/i.test(target)) return { file: target, args, options: {} };
|
|
54
|
+
|
|
55
|
+
const quote = (value) => `"${String(value).replace(/"/g, '""')}"`;
|
|
56
|
+
const command = [target, ...args].map(quote).join(' ');
|
|
57
|
+
return {
|
|
58
|
+
file: env.COMSPEC || 'cmd.exe',
|
|
59
|
+
args: ['/d', '/s', '/c', command],
|
|
60
|
+
options: { windowsVerbatimArguments: true },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function runBinary(binary, args, { cwd, timeout = TIMEOUT_MS, env = process.env } = {}) {
|
|
36
65
|
return new Promise((resolve, reject) => {
|
|
37
|
-
const
|
|
38
|
-
|
|
66
|
+
const invocation = process.platform === 'win32'
|
|
67
|
+
? windowsInvocation(binary, args, env)
|
|
68
|
+
: { file: binary, args, options: {} };
|
|
69
|
+
|
|
70
|
+
const options = {
|
|
71
|
+
cwd,
|
|
72
|
+
timeout,
|
|
73
|
+
maxBuffer: MAX_BUFFER,
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
...invocation.options,
|
|
77
|
+
};
|
|
78
|
+
execFile(invocation.file, invocation.args, options, (err, stdout, stderr) => {
|
|
39
79
|
if (!err) return resolve(stdout);
|
|
40
80
|
if (err.code === 'ENOENT') return reject(new Error(`${binary} is not on this process PATH`));
|
|
41
81
|
if (err.killed) return reject(new Error(`${binary} ${args[0]} was still running after ${timeout}ms and was stopped`));
|