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/parse.mjs
CHANGED
|
@@ -24,6 +24,36 @@ const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g;
|
|
|
24
24
|
/** Leading hook separator: em dash, en dash, hyphen or colon. */
|
|
25
25
|
const HOOK_SEP = /^[ \t]*(?:—|–|-|:)[ \t]*/;
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A line with its carriage return taken off, for matching only.
|
|
29
|
+
*
|
|
30
|
+
* Every classifying regex here ends in `$`, and JavaScript's `.` does not match
|
|
31
|
+
* a carriage return, so on a CRLF file each of them fails on the last character
|
|
32
|
+
* and the file parses as if it held nothing: no index entries, no frontmatter,
|
|
33
|
+
* every memory an orphan. Splitting on /\r?\n/ instead would fix the matching
|
|
34
|
+
* and break the promise that matters more - that a file is rewritten byte for
|
|
35
|
+
* byte - because it would rewrite a CRLF file as LF. So the return stays on the
|
|
36
|
+
* stored line and comes off only for the match.
|
|
37
|
+
*/
|
|
38
|
+
export function forMatch(line) {
|
|
39
|
+
return typeof line === 'string' && line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The carriage return a line added to this file should end with, so a new entry
|
|
44
|
+
* matches the file it joins rather than making it half one thing and half the
|
|
45
|
+
* other. Decided by what most of the file already does.
|
|
46
|
+
*/
|
|
47
|
+
export function fileEol(lines) {
|
|
48
|
+
let crlf = 0;
|
|
49
|
+
let lf = 0;
|
|
50
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
51
|
+
if (lines[i].endsWith('\r')) crlf += 1;
|
|
52
|
+
else lf += 1;
|
|
53
|
+
}
|
|
54
|
+
return crlf > lf ? '\r' : '';
|
|
55
|
+
}
|
|
56
|
+
|
|
27
57
|
function unquote(value) {
|
|
28
58
|
const trimmed = value.trim();
|
|
29
59
|
if (trimmed.length >= 2) {
|
|
@@ -50,7 +80,7 @@ function readYaml(lines) {
|
|
|
50
80
|
const withoutComment = raw.trimStart().startsWith('#') ? '' : raw;
|
|
51
81
|
if (!withoutComment.trim()) continue;
|
|
52
82
|
|
|
53
|
-
const match = withoutComment.match(/^([ \t]*)([A-Za-z0-9_.\-]+):[ \t]*(.*)$/);
|
|
83
|
+
const match = forMatch(withoutComment).match(/^([ \t]*)([A-Za-z0-9_.\-]+):[ \t]*(.*)$/);
|
|
54
84
|
if (!match) continue;
|
|
55
85
|
|
|
56
86
|
const [, indent, key, rest] = match;
|
|
@@ -103,7 +133,7 @@ export function parseFrontmatter(text) {
|
|
|
103
133
|
return {
|
|
104
134
|
data: readYaml(block),
|
|
105
135
|
raw: block.join('\n'),
|
|
106
|
-
body: lines.slice(end + 1).join('\n').replace(
|
|
136
|
+
body: lines.slice(end + 1).join('\n').replace(/^(?:\r?\n)+/, ''),
|
|
107
137
|
hasFrontmatter: true,
|
|
108
138
|
};
|
|
109
139
|
}
|
|
@@ -140,12 +170,12 @@ export function parseIndex(text) {
|
|
|
140
170
|
let section = null;
|
|
141
171
|
|
|
142
172
|
lines.forEach((line, i) => {
|
|
143
|
-
const heading = line.match(/^(#{1,6})[ \t]+(.*)$/);
|
|
173
|
+
const heading = forMatch(line).match(/^(#{1,6})[ \t]+(.*)$/);
|
|
144
174
|
if (heading) {
|
|
145
175
|
section = heading[2].trim();
|
|
146
176
|
parsedLines.push({ index: i, kind: 'heading', text: line, level: heading[1].length, section });
|
|
147
177
|
} else {
|
|
148
|
-
const indexMatch = line.match(INDEX_LINE);
|
|
178
|
+
const indexMatch = forMatch(line).match(INDEX_LINE);
|
|
149
179
|
// Only an unindented bullet is an index entry. An indented one is a
|
|
150
180
|
// sub-bullet of surrounding prose and must not be treated as a pointer.
|
|
151
181
|
if (indexMatch && indexMatch[1] === '') {
|
|
@@ -255,19 +285,23 @@ export function unwrapWikilink(text, target) {
|
|
|
255
285
|
}
|
|
256
286
|
|
|
257
287
|
function splitEntryLine(line) {
|
|
258
|
-
const
|
|
288
|
+
const body = forMatch(line);
|
|
289
|
+
const match = body.match(INDEX_LINE);
|
|
259
290
|
if (!match || match[1] !== '') return null;
|
|
260
291
|
const file = match[3];
|
|
261
292
|
const marker = `](${file})`;
|
|
262
|
-
const linkEnd =
|
|
263
|
-
const tail =
|
|
293
|
+
const linkEnd = body.indexOf(marker) + marker.length;
|
|
294
|
+
const tail = body.slice(linkEnd);
|
|
264
295
|
const separator = tail.match(HOOK_SEP);
|
|
265
296
|
return {
|
|
266
|
-
head:
|
|
297
|
+
head: body.slice(0, linkEnd),
|
|
267
298
|
title: match[2].trim(),
|
|
268
299
|
file,
|
|
269
300
|
separator: separator ? separator[0] : null,
|
|
270
301
|
hook: tail.replace(HOOK_SEP, ''),
|
|
302
|
+
// Carried so a rewritten line ends the way the line it replaces did. A file
|
|
303
|
+
// that comes back half CRLF and half LF is a file this tool corrupted.
|
|
304
|
+
eol: line.endsWith('\r') ? '\r' : '',
|
|
271
305
|
};
|
|
272
306
|
}
|
|
273
307
|
|
|
@@ -317,7 +351,7 @@ export function setIndexHook(text, index, expectedText, hook) {
|
|
|
317
351
|
|
|
318
352
|
const separator = parts.separator || dominantSeparator(parseIndex(text));
|
|
319
353
|
const before = lines[index];
|
|
320
|
-
lines[index] = trimmed ? `${parts.head}${separator}${trimmed}` : parts.head;
|
|
354
|
+
lines[index] = (trimmed ? `${parts.head}${separator}${trimmed}` : parts.head) + parts.eol;
|
|
321
355
|
return { text: lines.join('\n'), before, after: lines[index] };
|
|
322
356
|
}
|
|
323
357
|
|
|
@@ -396,7 +430,7 @@ export function sectionInsertIndex(parsed, section) {
|
|
|
396
430
|
export function insertIndexEntry(text, at, line) {
|
|
397
431
|
const lines = text.split('\n');
|
|
398
432
|
const target = Math.max(0, Math.min(Number(at), lines.length));
|
|
399
|
-
lines.splice(target, 0, line);
|
|
433
|
+
lines.splice(target, 0, line + fileEol(lines));
|
|
400
434
|
return { text: lines.join('\n'), index: target };
|
|
401
435
|
}
|
|
402
436
|
|
package/src/pathcheck.mjs
CHANGED
|
@@ -22,7 +22,12 @@ function candidate(raw) {
|
|
|
22
22
|
if (token.includes('://') || token.startsWith('@')) return null;
|
|
23
23
|
if (GLOB_OR_SHELL.test(token)) return null;
|
|
24
24
|
|
|
25
|
+
// A memory written on Windows quotes its paths with backslashes. The project
|
|
26
|
+
// index below is keyed on forward slashes, so a token that is not normalised
|
|
27
|
+
// matches nothing and every Windows-written path is reported as missing.
|
|
28
|
+
token = token.replace(/\\/g, '/');
|
|
25
29
|
token = token.replace(LINE_SUFFIX, '').replace(/[.,;:]+$/, '').replace(/\/+$/, '');
|
|
30
|
+
if (/^[A-Za-z]:\//.test(token)) return null;
|
|
26
31
|
if (token.startsWith('./')) token = token.slice(2);
|
|
27
32
|
if (!token || token.startsWith('/') || token.startsWith('~')) return null;
|
|
28
33
|
if (token.split('/').includes('..')) return null;
|
package/src/projects.mjs
CHANGED
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
// and the repository root preferred as the store's identity.
|
|
15
15
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
|
-
import os from 'node:os';
|
|
18
17
|
import path from 'node:path';
|
|
18
|
+
import { configPath, configSource, fixedProjectDirName } from './config.mjs';
|
|
19
19
|
import { rememberedPath } from './pathcache.mjs';
|
|
20
20
|
import { autoMemoryState, expandHome, resolveMemoryDirectory } from './settings.mjs';
|
|
21
21
|
|
|
22
|
-
export const DEFAULT_ROOT =
|
|
22
|
+
export const DEFAULT_ROOT = configPath('projects');
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Which store to read, and what decided it. The source travels with the path so
|
|
@@ -44,7 +44,24 @@ export function resolveRoot() {
|
|
|
44
44
|
invalid: `autoMemoryDirectory "${configured.raw}" is ${configured.invalid}`,
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// CLAUDE_CONFIG_DIR moved the whole config directory, so DEFAULT_ROOT is
|
|
48
|
+
// already inside it. Naming that as the source keeps the UI from claiming a
|
|
49
|
+
// default location the user never used.
|
|
50
|
+
const config = configSource();
|
|
51
|
+
if (config.source === 'env') {
|
|
52
|
+
return { path: DEFAULT_ROOT, source: 'config-dir', file: null, invalid: config.invalid };
|
|
53
|
+
}
|
|
54
|
+
return { path: DEFAULT_ROOT, source: 'default', file: null, invalid: config.invalid };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The one project directory every repository shares when
|
|
59
|
+
* CLAUDE_CODE_PROJECT_DIR_NAME is set, or null when each repository gets a slug
|
|
60
|
+
* of its own. Callers use it to explain why one row stands for several
|
|
61
|
+
* repositories rather than to change how the row is read.
|
|
62
|
+
*/
|
|
63
|
+
export function fixedProjectDir() {
|
|
64
|
+
return fixedProjectDirName();
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
export function projectsRoot() {
|
|
@@ -180,12 +197,29 @@ export function storeIdentity(cwds) {
|
|
|
180
197
|
* Candidates are verified against the filesystem so a wrong guess is reported
|
|
181
198
|
* as unresolved rather than presented as fact.
|
|
182
199
|
*/
|
|
183
|
-
function
|
|
200
|
+
export function slugCandidates(slug, platform = process.platform) {
|
|
201
|
+
// A Windows cwd slugifies with its drive in front ("C--Users-me-repo"), so the
|
|
202
|
+
// POSIX candidates below decode it to a path with no drive and no meaning.
|
|
203
|
+
if (platform === 'win32') {
|
|
204
|
+
const drive = /^([A-Za-z])--(.*)$/.exec(slug);
|
|
205
|
+
if (drive) {
|
|
206
|
+
const [, letter, rest] = drive;
|
|
207
|
+
return [
|
|
208
|
+
`${letter}:\\` + rest.replace(/--/g, '\\.').replace(/-/g, '\\'),
|
|
209
|
+
`${letter}:\\` + rest.replace(/-/g, '\\'),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
184
214
|
const body = slug.replace(/^-/, '');
|
|
185
|
-
|
|
215
|
+
return [
|
|
186
216
|
'/' + body.replace(/--/g, '/.').replace(/-/g, '/'),
|
|
187
217
|
'/' + body.replace(/-/g, '/'),
|
|
188
218
|
];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function decodeSlug(slug) {
|
|
222
|
+
const candidates = slugCandidates(slug);
|
|
189
223
|
for (const candidate of candidates) {
|
|
190
224
|
try {
|
|
191
225
|
if (fs.existsSync(candidate)) return { path: candidate, verified: true };
|
|
@@ -223,7 +257,10 @@ export function resolveProjectPath(dir, slug) {
|
|
|
223
257
|
|
|
224
258
|
/** A short label for the sidebar: the last two path segments. */
|
|
225
259
|
export function shortLabel(fullPath) {
|
|
226
|
-
|
|
260
|
+
// Split on both separators: a store recorded on Windows is still browsed on
|
|
261
|
+
// whatever machine is reading it, and a backslash path would otherwise come
|
|
262
|
+
// out as one unsplittable segment.
|
|
263
|
+
const parts = String(fullPath).split(/[\\/]+/).filter(Boolean);
|
|
227
264
|
return parts.slice(-2).join('/') || fullPath;
|
|
228
265
|
}
|
|
229
266
|
|
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`));
|