claude-memory-admin 1.0.0 → 1.0.1
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 +97 -6
- package/package.json +1 -1
- package/public/app.mjs +387 -90
- package/public/styles.css +37 -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
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
|
|
package/src/stores.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Discovery of every memory store on the machine, of both kinds Claude Code
|
|
2
|
+
// keeps.
|
|
3
|
+
//
|
|
4
|
+
// Auto memory is one store per project under the projects root. Subagents with a
|
|
5
|
+
// `memory:` field in their frontmatter get their own directories as well, in one
|
|
6
|
+
// of three scopes, and those hold exactly the same thing: a MEMORY.md index, a
|
|
7
|
+
// topic file per memory, the same 200-line/25KB load limit and the same
|
|
8
|
+
// instruction to keep the index short. So they are the same object here, and
|
|
9
|
+
// everything downstream - parsing, the meter, the graph, health, prune, trash -
|
|
10
|
+
// works on them without knowing which kind it has.
|
|
11
|
+
//
|
|
12
|
+
// The project-scoped agent directories live inside repositories, which this tool
|
|
13
|
+
// only knows about because it already recovers each project's real path from the
|
|
14
|
+
// session transcripts. Nothing here guesses at a path that was not confirmed
|
|
15
|
+
// somewhere else first.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { listProjects, memoryDir, projectsRoot, shortLabel } from './projects.mjs';
|
|
21
|
+
|
|
22
|
+
export const AGENT_USER_DIR = path.join(os.homedir(), '.claude', 'agent-memory');
|
|
23
|
+
export const AGENT_PROJECT_DIR = path.join('.claude', 'agent-memory');
|
|
24
|
+
export const AGENT_LOCAL_DIR = path.join('.claude', 'agent-memory-local');
|
|
25
|
+
|
|
26
|
+
export const STORE_KINDS = {
|
|
27
|
+
auto: { label: 'Project', scope: null },
|
|
28
|
+
'agent-user': { label: 'Subagent', scope: 'user' },
|
|
29
|
+
'agent-project': { label: 'Subagent', scope: 'project' },
|
|
30
|
+
'agent-local': { label: 'Subagent', scope: 'local' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A store id is opaque and URL-safe, and is only ever handed back to us from a
|
|
35
|
+
* list we produced. Encoding the kind and the location keeps it stable across
|
|
36
|
+
* restarts without a registry file to keep in sync.
|
|
37
|
+
*/
|
|
38
|
+
function storeId(kind, key) {
|
|
39
|
+
return `${kind}:${Buffer.from(key, 'utf8').toString('base64url')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function agentDirs(parent) {
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = fs.readdirSync(parent, { withFileTypes: true });
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return entries
|
|
50
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
51
|
+
.map((entry) => entry.name)
|
|
52
|
+
.filter((name) => !name.startsWith('.'))
|
|
53
|
+
.sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Files in a store directory, excluding the index and the trash folder. */
|
|
57
|
+
function countMemories(dir) {
|
|
58
|
+
try {
|
|
59
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
60
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'MEMORY.md')
|
|
61
|
+
.length;
|
|
62
|
+
} catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function agentStore(kind, agentName, dir, projectPath = null) {
|
|
68
|
+
return {
|
|
69
|
+
id: storeId(kind, dir),
|
|
70
|
+
kind,
|
|
71
|
+
dir,
|
|
72
|
+
agentName,
|
|
73
|
+
projectPath,
|
|
74
|
+
label: agentName,
|
|
75
|
+
// Which repository a project- or local-scoped agent store belongs to; the
|
|
76
|
+
// same agent can have a different memory in every checkout.
|
|
77
|
+
sublabel: projectPath ? shortLabel(projectPath) : 'all projects',
|
|
78
|
+
memoryCount: countMemories(dir),
|
|
79
|
+
hasMemoryDir: true,
|
|
80
|
+
hasIndex: fs.existsSync(path.join(dir, 'MEMORY.md')),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Subagent memory directories, across all three scopes. Both roots are
|
|
86
|
+
* parameters rather than constants so this is a total function of where it is
|
|
87
|
+
* told to look, which is also what makes it testable without a real home dir.
|
|
88
|
+
*/
|
|
89
|
+
export function listAgentStores({ userDir = AGENT_USER_DIR, projectPaths = [] } = {}) {
|
|
90
|
+
const stores = [];
|
|
91
|
+
|
|
92
|
+
for (const name of agentDirs(userDir)) {
|
|
93
|
+
stores.push(agentStore('agent-user', name, path.join(userDir, name)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A repository can be reached through more than one project entry (a worktree
|
|
97
|
+
// and its root), so the same directory must not be listed twice.
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
for (const projectPath of projectPaths) {
|
|
100
|
+
if (!projectPath || !path.isAbsolute(projectPath) || seen.has(projectPath)) continue;
|
|
101
|
+
seen.add(projectPath);
|
|
102
|
+
for (const [kind, relative] of [['agent-project', AGENT_PROJECT_DIR], ['agent-local', AGENT_LOCAL_DIR]]) {
|
|
103
|
+
const parent = path.join(projectPath, relative);
|
|
104
|
+
for (const name of agentDirs(parent)) {
|
|
105
|
+
stores.push(agentStore(kind, name, path.join(parent, name), projectPath));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return stores.sort((a, b) => b.memoryCount - a.memoryCount
|
|
111
|
+
|| a.label.localeCompare(b.label)
|
|
112
|
+
|| a.sublabel.localeCompare(b.sublabel));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** An auto-memory project, in the shape the rest of the app expects of a store. */
|
|
116
|
+
function autoStore(project, root) {
|
|
117
|
+
return {
|
|
118
|
+
...project,
|
|
119
|
+
id: storeId('auto', project.slug),
|
|
120
|
+
kind: 'auto',
|
|
121
|
+
dir: memoryDir(root, project.slug),
|
|
122
|
+
sublabel: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every store, auto memory first. Agent stores are found under the project paths
|
|
128
|
+
* auto memory already resolved, so a repository this tool has never seen a
|
|
129
|
+
* session for contributes nothing.
|
|
130
|
+
*/
|
|
131
|
+
export function listStores(root = projectsRoot()) {
|
|
132
|
+
const projects = listProjects(root);
|
|
133
|
+
const projectPaths = projects
|
|
134
|
+
.filter((project) => project.pathExists)
|
|
135
|
+
.flatMap((project) => [project.path, ...(project.workingDirs || [])]);
|
|
136
|
+
|
|
137
|
+
return [
|
|
138
|
+
...projects.map((project) => autoStore(project, root)),
|
|
139
|
+
...listAgentStores({ projectPaths }),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Resolve an id against the discovered list, so only real stores are ever addressed. */
|
|
144
|
+
export function findStore(root, id) {
|
|
145
|
+
return listStores(root).find((store) => store.id === id) || null;
|
|
146
|
+
}
|