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/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
|
|
@@ -144,7 +144,7 @@ export function normaliseCostValue(descriptor, value, outputStyles = []) {
|
|
|
144
144
|
* leftover `"env": {}` is harmless but reads as a setting that is still there,
|
|
145
145
|
* which is exactly the confusion this panel exists to remove.
|
|
146
146
|
*/
|
|
147
|
-
function setPath(data, keyPath, value) {
|
|
147
|
+
export function setPath(data, keyPath, value) {
|
|
148
148
|
const [head, ...rest] = keyPath;
|
|
149
149
|
|
|
150
150
|
if (!rest.length) {
|
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,
|
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
|
|