claude-memory-admin 1.0.0 → 1.1.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 +111 -9
- package/package.json +8 -2
- package/public/app.mjs +633 -362
- package/public/graph.mjs +32 -76
- package/public/index.html +34 -29
- package/public/markdown.mjs +74 -0
- package/public/styles.css +2 -402
- package/public/ui.mjs +291 -0
- package/server.mjs +61 -20
- package/src/instructions.mjs +423 -0
- package/src/model.mjs +44 -11
- package/src/mutate.mjs +17 -23
- package/src/pathcache.mjs +93 -0
- package/src/projects.mjs +120 -34
- package/src/search.mjs +13 -11
- package/src/settings.mjs +151 -0
- package/src/stats.mjs +105 -12
- package/src/stores.mjs +146 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// An opt-in record of project paths the user has confirmed.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code sweeps session transcripts after cleanupPeriodDays but explicitly
|
|
4
|
+
// never touches the memory directory, so the evidence this tool uses to name a
|
|
5
|
+
// store expires while the store itself does not. A project whose transcripts
|
|
6
|
+
// have aged out shows its raw slug forever, and no amount of re-reading brings
|
|
7
|
+
// the name back.
|
|
8
|
+
//
|
|
9
|
+
// Nothing here runs on its own. The file is created only when the user asks for
|
|
10
|
+
// a path to be remembered, and it holds slugs and directory paths - never memory
|
|
11
|
+
// content. That keeps the default behaviour a pure read of the store, and keeps
|
|
12
|
+
// this the only writer outside a memory/ directory.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const CACHE_DIR = process.env.MEMORY_PATH_CACHE_DIR || path.join(os.homedir(), '.claude-memory-admin');
|
|
19
|
+
export const CACHE_FILE = path.join(CACHE_DIR, 'paths.json');
|
|
20
|
+
|
|
21
|
+
/** Remembered paths by slug, or an empty map. Never throws: a bad file is ignored. */
|
|
22
|
+
export function readPathCache() {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
|
25
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The remembered path for one slug, only if it still exists on disk. */
|
|
32
|
+
export function rememberedPath(slug) {
|
|
33
|
+
const entry = readPathCache()[slug];
|
|
34
|
+
const dir = entry && typeof entry.path === 'string' ? entry.path : null;
|
|
35
|
+
if (!dir) return null;
|
|
36
|
+
try {
|
|
37
|
+
return fs.statSync(dir).isDirectory() ? dir : null;
|
|
38
|
+
} catch {
|
|
39
|
+
// Remembered, then moved or deleted. Say nothing rather than assert a path
|
|
40
|
+
// that is no longer there.
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeCache(data) {
|
|
46
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
47
|
+
const tmp = `${CACHE_FILE}.tmp-${process.pid}`;
|
|
48
|
+
const fd = fs.openSync(tmp, 'w');
|
|
49
|
+
try {
|
|
50
|
+
fs.writeFileSync(fd, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
51
|
+
fs.fsyncSync(fd);
|
|
52
|
+
} finally {
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
fs.renameSync(tmp, CACHE_FILE);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Record a path for a slug. Only an existing directory is accepted: the point is
|
|
60
|
+
* to preserve a fact that was verifiable once, not to let a typo become the
|
|
61
|
+
* label a project carries from now on.
|
|
62
|
+
*/
|
|
63
|
+
export function rememberPath(slug, dir) {
|
|
64
|
+
if (typeof slug !== 'string' || !slug) throw new Error('Invalid project');
|
|
65
|
+
if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
|
|
66
|
+
throw new Error('Path must be absolute');
|
|
67
|
+
}
|
|
68
|
+
let stat;
|
|
69
|
+
try {
|
|
70
|
+
stat = fs.statSync(dir);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error(`${dir} does not exist`);
|
|
73
|
+
}
|
|
74
|
+
if (!stat.isDirectory()) throw new Error(`${dir} is not a directory`);
|
|
75
|
+
|
|
76
|
+
const cache = readPathCache();
|
|
77
|
+
cache[slug] = { path: dir, rememberedAt: new Date().toISOString() };
|
|
78
|
+
writeCache(cache);
|
|
79
|
+
return { slug, path: dir };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Drop a remembered path. Removing the last one removes the file entirely. */
|
|
83
|
+
export function forgetPath(slug) {
|
|
84
|
+
const cache = readPathCache();
|
|
85
|
+
if (!(slug in cache)) return { slug, forgotten: false };
|
|
86
|
+
delete cache[slug];
|
|
87
|
+
if (Object.keys(cache).length === 0) {
|
|
88
|
+
try { fs.unlinkSync(CACHE_FILE); } catch { /* already gone */ }
|
|
89
|
+
} else {
|
|
90
|
+
writeCache(cache);
|
|
91
|
+
}
|
|
92
|
+
return { slug, forgotten: true };
|
|
93
|
+
}
|
package/src/projects.mjs
CHANGED
|
@@ -6,42 +6,57 @@
|
|
|
6
6
|
// from a path separator. So decoding the slug is a fallback, not the primary
|
|
7
7
|
// method. The reliable source is the session transcripts sitting next to the
|
|
8
8
|
// memory dir, whose JSONL lines carry the true `cwd`.
|
|
9
|
+
//
|
|
10
|
+
// One memory directory can serve several working directories: Claude Code keys
|
|
11
|
+
// the store on the git repository, so every worktree and subdirectory of a repo
|
|
12
|
+
// shares it. Reading a single `cwd` therefore names the store after whichever
|
|
13
|
+
// directory happened to run last, which is why every distinct cwd is collected
|
|
14
|
+
// and the repository root preferred as the store's identity.
|
|
9
15
|
|
|
10
16
|
import fs from 'node:fs';
|
|
11
17
|
import os from 'node:os';
|
|
12
18
|
import path from 'node:path';
|
|
19
|
+
import { rememberedPath } from './pathcache.mjs';
|
|
20
|
+
import { autoMemoryState, expandHome, resolveMemoryDirectory } from './settings.mjs';
|
|
13
21
|
|
|
14
22
|
export const DEFAULT_ROOT = path.join(os.homedir(), '.claude', 'projects');
|
|
15
|
-
const USER_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
|
|
16
|
-
|
|
17
|
-
function expandHome(value) {
|
|
18
|
-
if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
|
|
19
|
-
return value;
|
|
20
|
-
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
25
|
+
* Which store to read, and what decided it. The source travels with the path so
|
|
26
|
+
* the UI can say where a non-default store came from; a tool that silently reads
|
|
27
|
+
* somewhere other than the default is worse than one that reads nothing.
|
|
26
28
|
*/
|
|
27
|
-
export function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const configured = settings?.autoMemoryDirectory;
|
|
31
|
-
if (typeof configured === 'string' && configured.trim()) return expandHome(configured.trim());
|
|
32
|
-
} catch {
|
|
33
|
-
// No settings file, or not readable/parseable: fall through to the default.
|
|
29
|
+
export function resolveRoot() {
|
|
30
|
+
if (process.env.MEMORY_ROOT) {
|
|
31
|
+
return { path: expandHome(process.env.MEMORY_ROOT), source: 'flag', file: null, invalid: null };
|
|
34
32
|
}
|
|
35
|
-
|
|
33
|
+
const configured = resolveMemoryDirectory({ projectDir: process.cwd() });
|
|
34
|
+
if (configured?.path) {
|
|
35
|
+
return { path: configured.path, source: configured.scope, file: configured.file, invalid: null };
|
|
36
|
+
}
|
|
37
|
+
if (configured?.invalid) {
|
|
38
|
+
// Set, but to something Claude Code would not accept either. Fall back, and
|
|
39
|
+
// carry the reason so it can be reported rather than swallowed.
|
|
40
|
+
return {
|
|
41
|
+
path: DEFAULT_ROOT,
|
|
42
|
+
source: 'default',
|
|
43
|
+
file: configured.file,
|
|
44
|
+
invalid: `autoMemoryDirectory "${configured.raw}" is ${configured.invalid}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { path: DEFAULT_ROOT, source: 'default', file: null, invalid: null };
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
export function projectsRoot() {
|
|
39
|
-
|
|
40
|
-
return configuredMemoryDirectory() || DEFAULT_ROOT;
|
|
51
|
+
return resolveRoot().path;
|
|
41
52
|
}
|
|
42
53
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Read the head of a file without pulling a multi-megabyte transcript into
|
|
56
|
+
* memory. The `cwd` field shows up within the first few lines in practice, so
|
|
57
|
+
* the small read almost always suffices and the large one is a fallback.
|
|
58
|
+
*/
|
|
59
|
+
function readHead(file, bytes) {
|
|
45
60
|
const fd = fs.openSync(file, 'r');
|
|
46
61
|
try {
|
|
47
62
|
const buf = Buffer.alloc(bytes);
|
|
@@ -73,15 +88,14 @@ function findTranscripts(dir, depth = 2) {
|
|
|
73
88
|
return out;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
/**
|
|
77
|
-
function
|
|
78
|
-
const
|
|
79
|
-
for (const { file } of transcripts.slice(0, 3)) {
|
|
91
|
+
/** The first `cwd` in one transcript, escalating the read only if the small one missed. */
|
|
92
|
+
function cwdInTranscript(file) {
|
|
93
|
+
for (const size of [16384, 131072]) {
|
|
80
94
|
let head;
|
|
81
95
|
try {
|
|
82
|
-
head = readHead(file);
|
|
96
|
+
head = readHead(file, size);
|
|
83
97
|
} catch {
|
|
84
|
-
|
|
98
|
+
return null;
|
|
85
99
|
}
|
|
86
100
|
for (const line of head.split('\n')) {
|
|
87
101
|
if (!line.includes('"cwd"')) continue;
|
|
@@ -92,10 +106,71 @@ function cwdFromTranscripts(dir) {
|
|
|
92
106
|
// Truncated final line of the chunk, or a non-object line. Skip it.
|
|
93
107
|
}
|
|
94
108
|
}
|
|
109
|
+
if (head.length < size) return null; // Read the whole file already.
|
|
95
110
|
}
|
|
96
111
|
return null;
|
|
97
112
|
}
|
|
98
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Distinct `cwd` values across the newest transcripts, newest first.
|
|
116
|
+
*
|
|
117
|
+
* The cap keeps this cheap on projects with hundreds of sessions. It biases
|
|
118
|
+
* towards recent directories, which is the right bias: a worktree deleted a year
|
|
119
|
+
* ago is not worth naming the store after.
|
|
120
|
+
*/
|
|
121
|
+
export function cwdsFromTranscripts(dir, { limit = 12 } = {}) {
|
|
122
|
+
const transcripts = findTranscripts(dir).sort((a, b) => b.mtime - a.mtime);
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (const { file } of transcripts.slice(0, limit)) {
|
|
125
|
+
const cwd = cwdInTranscript(file);
|
|
126
|
+
if (cwd) seen.add(cwd);
|
|
127
|
+
}
|
|
128
|
+
return [...seen];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Longest path prefix shared by every input, or null when they share only "/". */
|
|
132
|
+
function commonAncestor(paths) {
|
|
133
|
+
if (paths.length === 0) return null;
|
|
134
|
+
let parts = paths[0].split('/');
|
|
135
|
+
for (const other of paths.slice(1)) {
|
|
136
|
+
const segments = other.split('/');
|
|
137
|
+
let i = 0;
|
|
138
|
+
while (i < parts.length && i < segments.length && parts[i] === segments[i]) i += 1;
|
|
139
|
+
parts = parts.slice(0, i);
|
|
140
|
+
}
|
|
141
|
+
const joined = parts.join('/');
|
|
142
|
+
return joined.length > 1 ? joined : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isGitRoot(dir) {
|
|
146
|
+
try {
|
|
147
|
+
return fs.existsSync(path.join(dir, '.git'));
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Which of several working directories names the store.
|
|
155
|
+
*
|
|
156
|
+
* Claude Code derives the store from the git repository, so when the cwds share
|
|
157
|
+
* an ancestor that is a repository root, that root is the store's real identity
|
|
158
|
+
* and every cwd is a worktree or subdirectory of it. When they do not - two
|
|
159
|
+
* worktrees checked out in unrelated places share a repo but not a path prefix -
|
|
160
|
+
* there is nothing to confirm, so the newest cwd is used exactly as before
|
|
161
|
+
* rather than inventing a common parent that means nothing.
|
|
162
|
+
*/
|
|
163
|
+
export function storeIdentity(cwds) {
|
|
164
|
+
if (cwds.length === 0) return null;
|
|
165
|
+
if (cwds.length === 1) return { path: cwds[0], resolvedBy: 'transcript' };
|
|
166
|
+
|
|
167
|
+
const ancestor = commonAncestor(cwds);
|
|
168
|
+
if (ancestor && isGitRoot(ancestor)) {
|
|
169
|
+
return { path: ancestor, resolvedBy: 'repo-root' };
|
|
170
|
+
}
|
|
171
|
+
return { path: cwds[0], resolvedBy: 'transcript' };
|
|
172
|
+
}
|
|
173
|
+
|
|
99
174
|
/**
|
|
100
175
|
* Best-effort slug decode, used only when there is no transcript to read.
|
|
101
176
|
* Candidates are verified against the filesystem so a wrong guess is reported
|
|
@@ -116,21 +191,30 @@ function decodeSlug(slug) {
|
|
|
116
191
|
}
|
|
117
192
|
|
|
118
193
|
export function resolveProjectPath(dir, slug) {
|
|
119
|
-
const
|
|
120
|
-
|
|
194
|
+
const cwds = cwdsFromTranscripts(dir);
|
|
195
|
+
const identity = storeIdentity(cwds);
|
|
196
|
+
if (identity) {
|
|
121
197
|
return {
|
|
122
|
-
path:
|
|
123
|
-
resolvedBy:
|
|
124
|
-
exists: fs.existsSync(
|
|
198
|
+
path: identity.path,
|
|
199
|
+
resolvedBy: identity.resolvedBy,
|
|
200
|
+
exists: fs.existsSync(identity.path),
|
|
201
|
+
workingDirs: cwds,
|
|
125
202
|
};
|
|
126
203
|
}
|
|
204
|
+
// A path the user confirmed once outranks a decode, and is the only thing that
|
|
205
|
+
// survives Claude Code sweeping the transcripts this store was named from.
|
|
206
|
+
const remembered = rememberedPath(slug);
|
|
207
|
+
if (remembered) {
|
|
208
|
+
return { path: remembered, resolvedBy: 'remembered', exists: true, workingDirs: [] };
|
|
209
|
+
}
|
|
210
|
+
|
|
127
211
|
const decoded = decodeSlug(slug);
|
|
128
212
|
if (decoded.verified) {
|
|
129
|
-
return { path: decoded.path, resolvedBy: 'slug', exists: true };
|
|
213
|
+
return { path: decoded.path, resolvedBy: 'slug', exists: true, workingDirs: [] };
|
|
130
214
|
}
|
|
131
215
|
// Nothing confirmed the guess, so show the slug rather than a path that
|
|
132
216
|
// looks authoritative and is probably wrong.
|
|
133
|
-
return { path: slug, resolvedBy: 'unresolved', exists: false, guess: decoded.path };
|
|
217
|
+
return { path: slug, resolvedBy: 'unresolved', exists: false, guess: decoded.path, workingDirs: [] };
|
|
134
218
|
}
|
|
135
219
|
|
|
136
220
|
/** A short label for the sidebar: the last two path segments. */
|
|
@@ -181,6 +265,8 @@ export function listProjects(root = projectsRoot()) {
|
|
|
181
265
|
label: shortLabel(resolved.path),
|
|
182
266
|
resolvedBy: resolved.resolvedBy,
|
|
183
267
|
pathExists: resolved.exists,
|
|
268
|
+
workingDirs: resolved.workingDirs,
|
|
269
|
+
autoMemory: autoMemoryState({ projectDir: resolved.exists ? resolved.path : null }),
|
|
184
270
|
hasMemoryDir,
|
|
185
271
|
hasIndex: hasMemoryDir && fs.existsSync(path.join(mem, 'MEMORY.md')),
|
|
186
272
|
memoryCount: files.length,
|
package/src/search.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// The whole store is a few hundred kilobytes, so this scans it directly on each
|
|
4
4
|
// query rather than maintaining an index that could go stale.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { buildStore } from './model.mjs';
|
|
7
|
+
import { listStores } from './stores.mjs';
|
|
8
8
|
|
|
9
9
|
const FIELD_WEIGHT = { name: 6, description: 3, hook: 2, body: 1 };
|
|
10
10
|
|
|
@@ -102,26 +102,28 @@ export function searchProject(project, terms) {
|
|
|
102
102
|
return { results: results.sort((a, b) => b.score - a.score), indexHits };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/** Search every
|
|
105
|
+
/** Search every store: auto memory and subagent memory alike. */
|
|
106
106
|
export function searchAll(root, query, { limit = 200 } = {}) {
|
|
107
107
|
const terms = fold(query).split(/\s+/).filter(Boolean);
|
|
108
108
|
if (!terms.length) return { terms, total: 0, projects: [] };
|
|
109
109
|
|
|
110
|
-
const
|
|
110
|
+
const stores = [];
|
|
111
111
|
let total = 0;
|
|
112
|
-
for (const listed of
|
|
112
|
+
for (const listed of listStores(root)) {
|
|
113
113
|
if (!listed.hasMemoryDir) continue;
|
|
114
|
-
const
|
|
115
|
-
const { results, indexHits } = searchProject(
|
|
114
|
+
const store = buildStore(listed);
|
|
115
|
+
const { results, indexHits } = searchProject(store, terms);
|
|
116
116
|
if (!results.length && !indexHits.length) continue;
|
|
117
117
|
total += results.length;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
stores.push({
|
|
119
|
+
id: listed.id,
|
|
120
|
+
kind: listed.kind,
|
|
121
|
+
label: store.label,
|
|
122
|
+
sublabel: listed.sublabel || null,
|
|
121
123
|
results: results.slice(0, limit),
|
|
122
124
|
indexHits: indexHits.slice(0, 20),
|
|
123
125
|
});
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
return { terms, total,
|
|
128
|
+
return { terms, total, stores: stores.sort((a, b) => b.results.length - a.results.length) };
|
|
127
129
|
}
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Layered reads of Claude Code's settings files.
|
|
2
|
+
//
|
|
3
|
+
// Several keys this tool cares about - autoMemoryDirectory, autoMemoryEnabled -
|
|
4
|
+
// can be set in any settings scope, and the scope that wins is not the one you
|
|
5
|
+
// would guess: managed policy beats everything, and the user file everyone
|
|
6
|
+
// actually edits is the weakest of the five. Reading only ~/.claude/settings.json
|
|
7
|
+
// silently shows the wrong store to anyone who set the key anywhere else.
|
|
8
|
+
//
|
|
9
|
+
// Precedence, strongest first:
|
|
10
|
+
// managed policy -> --settings -> .claude/settings.local.json
|
|
11
|
+
// -> .claude/settings.json -> ~/.claude/settings.json
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const USER_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
|
|
18
|
+
|
|
19
|
+
/** Default when nothing sets cleanupPeriodDays; transcripts older than this are swept. */
|
|
20
|
+
export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
|
|
21
|
+
|
|
22
|
+
function managedDir() {
|
|
23
|
+
if (process.platform === 'darwin') return '/Library/Application Support/ClaudeCode';
|
|
24
|
+
if (process.platform === 'win32') return 'C:\\Program Files\\ClaudeCode';
|
|
25
|
+
return '/etc/claude-code';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Managed policy settings: the main file plus the managed-settings.d drop-in
|
|
30
|
+
* directory, which organisations use to compose policy from several files.
|
|
31
|
+
*/
|
|
32
|
+
export function managedSettingsFiles() {
|
|
33
|
+
const dir = managedDir();
|
|
34
|
+
const files = [path.join(dir, 'managed-settings.json')];
|
|
35
|
+
let dropIns;
|
|
36
|
+
try {
|
|
37
|
+
dropIns = fs.readdirSync(path.join(dir, 'managed-settings.d'));
|
|
38
|
+
} catch {
|
|
39
|
+
dropIns = [];
|
|
40
|
+
}
|
|
41
|
+
for (const name of dropIns.filter((n) => n.endsWith('.json')).sort()) {
|
|
42
|
+
files.push(path.join(dir, 'managed-settings.d', name));
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readJson(file) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
50
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
51
|
+
} catch {
|
|
52
|
+
// Absent, unreadable, or not valid JSON. Claude Code would ignore it too.
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every settings file that exists and parses, strongest first, each tagged with
|
|
59
|
+
* the scope it came from so the UI can name the file a value came from rather
|
|
60
|
+
* than leaving the user to guess which of five files is in charge.
|
|
61
|
+
*
|
|
62
|
+
* `projectDir` is the directory a session would run in. This tool browses every
|
|
63
|
+
* project at once and is not itself a session, so callers pass either the launch
|
|
64
|
+
* cwd (for the store location, which is decided before any project is chosen) or
|
|
65
|
+
* a specific project's resolved path (for that project's auto memory state).
|
|
66
|
+
*/
|
|
67
|
+
export function settingsLayers({ projectDir = null, settingsFile = null } = {}) {
|
|
68
|
+
const candidates = [];
|
|
69
|
+
for (const file of managedSettingsFiles()) candidates.push({ scope: 'managed', file });
|
|
70
|
+
if (settingsFile) candidates.push({ scope: 'settings-flag', file: settingsFile });
|
|
71
|
+
if (projectDir) {
|
|
72
|
+
candidates.push({ scope: 'local', file: path.join(projectDir, '.claude', 'settings.local.json') });
|
|
73
|
+
candidates.push({ scope: 'project', file: path.join(projectDir, '.claude', 'settings.json') });
|
|
74
|
+
}
|
|
75
|
+
candidates.push({ scope: 'user', file: USER_SETTINGS });
|
|
76
|
+
|
|
77
|
+
const layers = [];
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
const data = readJson(candidate.file);
|
|
80
|
+
if (data) layers.push({ ...candidate, data });
|
|
81
|
+
}
|
|
82
|
+
return layers;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** First layer that defines `key`, or null. Layers are already in precedence order. */
|
|
86
|
+
export function lookup(layers, key) {
|
|
87
|
+
for (const layer of layers) {
|
|
88
|
+
if (Object.prototype.hasOwnProperty.call(layer.data, key)) {
|
|
89
|
+
return { value: layer.data[key], scope: layer.scope, file: layer.file };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function expandHome(value) {
|
|
96
|
+
if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where the auto memory store lives, per settings.
|
|
102
|
+
*
|
|
103
|
+
* The documented contract is "an absolute path or a `~/`-prefixed path". A value
|
|
104
|
+
* that is neither is reported rather than quietly ignored: the alternative is
|
|
105
|
+
* showing someone the default store while their setting points elsewhere, which
|
|
106
|
+
* is the exact failure this function exists to prevent.
|
|
107
|
+
*
|
|
108
|
+
* Returns { path, scope, file } when set, { invalid, ... } when set but
|
|
109
|
+
* unusable, or null when no layer sets it.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveMemoryDirectory(options = {}) {
|
|
112
|
+
const found = lookup(settingsLayers(options), 'autoMemoryDirectory');
|
|
113
|
+
if (!found) return null;
|
|
114
|
+
|
|
115
|
+
const raw = typeof found.value === 'string' ? found.value.trim() : '';
|
|
116
|
+
if (!raw) return null;
|
|
117
|
+
if (!raw.startsWith('/') && !raw.startsWith('~/') && !/^[A-Za-z]:[\\/]/.test(raw)) {
|
|
118
|
+
return { ...found, raw, path: null, invalid: 'not an absolute or ~/ path' };
|
|
119
|
+
}
|
|
120
|
+
return { ...found, raw, path: expandHome(raw), invalid: null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether auto memory is on for a project, and what decided it.
|
|
125
|
+
*
|
|
126
|
+
* A project with auto memory off has a store that will never grow again, which
|
|
127
|
+
* on disk is indistinguishable from a project Claude has simply not learned
|
|
128
|
+
* anything about yet. Without a resolved project path there is no project or
|
|
129
|
+
* local layer to read, so the answer is unknown rather than assumed.
|
|
130
|
+
*/
|
|
131
|
+
export function autoMemoryState({ projectDir = null } = {}) {
|
|
132
|
+
const env = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY;
|
|
133
|
+
if (env && env !== '0' && env !== 'false') {
|
|
134
|
+
return { enabled: false, setBy: 'CLAUDE_CODE_DISABLE_AUTO_MEMORY', scope: 'env', known: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const found = lookup(settingsLayers({ projectDir }), 'autoMemoryEnabled');
|
|
138
|
+
if (found && typeof found.value === 'boolean') {
|
|
139
|
+
return { enabled: found.value, setBy: found.file, scope: found.scope, known: true };
|
|
140
|
+
}
|
|
141
|
+
// Default is true, but without a project path the project and local layers
|
|
142
|
+
// were never consulted, so "on" here is an assumption and is labelled one.
|
|
143
|
+
return { enabled: true, setBy: null, scope: 'default', known: Boolean(projectDir) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Retention period for session transcripts. Memory files are excluded from the sweep. */
|
|
147
|
+
export function cleanupPeriodDays(options = {}) {
|
|
148
|
+
const found = lookup(settingsLayers(options), 'cleanupPeriodDays');
|
|
149
|
+
const value = Number(found?.value);
|
|
150
|
+
return Number.isFinite(value) && value >= 1 ? value : DEFAULT_CLEANUP_PERIOD_DAYS;
|
|
151
|
+
}
|
package/src/stats.mjs
CHANGED
|
@@ -26,16 +26,107 @@ export const INDEX_BYTE_LIMIT = 25 * 1024;
|
|
|
26
26
|
export const LONG_HOOK_CHARS = 200;
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* The text that actually counts against the limits
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* The text that actually counts against the limits, plus a map from each line of
|
|
30
|
+
* it back to the line it came from in the raw file.
|
|
31
|
+
*
|
|
32
|
+
* Claude Code strips YAML frontmatter and block-level HTML comments before
|
|
33
|
+
* loading the index, so measuring the raw file would overstate the size. That
|
|
34
|
+
* stripping is also why a position in the loaded text is not a line number in
|
|
35
|
+
* the file: to draw the cutoff where the user can see it, the removed spans have
|
|
36
|
+
* to be tracked rather than just discarded.
|
|
32
37
|
*/
|
|
38
|
+
export function loadedIndex(indexText) {
|
|
39
|
+
if (!indexText) return { text: '', rawLineFor: [] };
|
|
40
|
+
|
|
41
|
+
const removed = [];
|
|
42
|
+
const frontmatter = indexText.match(/^---\n[\s\S]*?\n---\n?/);
|
|
43
|
+
const base = frontmatter ? frontmatter[0].length : 0;
|
|
44
|
+
if (frontmatter) removed.push([0, base]);
|
|
45
|
+
|
|
46
|
+
// Matched on the post-frontmatter text, exactly as the stripping itself is, so
|
|
47
|
+
// the `m` anchors land on the same line starts.
|
|
48
|
+
for (const match of indexText.slice(base).matchAll(/^[ \t]*<!--[\s\S]*?-->[ \t]*\n?/gm)) {
|
|
49
|
+
removed.push([base + match.index, base + match.index + match[0].length]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
removed.sort((a, b) => a[0] - b[0]);
|
|
53
|
+
const kept = [];
|
|
54
|
+
let cursor = 0;
|
|
55
|
+
for (const [start, end] of removed) {
|
|
56
|
+
if (start > cursor) kept.push([cursor, start]);
|
|
57
|
+
cursor = Math.max(cursor, end);
|
|
58
|
+
}
|
|
59
|
+
if (cursor < indexText.length) kept.push([cursor, indexText.length]);
|
|
60
|
+
|
|
61
|
+
const rawLineStarts = [0];
|
|
62
|
+
for (let i = 0; i < indexText.length; i++) {
|
|
63
|
+
if (indexText[i] === '\n') rawLineStarts.push(i + 1);
|
|
64
|
+
}
|
|
65
|
+
const rawLineAt = (offset) => {
|
|
66
|
+
let low = 0;
|
|
67
|
+
let high = rawLineStarts.length - 1;
|
|
68
|
+
while (low < high) {
|
|
69
|
+
const mid = (low + high + 1) >> 1;
|
|
70
|
+
if (rawLineStarts[mid] <= offset) low = mid;
|
|
71
|
+
else high = mid - 1;
|
|
72
|
+
}
|
|
73
|
+
return low;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// One raw offset per surviving character, which is what makes the line map a
|
|
77
|
+
// lookup rather than a second parse that could disagree with the first.
|
|
78
|
+
const offsets = [];
|
|
79
|
+
for (const [start, end] of kept) {
|
|
80
|
+
for (let i = start; i < end; i++) offsets.push(i);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rawLineFor = [];
|
|
84
|
+
if (offsets.length) rawLineFor.push(rawLineAt(offsets[0]));
|
|
85
|
+
for (let i = 0; i < offsets.length - 1; i++) {
|
|
86
|
+
if (indexText[offsets[i]] === '\n') rawLineFor.push(rawLineAt(offsets[i + 1]));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { text: kept.map(([start, end]) => indexText.slice(start, end)).join(''), rawLineFor };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The loaded text alone, for callers that do not need the line map. */
|
|
33
93
|
export function loadedIndexText(indexText) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
94
|
+
return loadedIndex(indexText).text;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where the index stops being loaded, and which entries fall past it.
|
|
99
|
+
*
|
|
100
|
+
* The percentage on the meter says an index is too big; this says which memories
|
|
101
|
+
* Claude can no longer see, which is the thing you can act on. Both are derived
|
|
102
|
+
* from the same loaded text, so they cannot disagree.
|
|
103
|
+
*/
|
|
104
|
+
function findCutoff(loaded, entries) {
|
|
105
|
+
const lines = loaded.text.split('\n');
|
|
106
|
+
let bytes = 0;
|
|
107
|
+
let byteCut = Infinity;
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
// Every line but the last carries its newline.
|
|
110
|
+
bytes += Buffer.byteLength(lines[i], 'utf8') + (i < lines.length - 1 ? 1 : 0);
|
|
111
|
+
if (bytes > INDEX_BYTE_LIMIT) { byteCut = i; break; }
|
|
112
|
+
}
|
|
113
|
+
const lineCut = loaded.rawLineFor.length > INDEX_LINE_LIMIT ? INDEX_LINE_LIMIT : Infinity;
|
|
114
|
+
|
|
115
|
+
const loadedLine = Math.min(lineCut, byteCut);
|
|
116
|
+
if (!Number.isFinite(loadedLine)) return null;
|
|
117
|
+
|
|
118
|
+
const rawLine = loaded.rawLineFor[loadedLine];
|
|
119
|
+
if (rawLine === undefined) return null;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
loadedLine,
|
|
123
|
+
rawLine,
|
|
124
|
+
by: lineCut <= byteCut ? 'lines' : 'bytes',
|
|
125
|
+
droppedLines: loaded.rawLineFor.length - loadedLine,
|
|
126
|
+
droppedEntries: entries
|
|
127
|
+
.filter((entry) => entry.index >= rawLine)
|
|
128
|
+
.map((entry) => ({ index: entry.index, file: entry.file, title: entry.title })),
|
|
129
|
+
};
|
|
39
130
|
}
|
|
40
131
|
|
|
41
132
|
export function indexStats(indexText, entries) {
|
|
@@ -44,12 +135,13 @@ export function indexStats(indexText, entries) {
|
|
|
44
135
|
bytes: 0, lines: 0, tokens: 0, entryCount: 0, longHooks: [], longestHook: 0,
|
|
45
136
|
lineLimit: INDEX_LINE_LIMIT, byteLimit: INDEX_BYTE_LIMIT,
|
|
46
137
|
linePercent: 0, bytePercent: 0, worstPercent: 0, level: 'ok', overLimit: false, nearLimit: false,
|
|
138
|
+
cutoff: null,
|
|
47
139
|
};
|
|
48
140
|
}
|
|
49
141
|
|
|
50
|
-
const loaded =
|
|
51
|
-
const lines = loaded.
|
|
52
|
-
const bytes = Buffer.byteLength(loaded, 'utf8');
|
|
142
|
+
const loaded = loadedIndex(indexText);
|
|
143
|
+
const lines = loaded.rawLineFor.length;
|
|
144
|
+
const bytes = Buffer.byteLength(loaded.text, 'utf8');
|
|
53
145
|
|
|
54
146
|
const longHooks = entries
|
|
55
147
|
.filter((entry) => entry.hook.length > LONG_HOOK_CHARS)
|
|
@@ -64,7 +156,7 @@ export function indexStats(indexText, entries) {
|
|
|
64
156
|
bytes,
|
|
65
157
|
lines,
|
|
66
158
|
rawBytes: Buffer.byteLength(indexText, 'utf8'),
|
|
67
|
-
tokens: estimateTokens(loaded),
|
|
159
|
+
tokens: estimateTokens(loaded.text),
|
|
68
160
|
entryCount: entries.length,
|
|
69
161
|
longHooks,
|
|
70
162
|
longestHook: entries.reduce((max, e) => Math.max(max, e.hook.length), 0),
|
|
@@ -77,6 +169,7 @@ export function indexStats(indexText, entries) {
|
|
|
77
169
|
overLimit: lines > INDEX_LINE_LIMIT || bytes > INDEX_BYTE_LIMIT,
|
|
78
170
|
nearLimit: worstPercent >= 75,
|
|
79
171
|
level: worstPercent > 100 ? 'over' : worstPercent >= 75 ? 'near' : 'ok',
|
|
172
|
+
cutoff: findCutoff(loaded, entries),
|
|
80
173
|
};
|
|
81
174
|
}
|
|
82
175
|
|