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/model.mjs
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
// Builds the full view model for one
|
|
2
|
-
// wikilink graph between them, and the consistency problems worth surfacing.
|
|
1
|
+
// Builds the full view model for one memory store: its index, its memory files,
|
|
2
|
+
// the wikilink graph between them, and the consistency problems worth surfacing.
|
|
3
|
+
//
|
|
4
|
+
// A store is addressed by its directory, not by a root and a slug. Auto memory
|
|
5
|
+
// and subagent memory sit in unrelated places on disk but hold the same shape, so
|
|
6
|
+
// everything below is written against the shape and knows nothing about which
|
|
7
|
+
// kind it was handed.
|
|
3
8
|
//
|
|
4
9
|
// The whole store is a few hundred kilobytes, so this runs per request. No
|
|
5
10
|
// cache, no watcher, no invalidation bugs.
|
|
@@ -8,6 +13,7 @@ import fs from 'node:fs';
|
|
|
8
13
|
import path from 'node:path';
|
|
9
14
|
import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
|
|
10
15
|
import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
|
|
16
|
+
import { autoMemoryState } from './settings.mjs';
|
|
11
17
|
import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
|
|
12
18
|
|
|
13
19
|
export const TRASH_DIR = '.trash';
|
|
@@ -46,6 +52,13 @@ export function loadMemory(dir, file) {
|
|
|
46
52
|
stat = fs.statSync(path.join(dir, file));
|
|
47
53
|
} catch { /* raced with a delete */ }
|
|
48
54
|
|
|
55
|
+
// Claude Code stamps `modified` only on files that already begin with
|
|
56
|
+
// frontmatter, and never adds frontmatter to a file that has none, so for those
|
|
57
|
+
// files the mtime fallback is permanent. mtime is also reset by any copy, rsync
|
|
58
|
+
// or restore - including this tool's own trash round-trip - so which of the two
|
|
59
|
+
// a date came from is the difference between evidence and a guess.
|
|
60
|
+
const stamped = typeof metadata.modified === 'string' && metadata.modified ? metadata.modified : null;
|
|
61
|
+
|
|
49
62
|
return {
|
|
50
63
|
file,
|
|
51
64
|
stem,
|
|
@@ -60,7 +73,8 @@ export function loadMemory(dir, file) {
|
|
|
60
73
|
body,
|
|
61
74
|
raw,
|
|
62
75
|
bytes: stat ? stat.size : raw.length,
|
|
63
|
-
modified:
|
|
76
|
+
modified: stamped || (stat ? new Date(stat.mtimeMs).toISOString() : null),
|
|
77
|
+
modifiedFrom: stamped ? 'frontmatter' : (stat ? 'mtime' : null),
|
|
64
78
|
tokens: estimateTokens(raw),
|
|
65
79
|
outbound: extractWikilinks(body).map((w) => w.target),
|
|
66
80
|
};
|
|
@@ -80,10 +94,8 @@ function buildResolver(memories) {
|
|
|
80
94
|
return (target) => byName.get(target) || byStem.get(target) || null;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
export function
|
|
84
|
-
const dir =
|
|
85
|
-
const projectDir = path.join(root, slug);
|
|
86
|
-
const resolved = resolveProjectPath(projectDir, slug);
|
|
97
|
+
export function buildStore(store) {
|
|
98
|
+
const { dir } = store;
|
|
87
99
|
const hasMemoryDir = fs.existsSync(dir);
|
|
88
100
|
|
|
89
101
|
const indexRaw = readIfExists(path.join(dir, 'MEMORY.md'));
|
|
@@ -165,10 +177,7 @@ export function buildProject(root, slug) {
|
|
|
165
177
|
health.issueCount = health.issues.length;
|
|
166
178
|
|
|
167
179
|
return {
|
|
168
|
-
|
|
169
|
-
path: resolved.path,
|
|
170
|
-
label: shortLabel(resolved.path),
|
|
171
|
-
resolvedBy: resolved.resolvedBy,
|
|
180
|
+
...store,
|
|
172
181
|
hasMemoryDir,
|
|
173
182
|
hasIndex: index !== null,
|
|
174
183
|
index: index
|
|
@@ -197,6 +206,30 @@ export function buildProject(root, slug) {
|
|
|
197
206
|
};
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The auto-memory store for one project. Everything the project model carries
|
|
211
|
+
* beyond the store itself - its real path, the directories that feed it, whether
|
|
212
|
+
* Claude is still writing to it - is resolved here and handed to buildStore.
|
|
213
|
+
*/
|
|
214
|
+
export function buildProject(root, slug) {
|
|
215
|
+
const resolved = resolveProjectPath(path.join(root, slug), slug);
|
|
216
|
+
return buildStore({
|
|
217
|
+
slug,
|
|
218
|
+
kind: 'auto',
|
|
219
|
+
dir: memoryDir(root, slug),
|
|
220
|
+
path: resolved.path,
|
|
221
|
+
label: shortLabel(resolved.path),
|
|
222
|
+
resolvedBy: resolved.resolvedBy,
|
|
223
|
+
// The decode that could not be confirmed, offered as a starting point when
|
|
224
|
+
// the user is asked to name the path themselves.
|
|
225
|
+
guess: resolved.guess || null,
|
|
226
|
+
workingDirs: resolved.workingDirs || [],
|
|
227
|
+
// Off means this store will never grow again, which on disk looks exactly
|
|
228
|
+
// like a project Claude has not learned anything about yet.
|
|
229
|
+
autoMemory: autoMemoryState({ projectDir: resolved.exists ? resolved.path : null }),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
200
233
|
/** Restore records left behind by soft deletes, newest first. */
|
|
201
234
|
export function listTrash(dir) {
|
|
202
235
|
const trashPath = path.join(dir, TRASH_DIR);
|
package/src/mutate.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
// The only code
|
|
1
|
+
// The only code that writes inside a memory store. Everything else is read-only.
|
|
2
|
+
//
|
|
3
|
+
// Every entry point takes the store's directory rather than a root and a slug:
|
|
4
|
+
// auto memory and subagent memory live in unrelated places on disk but hold the
|
|
5
|
+
// same MEMORY.md-plus-topic-files shape, so nothing below needs to know which
|
|
6
|
+
// kind it is working on.
|
|
2
7
|
//
|
|
3
8
|
// Deletes are soft: the file moves into memory/.trash/ and a restore record
|
|
4
9
|
// captures the MEMORY.md lines that were removed, with their original indices,
|
|
@@ -8,7 +13,7 @@ import crypto from 'node:crypto';
|
|
|
8
13
|
import fs from 'node:fs';
|
|
9
14
|
import path from 'node:path';
|
|
10
15
|
import { parseIndex, removeIndexEntries, removeLine, insertLines, unwrapWikilink } from './parse.mjs';
|
|
11
|
-
import { listMemoryFiles
|
|
16
|
+
import { listMemoryFiles } from './projects.mjs';
|
|
12
17
|
import { TRASH_DIR, loadMemory } from './model.mjs';
|
|
13
18
|
|
|
14
19
|
function sha256(text) {
|
|
@@ -94,8 +99,7 @@ const writeIndexAtomic = (dir, text) => writeFileAtomic(dir, 'MEMORY.md', text);
|
|
|
94
99
|
* What a delete would do, without doing it. This is what the confirm dialog
|
|
95
100
|
* renders, so it has to be exhaustive about the collateral.
|
|
96
101
|
*/
|
|
97
|
-
export function deletePreview(
|
|
98
|
-
const dir = memoryDir(root, slug);
|
|
102
|
+
export function deletePreview(dir, file) {
|
|
99
103
|
const full = safeMemoryPath(dir, file);
|
|
100
104
|
const exists = fs.existsSync(full);
|
|
101
105
|
const indexText = readIndex(dir);
|
|
@@ -165,8 +169,7 @@ export function deletePreview(root, slug, file) {
|
|
|
165
169
|
* clearing a whole project both come through here, so there is a single restore
|
|
166
170
|
* path rather than three.
|
|
167
171
|
*/
|
|
168
|
-
export function deleteMemories(
|
|
169
|
-
const dir = memoryDir(root, slug);
|
|
172
|
+
export function deleteMemories(dir, files, { includeIndex = false, label = null } = {}) {
|
|
170
173
|
const wanted = [...new Set([].concat(files))];
|
|
171
174
|
if (!wanted.length) throw new Error('Nothing selected to delete');
|
|
172
175
|
|
|
@@ -221,7 +224,6 @@ export function deleteMemories(root, slug, files, { includeIndex = false, label
|
|
|
221
224
|
id: `${stamp}_${includeIndex ? 'project' : targets[0].file}`,
|
|
222
225
|
label: label || (targets.length === 1 ? targets[0].name : `${targets.length} memories`),
|
|
223
226
|
deletedAt: new Date().toISOString(),
|
|
224
|
-
slug,
|
|
225
227
|
files: moved.map(({ file, trashedFile, name, description }) => ({ file, trashedFile, name, description })),
|
|
226
228
|
indexTrashedFile: moved.indexTrashed || null,
|
|
227
229
|
removedLines: removed,
|
|
@@ -233,18 +235,16 @@ export function deleteMemories(root, slug, files, { includeIndex = false, label
|
|
|
233
235
|
}
|
|
234
236
|
|
|
235
237
|
/** Single-memory delete, optionally cascading to the memories that link to it. */
|
|
236
|
-
export function deleteMemory(
|
|
238
|
+
export function deleteMemory(dir, file, alsoDelete = []) {
|
|
237
239
|
const extra = [].concat(alsoDelete).filter((f) => f && f !== file);
|
|
238
|
-
return deleteMemories(
|
|
240
|
+
return deleteMemories(dir, [file, ...extra]);
|
|
239
241
|
}
|
|
240
242
|
|
|
241
243
|
/** What clearing a whole project would remove. */
|
|
242
|
-
export function projectDeletePreview(
|
|
243
|
-
const dir = memoryDir(root, slug);
|
|
244
|
+
export function projectDeletePreview(dir) {
|
|
244
245
|
const files = listMemoryFiles(dir);
|
|
245
246
|
const indexText = readIndex(dir);
|
|
246
247
|
return {
|
|
247
|
-
slug,
|
|
248
248
|
files: files.map((file) => {
|
|
249
249
|
const memory = loadMemory(dir, file);
|
|
250
250
|
return { file, name: memory?.name || file, description: memory?.description || '' };
|
|
@@ -255,8 +255,7 @@ export function projectDeletePreview(root, slug) {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
/** Trash every memory in a project, MEMORY.md included, as one operation. */
|
|
258
|
-
export function deleteProject(
|
|
259
|
-
const dir = memoryDir(root, slug);
|
|
258
|
+
export function deleteProject(dir) {
|
|
260
259
|
const files = listMemoryFiles(dir);
|
|
261
260
|
const indexText = readIndex(dir);
|
|
262
261
|
if (!files.length && indexText === null) throw new Error('This project has no memory to delete');
|
|
@@ -274,7 +273,6 @@ export function deleteProject(root, slug) {
|
|
|
274
273
|
id: `${stamp}_project`,
|
|
275
274
|
label: 'MEMORY.md',
|
|
276
275
|
deletedAt: new Date().toISOString(),
|
|
277
|
-
slug,
|
|
278
276
|
files: [],
|
|
279
277
|
indexTrashedFile: trashedIndex,
|
|
280
278
|
removedLines: [],
|
|
@@ -285,7 +283,7 @@ export function deleteProject(root, slug) {
|
|
|
285
283
|
return { deleted: true, record };
|
|
286
284
|
}
|
|
287
285
|
|
|
288
|
-
return deleteMemories(
|
|
286
|
+
return deleteMemories(dir, files, {
|
|
289
287
|
includeIndex: indexText !== null,
|
|
290
288
|
label: `whole project (${files.length} memories)`,
|
|
291
289
|
});
|
|
@@ -295,8 +293,7 @@ export function deleteProject(root, slug) {
|
|
|
295
293
|
* Turn a broken `[[target]]` into plain text in one memory. The original file
|
|
296
294
|
* is copied into .trash first so the edit can be undone like any delete.
|
|
297
295
|
*/
|
|
298
|
-
export function removeWikilink(
|
|
299
|
-
const dir = memoryDir(root, slug);
|
|
296
|
+
export function removeWikilink(dir, file, target) {
|
|
300
297
|
const full = safeMemoryPath(dir, file);
|
|
301
298
|
if (!fs.existsSync(full)) throw new Error(`No such memory: ${file}`);
|
|
302
299
|
if (typeof target !== 'string' || !target.trim()) throw new Error('No link target given');
|
|
@@ -324,7 +321,6 @@ export function removeWikilink(root, slug, file, target) {
|
|
|
324
321
|
id: `${stamp}_${file}.unlink`,
|
|
325
322
|
label: `[[${target}]] in ${file}`,
|
|
326
323
|
deletedAt: new Date().toISOString(),
|
|
327
|
-
slug,
|
|
328
324
|
sourceFile: file,
|
|
329
325
|
target,
|
|
330
326
|
occurrences: count,
|
|
@@ -337,8 +333,7 @@ export function removeWikilink(root, slug, file, target) {
|
|
|
337
333
|
}
|
|
338
334
|
|
|
339
335
|
/** Undo any trashed operation: a delete, a cascade, a project clear, or an unlink. */
|
|
340
|
-
export function restoreMemory(
|
|
341
|
-
const dir = memoryDir(root, slug);
|
|
336
|
+
export function restoreMemory(dir, id) {
|
|
342
337
|
const trashPath = path.join(dir, TRASH_DIR);
|
|
343
338
|
const recordPath = path.join(trashPath, `${id}.restore.json`);
|
|
344
339
|
if (!fs.existsSync(recordPath)) throw new Error('No such trash record');
|
|
@@ -403,8 +398,7 @@ export function restoreMemory(root, slug, id) {
|
|
|
403
398
|
}
|
|
404
399
|
|
|
405
400
|
/** Drop a single MEMORY.md line, used to clear a pointer whose file is gone. */
|
|
406
|
-
export function deleteIndexLine(
|
|
407
|
-
const dir = memoryDir(root, slug);
|
|
401
|
+
export function deleteIndexLine(dir, lineIndex, expectedText) {
|
|
408
402
|
const current = readIndex(dir);
|
|
409
403
|
if (current === null) throw new Error('This project has no MEMORY.md');
|
|
410
404
|
|
|
@@ -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
|
}
|