claude-memory-admin 1.10.1 → 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/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
- export const USER_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
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
- if (!raw.startsWith('/') && !raw.startsWith('~/') && !/^[A-Za-z]:[\\/]/.test(raw)) {
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 env = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY;
190
- if (env && env !== '0' && env !== 'false') {
191
- return { enabled: false, setBy: 'CLAUDE_CODE_DISABLE_AUTO_MEMORY', scope: 'env', known: true };
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(settingsLayers({ projectDir }), 'autoMemoryEnabled');
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 raw = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY;
267
- const disabling = Boolean(raw && raw !== '0' && raw !== 'false');
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: 'CLAUDE_CODE_DISABLE_AUTO_MEMORY',
296
- value: raw ?? null,
297
- overrides: disabling ? 'autoMemoryEnabled' : null,
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 = path.join(os.homedir(), '.claude', 'agent-memory');
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) || seen.has(projectPath)) continue;
102
- seen.add(projectPath);
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(home) {
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
- * `home` is a parameter because the global entry is about the real home directory
158
- * rather than the memory root, which --root can point somewhere else entirely.
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 = os.homedir() } = {}) {
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(home),
234
+ globalStore(global),
168
235
  ...projects.map((project) => autoStore(project, root)),
169
- ...listAgentStores({ projectPaths }),
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 (process.platform !== 'win32') return [binary];
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
- fs.accessSync(candidate, fs.constants.X_OK);
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
- export function runBinary(binary, args, { cwd, timeout = TIMEOUT_MS } = {}) {
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 options = { cwd, timeout, maxBuffer: MAX_BUFFER, windowsHide: true, encoding: 'utf8' };
38
- execFile(binary, args, options, (err, stdout, stderr) => {
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`));