claude-memory-admin 1.10.0 → 1.10.2
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 +36 -6
- package/package.json +1 -1
- package/public/ui.mjs +2 -0
- package/public/views/cost.mjs +53 -10
- package/public/views/header.mjs +1 -1
- package/public/views/issue.mjs +24 -0
- package/public/views/stores.mjs +22 -0
- package/public/views/tools.mjs +37 -9
- package/server.mjs +76 -8
- package/src/agents.mjs +94 -18
- package/src/checks.mjs +69 -0
- package/src/config.mjs +130 -0
- package/src/cost.mjs +2 -2
- 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/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) {
|
package/src/checks.mjs
CHANGED
|
@@ -2,6 +2,13 @@ import path from 'node:path';
|
|
|
2
2
|
import { parseFrontmatter } from './parse.mjs';
|
|
3
3
|
|
|
4
4
|
export const VALID_TYPES = ['user', 'feedback', 'project', 'reference'];
|
|
5
|
+
|
|
6
|
+
/** The scope each subagent store kind stands for, for the messages below. */
|
|
7
|
+
const STORE_SCOPES = {
|
|
8
|
+
'agent-user': 'user',
|
|
9
|
+
'agent-project': 'project',
|
|
10
|
+
'agent-local': 'local',
|
|
11
|
+
};
|
|
5
12
|
export const EMPTY_BODY_CHARS = 40;
|
|
6
13
|
export const EMPTY_INSTRUCTION_CHARS = 10;
|
|
7
14
|
export const HOOK_ECHO_OVERLAP = 0.9;
|
|
@@ -218,6 +225,68 @@ export function checkNoMemoryDespiteSessions(store, memories, retention) {
|
|
|
218
225
|
}];
|
|
219
226
|
}
|
|
220
227
|
|
|
228
|
+
/**
|
|
229
|
+
* A subagent store nothing declares any more.
|
|
230
|
+
*
|
|
231
|
+
* The directory only exists because some agent file once carried a `memory:`
|
|
232
|
+
* field naming this scope. Rename the agent, move it to another scope or drop
|
|
233
|
+
* the field and the directory stays exactly where it is, still holding whatever
|
|
234
|
+
* it learned, and no session will ever read it again. That is invisible on disk,
|
|
235
|
+
* which is the whole reason to say it here.
|
|
236
|
+
*/
|
|
237
|
+
export function checkAgentStoreOrphan(store) {
|
|
238
|
+
if (!store || !store.linkage || store.linked) return [];
|
|
239
|
+
if (store.declaredScope) return [];
|
|
240
|
+
|
|
241
|
+
return [{
|
|
242
|
+
kind: 'agent-store-orphan',
|
|
243
|
+
severity: 'warn',
|
|
244
|
+
agentName: store.agentName,
|
|
245
|
+
scope: STORE_SCOPES[store.kind] || null,
|
|
246
|
+
defined: Boolean(store.defined),
|
|
247
|
+
}];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The agent still exists, but its `memory:` names a different scope than this store. */
|
|
251
|
+
export function checkAgentStoreScopeMismatch(store) {
|
|
252
|
+
if (!store || !store.linkage || store.linked) return [];
|
|
253
|
+
if (!store.declaredScope) return [];
|
|
254
|
+
|
|
255
|
+
return [{
|
|
256
|
+
kind: 'agent-store-scope-mismatch',
|
|
257
|
+
severity: 'warn',
|
|
258
|
+
agentName: store.agentName,
|
|
259
|
+
scope: STORE_SCOPES[store.kind] || null,
|
|
260
|
+
declaredScope: store.declaredScope,
|
|
261
|
+
declaredBy: store.declaredBy,
|
|
262
|
+
}];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Subagent memory is part of auto memory, so turning auto memory off turns this
|
|
267
|
+
* store off with it: the `memory:` field stops having any effect, the agent
|
|
268
|
+
* launches with no memory instructions and no file tools, and the directory can
|
|
269
|
+
* neither be read nor grow. Nothing about the files themselves shows that.
|
|
270
|
+
*/
|
|
271
|
+
export function checkAgentMemoryInert(store) {
|
|
272
|
+
if (!store || !store.linkage || !store.inert) return [];
|
|
273
|
+
|
|
274
|
+
return [{
|
|
275
|
+
kind: 'agent-memory-inert',
|
|
276
|
+
severity: 'warn',
|
|
277
|
+
agentName: store.agentName,
|
|
278
|
+
setBy: store.inertBy || null,
|
|
279
|
+
}];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function agentStoreChecks(store) {
|
|
283
|
+
return [
|
|
284
|
+
...checkAgentMemoryInert(store),
|
|
285
|
+
...checkAgentStoreOrphan(store),
|
|
286
|
+
...checkAgentStoreScopeMismatch(store),
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
|
|
221
290
|
export function sessionChecks(store, memories, retention, { remembered = false, resolveOrigin = null } = {}) {
|
|
222
291
|
if (!retention) return [];
|
|
223
292
|
return [
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Where Claude Code keeps its files, and the path primitives that answer it the
|
|
2
|
+
// same way on all three platforms.
|
|
3
|
+
//
|
|
4
|
+
// Everything under ~/.claude - the projects root, the agent definitions, the
|
|
5
|
+
// agent memory directories, the user settings file, the output styles - moves as
|
|
6
|
+
// one when CLAUDE_CONFIG_DIR is set. Hardcoding the home-relative path in each
|
|
7
|
+
// module meant a machine with a custom config dir opened this tool to an empty
|
|
8
|
+
// list rather than to its memory, so all of them resolve through configDir()
|
|
9
|
+
// instead.
|
|
10
|
+
//
|
|
11
|
+
// The platform is a parameter on every function here rather than a read of
|
|
12
|
+
// process.platform inside it. Only one of the three platforms is ever available
|
|
13
|
+
// to develop on, and behaviour that cannot be exercised from a test is behaviour
|
|
14
|
+
// nobody checks: the defaults keep the call sites unchanged and the parameter is
|
|
15
|
+
// what lets test/config.test.mjs assert the Windows rules from anywhere.
|
|
16
|
+
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/** The separator rules of the named platform, rather than of the host. */
|
|
21
|
+
const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Expand a leading `~/`. Claude Code documents that form for autoMemoryDirectory
|
|
25
|
+
* and it is what people type on every platform, Windows included, so it is
|
|
26
|
+
* handled before any separator rule is applied.
|
|
27
|
+
*
|
|
28
|
+
* The join goes through pathFor rather than through path directly. A caller that
|
|
29
|
+
* names a platform is naming its separator too, and the native join would answer
|
|
30
|
+
* from the host instead: a POSIX home joined under win32 comes back rooted at no
|
|
31
|
+
* drive, which isAbsolutePath then rejects for a reason the caller never asked
|
|
32
|
+
* for.
|
|
33
|
+
*/
|
|
34
|
+
export function expandHome(value, { home = os.homedir(), platform = process.platform } = {}) {
|
|
35
|
+
if (typeof value !== 'string') return value;
|
|
36
|
+
if (value === '~') return home;
|
|
37
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return pathFor(platform).join(home, value.slice(2));
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether a path is absolute in the sense Claude Code requires: rooted, and on
|
|
43
|
+
* Windows rooted at a drive or a UNC share.
|
|
44
|
+
*
|
|
45
|
+
* path.isAbsolute is not enough on its own. Under win32 it answers true for
|
|
46
|
+
* "/foo", which has no drive and so names nothing that can be opened. Accepting
|
|
47
|
+
* it would turn a typo into a silent read of the wrong place, which is the one
|
|
48
|
+
* failure every caller here exists to prevent.
|
|
49
|
+
*/
|
|
50
|
+
export function isAbsolutePath(value, platform = process.platform) {
|
|
51
|
+
if (typeof value !== 'string' || !value) return false;
|
|
52
|
+
if (platform === 'win32') {
|
|
53
|
+
return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]/.test(value) || /^\/\/[^/]/.test(value);
|
|
54
|
+
}
|
|
55
|
+
return value.startsWith('/');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A path with forward slashes, which is the only separator glob syntax knows. */
|
|
59
|
+
export function toPosix(value) {
|
|
60
|
+
return typeof value === 'string' ? value.replace(/\\/g, '/') : value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A path in the form to compare two paths by, never the form to display or store.
|
|
65
|
+
*
|
|
66
|
+
* Windows and a default macOS volume are both case-insensitive, so two spellings
|
|
67
|
+
* of one directory are one directory, and a dedup set keyed on the raw string
|
|
68
|
+
* misses the duplicate. Lowercasing is only sound for the comparison: the store
|
|
69
|
+
* ids in src/stores.mjs are derived from the path as found on disk and have to
|
|
70
|
+
* stay that way, or every id on the machine changes.
|
|
71
|
+
*/
|
|
72
|
+
export function canonicalPath(value, platform = process.platform) {
|
|
73
|
+
if (typeof value !== 'string' || !value) return value;
|
|
74
|
+
const resolved = toPosix(path.resolve(value));
|
|
75
|
+
return platform === 'win32' || platform === 'darwin' ? resolved.toLowerCase() : resolved;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const DEFAULT_CONFIG_DIR_NAME = '.claude';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The config directory and what decided it, in the shape src/projects.mjs
|
|
82
|
+
* already uses for the memory root: a tool that silently reads somewhere other
|
|
83
|
+
* than the default is worse than one that reads nothing, so the source travels
|
|
84
|
+
* with the path.
|
|
85
|
+
*
|
|
86
|
+
* A CLAUDE_CONFIG_DIR that is not absolute is reported and not used. Claude Code
|
|
87
|
+
* would not accept it either, so falling back to the default is the honest
|
|
88
|
+
* answer - but silently is not, hence `invalid`.
|
|
89
|
+
*/
|
|
90
|
+
export function configSource({ env = process.env, home = os.homedir(), platform = process.platform } = {}) {
|
|
91
|
+
const fallback = pathFor(platform).join(home, DEFAULT_CONFIG_DIR_NAME);
|
|
92
|
+
const raw = typeof env.CLAUDE_CONFIG_DIR === 'string' ? env.CLAUDE_CONFIG_DIR.trim() : '';
|
|
93
|
+
if (!raw) return { path: fallback, source: 'default', raw: null, invalid: null };
|
|
94
|
+
|
|
95
|
+
const expanded = expandHome(raw, { home, platform });
|
|
96
|
+
if (!isAbsolutePath(expanded, platform)) {
|
|
97
|
+
return {
|
|
98
|
+
path: fallback,
|
|
99
|
+
source: 'default',
|
|
100
|
+
raw,
|
|
101
|
+
invalid: `CLAUDE_CONFIG_DIR "${raw}" is not an absolute or ~/ path`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { path: expanded, source: 'env', raw, invalid: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function configDir(options = {}) {
|
|
108
|
+
return configSource(options).path;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A path inside the config directory, such as configPath('agents'). */
|
|
112
|
+
export function configPath(...segments) {
|
|
113
|
+
return path.join(configDir(), ...segments);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* CLAUDE_CODE_PROJECT_DIR_NAME, which fixes the <project> directory name so that
|
|
118
|
+
* every repository launched with this config dir shares one store instead of
|
|
119
|
+
* getting a slug of its own.
|
|
120
|
+
*
|
|
121
|
+
* Claude Code documents it as being set beside CLAUDE_CONFIG_DIR, and it is only
|
|
122
|
+
* honoured here on that condition: on its own it would silently redirect a
|
|
123
|
+
* default installation to a directory Claude Code is not using.
|
|
124
|
+
*/
|
|
125
|
+
export function fixedProjectDirName({ env = process.env } = {}) {
|
|
126
|
+
if (typeof env.CLAUDE_CONFIG_DIR !== 'string' || !env.CLAUDE_CONFIG_DIR.trim()) return null;
|
|
127
|
+
const name = typeof env.CLAUDE_CODE_PROJECT_DIR_NAME === 'string' ? env.CLAUDE_CODE_PROJECT_DIR_NAME.trim() : '';
|
|
128
|
+
if (!name || name !== path.basename(name) || name.startsWith('.')) return null;
|
|
129
|
+
return name;
|
|
130
|
+
}
|
package/src/cost.mjs
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
// prompt and are untouched by it.
|
|
19
19
|
|
|
20
20
|
import fs from 'node:fs';
|
|
21
|
-
import os from 'node:os';
|
|
22
21
|
import path from 'node:path';
|
|
23
22
|
|
|
23
|
+
import { configPath } from './config.mjs';
|
|
24
24
|
import { parseFrontmatter } from './parse.mjs';
|
|
25
25
|
import { writeFileAtomic } from './mutate.mjs';
|
|
26
26
|
import {
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
settingsCandidates,
|
|
32
32
|
} from './settings.mjs';
|
|
33
33
|
|
|
34
|
-
export const OUTPUT_STYLES_DIR =
|
|
34
|
+
export const OUTPUT_STYLES_DIR = configPath('output-styles');
|
|
35
35
|
|
|
36
36
|
// A full model name is documented as acceptable wherever an alias is, and this
|
|
37
37
|
// tool has no business deciding which ones exist. The shape is checked so a
|
package/src/instructions.mjs
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
// would hide exactly the problem this is meant to surface.
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
|
-
import os from 'node:os';
|
|
17
16
|
import path from 'node:path';
|
|
17
|
+
import { canonicalPath, configDir, DEFAULT_CONFIG_DIR_NAME, expandHome, toPosix } from './config.mjs';
|
|
18
18
|
import { parseFrontmatter } from './parse.mjs';
|
|
19
19
|
import { estimateTokens } from './stats.mjs';
|
|
20
20
|
import { lookup, settingsLayers } from './settings.mjs';
|
|
@@ -133,7 +133,7 @@ export function findImports(text) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
function resolveImport(spec, fromFile) {
|
|
136
|
-
if (spec.startsWith('~/')
|
|
136
|
+
if (spec.startsWith('~/') || spec.startsWith('~\\')) return expandHome(spec);
|
|
137
137
|
if (path.isAbsolute(spec)) return spec;
|
|
138
138
|
// Relative to the file holding the import, not to the working directory.
|
|
139
139
|
return path.resolve(path.dirname(fromFile), spec);
|
|
@@ -149,7 +149,10 @@ function resolveImport(spec, fromFile) {
|
|
|
149
149
|
export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
|
|
150
150
|
const files = [];
|
|
151
151
|
const problems = [];
|
|
152
|
-
|
|
152
|
+
// Canonical keys: on a case-insensitive filesystem two spellings of one file
|
|
153
|
+
// are one file, and a cycle spelled differently at each hop would otherwise
|
|
154
|
+
// recurse until it hit the depth limit instead of being named as a cycle.
|
|
155
|
+
const seen = new Set([canonicalPath(rootFile)]);
|
|
153
156
|
let frontier = [{ file: rootFile, text: rootText, depth: 0 }];
|
|
154
157
|
|
|
155
158
|
while (frontier.length) {
|
|
@@ -162,7 +165,7 @@ export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
|
|
|
162
165
|
problems.push({ kind: 'too-deep', spec, from: current.file, file: resolved });
|
|
163
166
|
continue;
|
|
164
167
|
}
|
|
165
|
-
if (seen.has(resolved)) {
|
|
168
|
+
if (seen.has(canonicalPath(resolved))) {
|
|
166
169
|
problems.push({ kind: 'cycle', spec, from: current.file, file: resolved });
|
|
167
170
|
continue;
|
|
168
171
|
}
|
|
@@ -173,7 +176,7 @@ export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
|
|
|
173
176
|
continue;
|
|
174
177
|
}
|
|
175
178
|
|
|
176
|
-
seen.add(resolved);
|
|
179
|
+
seen.add(canonicalPath(resolved));
|
|
177
180
|
// An import resolving outside the project is the kind Claude Code asks
|
|
178
181
|
// you to approve once; a declined one then stays silently disabled.
|
|
179
182
|
const external = projectDir ? path.relative(projectDir, resolved).startsWith('..') : false;
|
|
@@ -342,17 +345,34 @@ function entry(file, scope, kind, { conditional = false } = {}) {
|
|
|
342
345
|
}
|
|
343
346
|
|
|
344
347
|
/** Directories from the filesystem root down to `dir`, in load order. */
|
|
348
|
+
/**
|
|
349
|
+
* Every directory from the filesystem root down to `dir`, root last.
|
|
350
|
+
*
|
|
351
|
+
* Walked with dirname rather than by splitting on the separator, because the
|
|
352
|
+
* separator alone does not describe a root: a Windows path starts at `C:\` and
|
|
353
|
+
* a UNC path at `\\server\share`, and splitting either of those produces
|
|
354
|
+
* candidates that name nothing.
|
|
355
|
+
*/
|
|
345
356
|
function ancestors(dir) {
|
|
346
|
-
const parts = path.resolve(dir).split(path.sep);
|
|
347
357
|
const out = [];
|
|
348
|
-
|
|
349
|
-
|
|
358
|
+
let current = path.resolve(dir);
|
|
359
|
+
while (true) {
|
|
360
|
+
out.push(current);
|
|
361
|
+
const parent = path.dirname(current);
|
|
362
|
+
if (parent === current) break;
|
|
363
|
+
current = parent;
|
|
350
364
|
}
|
|
351
|
-
return out;
|
|
365
|
+
return out.reverse();
|
|
352
366
|
}
|
|
353
367
|
|
|
354
|
-
function globToRegExp(pattern) {
|
|
355
|
-
// Only used for claudeMdExcludes, which matches absolute paths.
|
|
368
|
+
export function globToRegExp(pattern, platform = process.platform) {
|
|
369
|
+
// Only used for claudeMdExcludes, which matches absolute paths. Glob syntax
|
|
370
|
+
// knows one separator, so a Windows path has to be compared in its forward
|
|
371
|
+
// slash form or every pattern silently matches nothing - and on the two
|
|
372
|
+
// case-insensitive platforms the comparison has to ignore case for the same
|
|
373
|
+
// reason a path does.
|
|
374
|
+
const flags = platform === 'win32' || platform === 'darwin' ? 'i' : '';
|
|
375
|
+
pattern = toPosix(pattern);
|
|
356
376
|
let out = '';
|
|
357
377
|
for (let i = 0; i < pattern.length; i++) {
|
|
358
378
|
const char = pattern[i];
|
|
@@ -362,16 +382,26 @@ function globToRegExp(pattern) {
|
|
|
362
382
|
} else if (char === '?') out += '[^/]';
|
|
363
383
|
else out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
364
384
|
}
|
|
365
|
-
return new RegExp(`^${out}
|
|
385
|
+
return new RegExp(`^${out}$`, flags);
|
|
366
386
|
}
|
|
367
387
|
|
|
368
388
|
/**
|
|
369
389
|
* Managed policy and user scope: what loads whichever project a session starts in.
|
|
370
390
|
*
|
|
371
|
-
* `home` is a parameter rather than a
|
|
391
|
+
* `home` is a parameter rather than a read of the config directory so this is a total
|
|
372
392
|
* function of where it is told to look, which is what makes it testable without a
|
|
373
393
|
* real home directory.
|
|
374
394
|
*/
|
|
395
|
+
/**
|
|
396
|
+
* The user-scope directory: normally the config directory, which
|
|
397
|
+
* CLAUDE_CONFIG_DIR may have moved off the home directory entirely. A `home`
|
|
398
|
+
* passed in names its own, which is what lets the tests point the user scope at
|
|
399
|
+
* a fixture.
|
|
400
|
+
*/
|
|
401
|
+
function userDir(home) {
|
|
402
|
+
return home ? path.join(home, DEFAULT_CONFIG_DIR_NAME) : configDir();
|
|
403
|
+
}
|
|
404
|
+
|
|
375
405
|
function userCandidates(home, layers) {
|
|
376
406
|
const candidates = [entry(managedClaudeMd(), 'managed', 'claude-md')];
|
|
377
407
|
|
|
@@ -382,8 +412,9 @@ function userCandidates(home, layers) {
|
|
|
382
412
|
: null;
|
|
383
413
|
if (inline) candidates.push(inline);
|
|
384
414
|
|
|
385
|
-
|
|
386
|
-
|
|
415
|
+
const dir = userDir(home);
|
|
416
|
+
candidates.push(entry(path.join(dir, 'CLAUDE.md'), 'user', 'claude-md'));
|
|
417
|
+
for (const file of listRuleFiles(path.join(dir, 'rules'))) {
|
|
387
418
|
candidates.push(ruleEntry(file, 'user'));
|
|
388
419
|
}
|
|
389
420
|
return candidates;
|
|
@@ -416,14 +447,14 @@ function readExcludes(layers) {
|
|
|
416
447
|
* one cannot drift apart in what they count or what they consider broken.
|
|
417
448
|
*/
|
|
418
449
|
function finalize(candidates, { projectDir, excludes }) {
|
|
419
|
-
const matchers = excludes.map(globToRegExp);
|
|
450
|
+
const matchers = excludes.map((pattern) => globToRegExp(pattern));
|
|
420
451
|
const excluded = [];
|
|
421
452
|
const loaded = [];
|
|
422
453
|
const problems = [];
|
|
423
454
|
|
|
424
455
|
for (const candidate of candidates.filter(Boolean)) {
|
|
425
456
|
// Managed policy cannot be excluded; that is the point of managed policy.
|
|
426
|
-
if (candidate.scope !== 'managed' && matchers.some((re) => re.test(candidate.file))) {
|
|
457
|
+
if (candidate.scope !== 'managed' && matchers.some((re) => re.test(toPosix(candidate.file)))) {
|
|
427
458
|
excluded.push(candidate.file);
|
|
428
459
|
continue;
|
|
429
460
|
}
|
|
@@ -498,7 +529,7 @@ function finalize(candidates, { projectDir, excludes }) {
|
|
|
498
529
|
* and walking into them would bury the one finding that matters.
|
|
499
530
|
*/
|
|
500
531
|
export function unreferencedUserFiles(home, loadedFiles) {
|
|
501
|
-
const dir =
|
|
532
|
+
const dir = userDir(home);
|
|
502
533
|
// Imports arrive already absolutised by resolveImport while candidates are
|
|
503
534
|
// joined from `home`, so both sides are resolved before they are compared.
|
|
504
535
|
const loaded = new Set(loadedFiles.map((item) => path.resolve(item.file)));
|
|
@@ -526,7 +557,7 @@ export function unreferencedUserFiles(home, loadedFiles) {
|
|
|
526
557
|
export function resolveInstructions(projectDir) {
|
|
527
558
|
const layers = settingsLayers({ projectDir });
|
|
528
559
|
const candidates = [
|
|
529
|
-
...userCandidates(
|
|
560
|
+
...userCandidates(null, layers),
|
|
530
561
|
...projectCandidates(projectDir),
|
|
531
562
|
];
|
|
532
563
|
const resolved = finalize(candidates, { projectDir, excludes: readExcludes(layers) });
|
|
@@ -552,7 +583,7 @@ export function resolveInstructions(projectDir) {
|
|
|
552
583
|
* With no project there is no boundary for an import to resolve outside of, so
|
|
553
584
|
* nothing is marked external here - that check belongs to the whole-session view.
|
|
554
585
|
*/
|
|
555
|
-
export function resolveGlobalInstructions({ home =
|
|
586
|
+
export function resolveGlobalInstructions({ home = null, settingsFile = null } = {}) {
|
|
556
587
|
const layers = settingsLayers(settingsFile ? { settingsFile } : {});
|
|
557
588
|
const resolved = finalize(userCandidates(home, layers), { projectDir: null, excludes: readExcludes(layers) });
|
|
558
589
|
resolved.problems.push(...unreferencedUserFiles(home, resolved.files));
|
package/src/liveSessions.mjs
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// happening right now.
|
|
5
5
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
|
-
import os from 'node:os';
|
|
8
7
|
import path from 'node:path';
|
|
9
8
|
|
|
9
|
+
import { configPath } from './config.mjs';
|
|
10
|
+
|
|
10
11
|
function isAlive(pid) {
|
|
11
12
|
try {
|
|
12
13
|
process.kill(pid, 0);
|
|
@@ -21,7 +22,7 @@ function isAlive(pid) {
|
|
|
21
22
|
* left behind by a process that has since exited is silently skipped rather
|
|
22
23
|
* than reported as stale - the registry itself is not this app's to clean up.
|
|
23
24
|
*/
|
|
24
|
-
export function readLiveSessions({ dir =
|
|
25
|
+
export function readLiveSessions({ dir = configPath('sessions') } = {}) {
|
|
25
26
|
let entries;
|
|
26
27
|
try {
|
|
27
28
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
package/src/model.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
|
|
|
15
15
|
import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
|
|
16
16
|
import { autoMemoryState } from './settings.mjs';
|
|
17
17
|
import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
|
|
18
|
-
import { memoryChecks, sessionChecks } from './checks.mjs';
|
|
18
|
+
import { agentStoreChecks, memoryChecks, sessionChecks } from './checks.mjs';
|
|
19
19
|
import { listSessions, originSession, retention, transcriptDir } from './sessions.mjs';
|
|
20
20
|
import { rememberedPath } from './pathcache.mjs';
|
|
21
21
|
|
|
@@ -198,6 +198,7 @@ export function buildStore(store) {
|
|
|
198
198
|
...(health.longHooks.length
|
|
199
199
|
? [{ kind: 'long-hooks', severity: 'warn', count: health.longHooks.length, longest: health.longHooks[0] }]
|
|
200
200
|
: []),
|
|
201
|
+
...agentStoreChecks(store),
|
|
201
202
|
...memoryChecks(memories, index),
|
|
202
203
|
...sessionChecks(store, memories, sessions.retention, {
|
|
203
204
|
remembered: sessions.remembered,
|