hippo-memory 1.52.9 → 1.53.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 +47 -13
- package/dist/agent-memories/apply.d.ts +47 -0
- package/dist/agent-memories/apply.js +253 -0
- package/dist/agent-memories/claude-code.d.ts +11 -0
- package/dist/agent-memories/claude-code.js +113 -0
- package/dist/agent-memories/codex.d.ts +3 -0
- package/dist/agent-memories/codex.js +47 -0
- package/dist/agent-memories/copilot.d.ts +3 -0
- package/dist/agent-memories/copilot.js +125 -0
- package/dist/agent-memories/files.d.ts +37 -0
- package/dist/agent-memories/files.js +77 -0
- package/dist/agent-memories/folder-store.d.ts +17 -0
- package/dist/agent-memories/folder-store.js +44 -0
- package/dist/agent-memories/gemini.d.ts +3 -0
- package/dist/agent-memories/gemini.js +103 -0
- package/dist/agent-memories/git.d.ts +8 -0
- package/dist/agent-memories/git.js +11 -0
- package/dist/agent-memories/keys.d.ts +9 -0
- package/dist/agent-memories/keys.js +20 -0
- package/dist/agent-memories/legacy.d.ts +17 -0
- package/dist/agent-memories/legacy.js +45 -0
- package/dist/agent-memories/markdown.d.ts +13 -0
- package/dist/agent-memories/markdown.js +123 -0
- package/dist/agent-memories/openclaw.d.ts +3 -0
- package/dist/agent-memories/openclaw.js +42 -0
- package/dist/agent-memories/plan.d.ts +78 -0
- package/dist/agent-memories/plan.js +123 -0
- package/dist/agent-memories/qwen-code.d.ts +5 -0
- package/dist/agent-memories/qwen-code.js +50 -0
- package/dist/agent-memories/report.d.ts +52 -0
- package/dist/agent-memories/report.js +88 -0
- package/dist/agent-memories/source.d.ts +16 -0
- package/dist/agent-memories/source.js +32 -0
- package/dist/agent-memories/sync.d.ts +33 -0
- package/dist/agent-memories/sync.js +336 -0
- package/dist/agent-memories/tools.d.ts +33 -0
- package/dist/agent-memories/tools.js +19 -0
- package/dist/agent-memories/types.d.ts +42 -0
- package/dist/agent-memories/types.js +2 -0
- package/dist/api.d.ts +2 -2
- package/dist/api.js +28 -29
- package/dist/audit.d.ts +4 -3
- package/dist/audit.js +10 -6
- package/dist/capture.d.ts +11 -22
- package/dist/capture.js +76 -81
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +133 -170
- package/dist/compaction-items.d.ts +18 -0
- package/dist/compaction-items.js +60 -0
- package/dist/compaction-record.d.ts +94 -0
- package/dist/compaction-record.js +573 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +13 -0
- package/dist/consolidate.d.ts +0 -2
- package/dist/consolidate.js +3 -36
- package/dist/db.d.ts +5 -1
- package/dist/db.js +40 -8
- package/dist/dedupe.js +3 -2
- package/dist/doctor.js +34 -2
- package/dist/dormant.d.ts +5 -3
- package/dist/dormant.js +9 -0
- package/dist/gated-write.d.ts +9 -0
- package/dist/gated-write.js +24 -0
- package/dist/hooks.d.ts +4 -2
- package/dist/hooks.js +8 -7
- package/dist/memory.d.ts +19 -2
- package/dist/memory.js +32 -3
- package/dist/shared.js +10 -7
- package/dist/store.d.ts +14 -2
- package/dist/store.js +75 -19
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// GitHub Copilot Chat in VS Code: memory-tool files under VS Code's own data folders, one set per product and workspace.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readTextFile } from './files.js';
|
|
5
|
+
import { readFolderStore } from './folder-store.js';
|
|
6
|
+
import { gitLayout } from './git.js';
|
|
7
|
+
const PRODUCTS = ['Code', 'Code - Insiders'];
|
|
8
|
+
const MEMORY_TOOL = ['github.copilot-chat', 'memory-tool', 'memories'];
|
|
9
|
+
/** VS Code's user-data resolution: portable install first, then the app-data override, then the platform default. */
|
|
10
|
+
function dataFolders(ctx) {
|
|
11
|
+
const { env, home, platform } = ctx;
|
|
12
|
+
if (env.VSCODE_PORTABLE)
|
|
13
|
+
return [path.join(env.VSCODE_PORTABLE, 'user-data')];
|
|
14
|
+
let appData;
|
|
15
|
+
if (env.VSCODE_APPDATA)
|
|
16
|
+
appData = env.VSCODE_APPDATA;
|
|
17
|
+
else if (platform === 'win32')
|
|
18
|
+
appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
19
|
+
else if (platform === 'darwin')
|
|
20
|
+
appData = path.join(home, 'Library', 'Application Support');
|
|
21
|
+
else
|
|
22
|
+
appData = env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
23
|
+
return PRODUCTS.map((product) => path.join(appData, product));
|
|
24
|
+
}
|
|
25
|
+
/** A `file:` URI as a local path; null for any other scheme or a URI with a host part. */
|
|
26
|
+
function fileUriToPath(uri, platform) {
|
|
27
|
+
if (!uri.startsWith('file://'))
|
|
28
|
+
return null;
|
|
29
|
+
const rest = decodeURIComponent(uri.slice('file://'.length));
|
|
30
|
+
if (!rest.startsWith('/'))
|
|
31
|
+
return null;
|
|
32
|
+
return platform === 'win32' ? rest.replace(/^\/(?=[a-zA-Z]:)/, '').replace(/\//g, '\\') : rest;
|
|
33
|
+
}
|
|
34
|
+
/** The workspace's single folder as a path; null (with a warning when the file is unusable) for anything else. */
|
|
35
|
+
function workspaceFolder(file, platform, warnings) {
|
|
36
|
+
const read = readTextFile(file);
|
|
37
|
+
if (!read.ok) {
|
|
38
|
+
warnings.push(read.reason);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
// SAFETY: JSON.parse output; a non-string `folder` or a scalar file throws below and becomes a warning.
|
|
43
|
+
const json = JSON.parse(read.text);
|
|
44
|
+
if (json === null || Array.isArray(json))
|
|
45
|
+
throw new Error('not a JSON object');
|
|
46
|
+
if ('workspace' in json || !json.folder)
|
|
47
|
+
return null;
|
|
48
|
+
return fileUriToPath(json.folder, platform);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
warnings.push(`${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Matches the project root exactly, or its git top level; git is asked once, and only when a folder needs it. */
|
|
56
|
+
function projectMatcher(projectRoot, platform) {
|
|
57
|
+
const fold = (p) => (platform === 'win32' ? p.toLowerCase() : p);
|
|
58
|
+
const root = fold(path.resolve(projectRoot));
|
|
59
|
+
let top;
|
|
60
|
+
return (folder) => {
|
|
61
|
+
const wanted = fold(folder);
|
|
62
|
+
if (wanted === root)
|
|
63
|
+
return true;
|
|
64
|
+
if (top === undefined) {
|
|
65
|
+
const layout = gitLayout(projectRoot);
|
|
66
|
+
top = layout === null ? null : fold(path.resolve(layout.top));
|
|
67
|
+
}
|
|
68
|
+
return wanted === top;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const EVERY_FILE = {
|
|
72
|
+
recursive: true,
|
|
73
|
+
include: () => true,
|
|
74
|
+
item: (text, mtimeMs) => ({ text: text.trim(), updatedAt: mtimeMs }),
|
|
75
|
+
};
|
|
76
|
+
function isDirectory(p) {
|
|
77
|
+
return fs.statSync(p, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
78
|
+
}
|
|
79
|
+
function projectContainers(data, ctx, matches, warnings) {
|
|
80
|
+
const storage = path.join(data, 'User', 'workspaceStorage');
|
|
81
|
+
if (!fs.existsSync(storage))
|
|
82
|
+
return [];
|
|
83
|
+
let ids;
|
|
84
|
+
try {
|
|
85
|
+
ids = fs.readdirSync(storage, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
warnings.push(`${storage}: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
const found = [];
|
|
92
|
+
for (const id of ids) {
|
|
93
|
+
const repo = path.join(storage, id, ...MEMORY_TOOL, 'repo');
|
|
94
|
+
if (!isDirectory(repo))
|
|
95
|
+
continue;
|
|
96
|
+
const folder = workspaceFolder(path.join(storage, id, 'workspace.json'), ctx.platform, warnings);
|
|
97
|
+
const container = folder !== null && matches(folder) ? readFolderStore(repo, 'project', EVERY_FILE) : null;
|
|
98
|
+
if (container)
|
|
99
|
+
found.push(container);
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
export const copilotAdapter = {
|
|
104
|
+
tool: 'copilot',
|
|
105
|
+
list(ctx, scope) {
|
|
106
|
+
const data = dataFolders(ctx);
|
|
107
|
+
const warnings = [];
|
|
108
|
+
const found = [];
|
|
109
|
+
if (scope === 'user') {
|
|
110
|
+
for (const dir of data) {
|
|
111
|
+
const container = readFolderStore(path.join(dir, 'User', 'globalStorage', ...MEMORY_TOOL), 'user', EVERY_FILE);
|
|
112
|
+
if (container)
|
|
113
|
+
found.push(container);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (ctx.projectRoot !== undefined) {
|
|
117
|
+
const matches = projectMatcher(ctx.projectRoot, ctx.platform);
|
|
118
|
+
for (const dir of data)
|
|
119
|
+
found.push(...projectContainers(dir, ctx, matches, warnings));
|
|
120
|
+
}
|
|
121
|
+
const home = data.find((dir) => fs.existsSync(dir)) ?? data[0];
|
|
122
|
+
return { tool: 'copilot', home, containers: found, warnings };
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
//# sourceMappingURL=copilot.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const MAX_ITEM_BYTES: number;
|
|
2
|
+
export type TextFile = {
|
|
3
|
+
readonly ok: true;
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly mtimeMs: number;
|
|
6
|
+
} | {
|
|
7
|
+
readonly ok: false;
|
|
8
|
+
readonly reason: string;
|
|
9
|
+
};
|
|
10
|
+
/** A file's text, or why it was skipped: empty, too big, holds a NUL byte (not text), or unreadable. */
|
|
11
|
+
export declare function readTextFile(file: string): TextFile;
|
|
12
|
+
export type DirListing = {
|
|
13
|
+
readonly status: 'ok';
|
|
14
|
+
readonly files: readonly string[];
|
|
15
|
+
} | {
|
|
16
|
+
readonly status: 'missing';
|
|
17
|
+
} | {
|
|
18
|
+
readonly status: 'unreadable';
|
|
19
|
+
readonly reason: string;
|
|
20
|
+
};
|
|
21
|
+
/** Files in a folder as '/'-separated relative paths, sorted; dot entries skipped, symlinked folders not followed. */
|
|
22
|
+
export declare function listFiles(dir: string, opts: {
|
|
23
|
+
recursive: boolean;
|
|
24
|
+
}): DirListing;
|
|
25
|
+
export interface Frontmatter {
|
|
26
|
+
/** The YAML between the fences, or null when the file has none. */
|
|
27
|
+
readonly yaml: string | null;
|
|
28
|
+
readonly body: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function splitFrontmatter(raw: string): Frontmatter;
|
|
31
|
+
/** One top-level scalar from frontmatter YAML, quotes removed; enough for `modified`, no YAML parser needed. */
|
|
32
|
+
export declare function frontmatterField(yaml: string, field: string): string | null;
|
|
33
|
+
/** An item's time: the date string when it parses and is not in the future, else the fallback. */
|
|
34
|
+
export declare function itemTime(dateText: string | null, fallbackMs: number, nowMs?: number): number;
|
|
35
|
+
/** `~/x` against the given home; anything else unchanged. */
|
|
36
|
+
export declare function expandHome(p: string, home: string): string;
|
|
37
|
+
//# sourceMappingURL=files.d.ts.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Read-only file access shared by the adapters: size, binary and failure checks live in one place.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const MAX_ITEM_BYTES = 256 * 1024;
|
|
5
|
+
const MAX_DEPTH = 8;
|
|
6
|
+
/** A file's text, or why it was skipped: empty, too big, holds a NUL byte (not text), or unreadable. */
|
|
7
|
+
export function readTextFile(file) {
|
|
8
|
+
try {
|
|
9
|
+
const stat = fs.statSync(file);
|
|
10
|
+
if (!stat.isFile())
|
|
11
|
+
return { ok: false, reason: `${file}: not a file` };
|
|
12
|
+
// A tool rewriting a file truncates it first; read as holding no notes, every row would be set aside.
|
|
13
|
+
if (stat.size === 0)
|
|
14
|
+
return { ok: false, reason: `${file}: empty` };
|
|
15
|
+
if (stat.size > MAX_ITEM_BYTES)
|
|
16
|
+
return { ok: false, reason: `${file}: over ${MAX_ITEM_BYTES} bytes` };
|
|
17
|
+
const buf = fs.readFileSync(file);
|
|
18
|
+
if (buf.includes(0))
|
|
19
|
+
return { ok: false, reason: `${file}: not text` };
|
|
20
|
+
return { ok: true, text: buf.toString('utf8'), mtimeMs: stat.mtimeMs };
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
return { ok: false, reason: `${file}: ${err instanceof Error ? err.message : String(err)}` };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Files in a folder as '/'-separated relative paths, sorted; dot entries skipped, symlinked folders not followed. */
|
|
27
|
+
export function listFiles(dir, opts) {
|
|
28
|
+
if (!fs.existsSync(dir))
|
|
29
|
+
return { status: 'missing' };
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.statSync(dir).isDirectory())
|
|
32
|
+
return { status: 'unreadable', reason: `${dir}: not a folder` };
|
|
33
|
+
const files = [];
|
|
34
|
+
walk(dir, '', opts.recursive ? MAX_DEPTH : 0, files);
|
|
35
|
+
return { status: 'ok', files: files.sort() };
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return { status: 'unreadable', reason: `${dir}: ${err instanceof Error ? err.message : String(err)}` };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function walk(root, rel, depthLeft, out) {
|
|
42
|
+
for (const entry of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
43
|
+
if (entry.name.startsWith('.'))
|
|
44
|
+
continue;
|
|
45
|
+
const child = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
46
|
+
if (entry.isFile() || (entry.isSymbolicLink() && isFile(path.join(root, child))))
|
|
47
|
+
out.push(child);
|
|
48
|
+
else if (entry.isDirectory() && depthLeft > 0)
|
|
49
|
+
walk(root, child, depthLeft - 1, out);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isFile(p) {
|
|
53
|
+
return fs.statSync(p, { throwIfNoEntry: false })?.isFile() === true;
|
|
54
|
+
}
|
|
55
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
|
56
|
+
export function splitFrontmatter(raw) {
|
|
57
|
+
const m = raw.match(FRONTMATTER);
|
|
58
|
+
return m ? { yaml: m[1], body: m[2] } : { yaml: null, body: raw };
|
|
59
|
+
}
|
|
60
|
+
/** One top-level scalar from frontmatter YAML, quotes removed; enough for `modified`, no YAML parser needed. */
|
|
61
|
+
export function frontmatterField(yaml, field) {
|
|
62
|
+
const m = yaml.match(new RegExp(`^${field}:[ \\t]*(.+?)[ \\t]*$`, 'm'));
|
|
63
|
+
if (!m)
|
|
64
|
+
return null;
|
|
65
|
+
return m[1].replace(/^(['"])(.*)\1$/, '$2');
|
|
66
|
+
}
|
|
67
|
+
/** An item's time: the date string when it parses and is not in the future, else the fallback. */
|
|
68
|
+
export function itemTime(dateText, fallbackMs, nowMs = Date.now()) {
|
|
69
|
+
const parsed = dateText === null ? NaN : Date.parse(dateText);
|
|
70
|
+
const t = Number.isFinite(parsed) ? parsed : fallbackMs;
|
|
71
|
+
return Math.min(t, nowMs);
|
|
72
|
+
}
|
|
73
|
+
/** `~/x` against the given home; anything else unchanged. */
|
|
74
|
+
export function expandHome(p, home) {
|
|
75
|
+
return p === '~' ? home : p.startsWith('~/') || p.startsWith('~\\') ? path.join(home, p.slice(2)) : p;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=files.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Container, Scope } from './types.js';
|
|
2
|
+
export interface FolderRules {
|
|
3
|
+
readonly recursive: boolean;
|
|
4
|
+
readonly include: (rel: string) => boolean;
|
|
5
|
+
/** The stored text and time of a file read in full, or null when the tool would not count it as a note. */
|
|
6
|
+
readonly item: (text: string, mtimeMs: number) => {
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly updatedAt: number;
|
|
9
|
+
} | null;
|
|
10
|
+
}
|
|
11
|
+
/** Null when the folder is missing; a file that could not be read is `skipped`, so its row is left alone. */
|
|
12
|
+
export declare function readFolderStore(dir: string, scope: Scope, rules: FolderRules): Container | null;
|
|
13
|
+
/** Markdown notes directly in, or anywhere under, the folder, with the tool's index file left out. */
|
|
14
|
+
export declare function markdownNotes(rel: string): boolean;
|
|
15
|
+
/** Each folder once, compared case-insensitively on win32 as the file system does. */
|
|
16
|
+
export declare function uniqueFolders(folders: readonly string[], platform: NodeJS.Platform): string[];
|
|
17
|
+
//# sourceMappingURL=folder-store.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// A folder whose files are the items, as Claude Code, Gemini, Qwen and Copilot keep them: one reader for all four.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { listFiles, readTextFile } from './files.js';
|
|
4
|
+
/** Null when the folder is missing; a file that could not be read is `skipped`, so its row is left alone. */
|
|
5
|
+
export function readFolderStore(dir, scope, rules) {
|
|
6
|
+
const listing = listFiles(dir, { recursive: rules.recursive });
|
|
7
|
+
if (listing.status === 'missing')
|
|
8
|
+
return null;
|
|
9
|
+
if (listing.status === 'unreadable') {
|
|
10
|
+
return { scope, path: dir, readable: false, items: [], skipped: [], warnings: [listing.reason], textKeyed: false };
|
|
11
|
+
}
|
|
12
|
+
const items = [];
|
|
13
|
+
const skipped = [];
|
|
14
|
+
const warnings = [];
|
|
15
|
+
for (const key of listing.files.filter(rules.include)) {
|
|
16
|
+
const file = readTextFile(path.join(dir, key));
|
|
17
|
+
if (!file.ok) {
|
|
18
|
+
skipped.push(key);
|
|
19
|
+
warnings.push(file.reason);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const item = rules.item(file.text, file.mtimeMs);
|
|
23
|
+
if (item !== null)
|
|
24
|
+
items.push({ key, ...item });
|
|
25
|
+
}
|
|
26
|
+
return { scope, path: dir, readable: true, items, skipped, warnings, textKeyed: false };
|
|
27
|
+
}
|
|
28
|
+
/** Markdown notes directly in, or anywhere under, the folder, with the tool's index file left out. */
|
|
29
|
+
export function markdownNotes(rel) {
|
|
30
|
+
return rel.endsWith('.md') && rel !== 'MEMORY.md';
|
|
31
|
+
}
|
|
32
|
+
/** Each folder once, compared case-insensitively on win32 as the file system does. */
|
|
33
|
+
export function uniqueFolders(folders, platform) {
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
return folders.filter((folder) => {
|
|
36
|
+
const resolved = path.resolve(folder);
|
|
37
|
+
const key = platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
38
|
+
if (seen.has(key))
|
|
39
|
+
return false;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=folder-store.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Gemini CLI: the "Gemini Added Memories" section of GEMINI.md, and the auto-memory folder that projects.json names.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isStringValue } from '../capture.js';
|
|
5
|
+
import { isJsonObject } from '../hooks.js';
|
|
6
|
+
import { readTextFile, splitFrontmatter } from './files.js';
|
|
7
|
+
import { markdownNotes, readFolderStore } from './folder-store.js';
|
|
8
|
+
import { gitLayout } from './git.js';
|
|
9
|
+
import { textItemKeys } from './keys.js';
|
|
10
|
+
import { splitMarkdownItems } from './markdown.js';
|
|
11
|
+
const SECTION = 'gemini added memories';
|
|
12
|
+
export const geminiAdapter = {
|
|
13
|
+
tool: 'gemini',
|
|
14
|
+
list(ctx, scope) {
|
|
15
|
+
const home = path.join(ctx.env.GEMINI_CLI_HOME || ctx.home, '.gemini');
|
|
16
|
+
if (scope === 'user')
|
|
17
|
+
return { tool: 'gemini', home, containers: userContainers(home), warnings: [] };
|
|
18
|
+
return projectListing(ctx, home);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
function refused(scope, file, reason, textKeyed) {
|
|
22
|
+
return { scope, path: file, readable: false, items: [], skipped: [], warnings: [reason], textKeyed };
|
|
23
|
+
}
|
|
24
|
+
function userContainers(home) {
|
|
25
|
+
const file = path.join(home, 'GEMINI.md');
|
|
26
|
+
if (!fs.existsSync(file))
|
|
27
|
+
return [];
|
|
28
|
+
const read = readTextFile(file);
|
|
29
|
+
if (!read.ok)
|
|
30
|
+
return [refused('user', file, read.reason, true)];
|
|
31
|
+
const kept = splitMarkdownItems(read.text).filter((item) => item.heading.toLowerCase() === SECTION);
|
|
32
|
+
const keys = textItemKeys(kept);
|
|
33
|
+
const items = kept.map((item, i) => ({ key: keys[i], text: item.text, updatedAt: read.mtimeMs }));
|
|
34
|
+
return [{ scope: 'user', path: file, readable: true, items, skipped: [], warnings: [], textKeyed: true }];
|
|
35
|
+
}
|
|
36
|
+
function projectListing(ctx, home) {
|
|
37
|
+
const warnings = [];
|
|
38
|
+
const containers = [];
|
|
39
|
+
if (ctx.projectRoot !== undefined) {
|
|
40
|
+
const slug = projectSlug(ctx, ctx.projectRoot, home, warnings);
|
|
41
|
+
const container = slug === null ? null : readFolderStore(path.join(home, 'tmp', slug, 'memory'), 'project', NOTES);
|
|
42
|
+
if (container !== null)
|
|
43
|
+
containers.push(container);
|
|
44
|
+
}
|
|
45
|
+
return { tool: 'gemini', home, containers, warnings };
|
|
46
|
+
}
|
|
47
|
+
function projectSlug(ctx, projectRoot, home, warnings) {
|
|
48
|
+
const index = path.join(home, 'projects.json');
|
|
49
|
+
if (!fs.existsSync(index))
|
|
50
|
+
return null;
|
|
51
|
+
const read = readTextFile(index);
|
|
52
|
+
if (!read.ok) {
|
|
53
|
+
warnings.push(read.reason);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
let projects;
|
|
57
|
+
try {
|
|
58
|
+
projects = parseIndex(read.text);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
warnings.push(`${index}: ${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
// The git top level is only asked for when the folder itself has no entry, since it spawns git.
|
|
65
|
+
const entry = findEntry(projects, projectRoot, ctx.platform) ?? topEntry(projects, projectRoot, ctx.platform);
|
|
66
|
+
if (entry === undefined)
|
|
67
|
+
return null;
|
|
68
|
+
const [key, slug] = entry;
|
|
69
|
+
if (isStringValue(slug) && isPlainName(slug))
|
|
70
|
+
return slug;
|
|
71
|
+
warnings.push(`${index}: the slug for ${key} is not a plain folder name`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
function parseIndex(text) {
|
|
75
|
+
// SAFETY: JSON.parse yields JSON; the object checks below decide what is used.
|
|
76
|
+
const data = JSON.parse(text);
|
|
77
|
+
const projects = isJsonObject(data) ? data.projects : undefined;
|
|
78
|
+
if (!isJsonObject(projects))
|
|
79
|
+
throw new Error('expected { "projects": { "<absolute path>": "<slug>" } }');
|
|
80
|
+
return projects;
|
|
81
|
+
}
|
|
82
|
+
function isPlainName(slug) {
|
|
83
|
+
return slug !== '' && slug !== '.' && slug !== '..' && !/[\\/]/.test(slug);
|
|
84
|
+
}
|
|
85
|
+
function fold(p, platform) {
|
|
86
|
+
const resolved = path.resolve(p);
|
|
87
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
88
|
+
}
|
|
89
|
+
// A relative key would resolve against the working folder, which no adapter may read.
|
|
90
|
+
function findEntry(projects, target, platform) {
|
|
91
|
+
const want = fold(target, platform);
|
|
92
|
+
return Object.entries(projects).find(([key]) => path.isAbsolute(key) && fold(key, platform) === want);
|
|
93
|
+
}
|
|
94
|
+
function topEntry(projects, projectRoot, platform) {
|
|
95
|
+
const top = gitLayout(projectRoot)?.top;
|
|
96
|
+
return top === undefined ? undefined : findEntry(projects, top, platform);
|
|
97
|
+
}
|
|
98
|
+
const NOTES = {
|
|
99
|
+
recursive: false,
|
|
100
|
+
include: markdownNotes,
|
|
101
|
+
item: (text, mtimeMs) => ({ text: splitFrontmatter(text).body.trim(), updatedAt: mtimeMs }),
|
|
102
|
+
};
|
|
103
|
+
//# sourceMappingURL=gemini.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface GitLayout {
|
|
2
|
+
readonly top: string;
|
|
3
|
+
readonly gitDir: string;
|
|
4
|
+
readonly common: string;
|
|
5
|
+
}
|
|
6
|
+
/** Null outside a repository or when git fails; a missing folder name then only means fewer containers listed. */
|
|
7
|
+
export declare function gitLayout(projectRoot: string): GitLayout | null;
|
|
8
|
+
//# sourceMappingURL=git.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The git layout of a project root, which Claude Code, Gemini and Copilot use to name a project's memory folder.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
/** Null outside a repository or when git fails; a missing folder name then only means fewer containers listed. */
|
|
4
|
+
export function gitLayout(projectRoot) {
|
|
5
|
+
const git = spawnSync('git', ['rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'], { cwd: projectRoot, encoding: 'utf8', timeout: 10000, windowsHide: true });
|
|
6
|
+
if (git.status !== 0)
|
|
7
|
+
return null;
|
|
8
|
+
const [top, gitDir, common] = git.stdout.trim().split(/\r?\n/);
|
|
9
|
+
return top && gitDir && common ? { top, gitDir, common } : null;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=git.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function sha256Hex(text: string): string;
|
|
2
|
+
/** The `#<hash>` part of a source: 16 hex of the full text, so an edit past the content cap is still seen. */
|
|
3
|
+
export declare function itemHash(text: string): string;
|
|
4
|
+
/** Keys for a single-file store's items: `<heading slug>/<12 hex of the text>`, `~2`, `~3` on repeats. */
|
|
5
|
+
export declare function textItemKeys(items: readonly {
|
|
6
|
+
readonly headingSlug: string;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
}[]): string[];
|
|
9
|
+
//# sourceMappingURL=keys.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Hashes and keys for imported items, shared by the sync and the single-file adapters so they never drift apart.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
export function sha256Hex(text) {
|
|
4
|
+
return createHash('sha256').update(text.replace(/\r\n/g, '\n'), 'utf8').digest('hex');
|
|
5
|
+
}
|
|
6
|
+
/** The `#<hash>` part of a source: 16 hex of the full text, so an edit past the content cap is still seen. */
|
|
7
|
+
export function itemHash(text) {
|
|
8
|
+
return sha256Hex(text).slice(0, 16);
|
|
9
|
+
}
|
|
10
|
+
/** Keys for a single-file store's items: `<heading slug>/<12 hex of the text>`, `~2`, `~3` on repeats. */
|
|
11
|
+
export function textItemKeys(items) {
|
|
12
|
+
const seen = new Map();
|
|
13
|
+
return items.map(({ headingSlug, text }) => {
|
|
14
|
+
const base = `${headingSlug}/${sha256Hex(text).slice(0, 12)}`;
|
|
15
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
16
|
+
seen.set(base, n);
|
|
17
|
+
return n === 1 ? base : `${base}~${n}`;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=keys.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DatabaseSyncLike } from '../db.js';
|
|
2
|
+
import type { MemoryEntry } from '../memory.js';
|
|
3
|
+
import type { Listing } from './types.js';
|
|
4
|
+
export declare const LEGACY_SOURCE_PREFIX = "claude-memory:";
|
|
5
|
+
export interface ContainerLegacy {
|
|
6
|
+
readonly adopt: Map<string, MemoryEntry[]>;
|
|
7
|
+
readonly replace: Map<string, MemoryEntry[]>;
|
|
8
|
+
}
|
|
9
|
+
export interface LegacyWork {
|
|
10
|
+
/** By container path. */
|
|
11
|
+
readonly byContainer: ReadonlyMap<string, ContainerLegacy>;
|
|
12
|
+
/** Rows about to take a note's key, which stop counting as text another path stored. */
|
|
13
|
+
readonly adopted: ReadonlySet<string>;
|
|
14
|
+
}
|
|
15
|
+
/** Matched once across all the store's Claude folders, before any container's transaction; each adoption re-checks its row inside one. */
|
|
16
|
+
export declare function legacyWork(db: DatabaseSyncLike, tenantId: string, listings: readonly Listing[]): LegacyWork;
|
|
17
|
+
//# sourceMappingURL=legacy.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Rows the old Claude import wrote (`claude-memory:<file>`), taken over by the notes they came from (plan design 10).
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { duplicateKey } from '../same-text.js';
|
|
4
|
+
import { selectLiveEntriesBySourcePrefix } from '../store.js';
|
|
5
|
+
import { matchLegacy } from './plan.js';
|
|
6
|
+
import { MIN_ITEM_CHARS, storedText } from './source.js';
|
|
7
|
+
export const LEGACY_SOURCE_PREFIX = 'claude-memory:';
|
|
8
|
+
const LEGACY_TAG = 'claude-code-memory';
|
|
9
|
+
/** Matched once across all the store's Claude folders, before any container's transaction; each adoption re-checks its row inside one. */
|
|
10
|
+
export function legacyWork(db, tenantId, listings) {
|
|
11
|
+
const containers = listings.filter((l) => l.tool === 'claude-code').flatMap((l) => l.containers).filter((c) => c.readable);
|
|
12
|
+
const rows = containers.length === 0
|
|
13
|
+
? []
|
|
14
|
+
: selectLiveEntriesBySourcePrefix(db, tenantId, LEGACY_SOURCE_PREFIX).filter((r) => r.tags.includes(LEGACY_TAG));
|
|
15
|
+
const byContainer = new Map();
|
|
16
|
+
if (rows.length === 0)
|
|
17
|
+
return { byContainer, adopted: new Set() };
|
|
18
|
+
const refs = [];
|
|
19
|
+
const targets = [];
|
|
20
|
+
for (const container of containers) {
|
|
21
|
+
for (const item of container.items) {
|
|
22
|
+
if (item.text.trim().length < MIN_ITEM_CHARS)
|
|
23
|
+
continue;
|
|
24
|
+
targets.push({ ref: String(refs.length), file: path.posix.basename(item.key), textKey: duplicateKey(storedText(item.text)) });
|
|
25
|
+
refs.push({ dir: container.path, key: item.key });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const match = matchLegacy(rows.map((r) => ({ id: r.id, file: r.source.slice(LEGACY_SOURCE_PREFIX.length), textKey: duplicateKey(r.content) })), targets);
|
|
29
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
30
|
+
const add = (pick, ref, id) => {
|
|
31
|
+
const { dir, key } = refs[Number(ref)];
|
|
32
|
+
const row = byId.get(id);
|
|
33
|
+
if (row === undefined)
|
|
34
|
+
return;
|
|
35
|
+
const found = byContainer.get(dir) ?? { adopt: new Map(), replace: new Map() };
|
|
36
|
+
byContainer.set(dir, found);
|
|
37
|
+
found[pick].set(key, [...(found[pick].get(key) ?? []), row]);
|
|
38
|
+
};
|
|
39
|
+
for (const [id, ref] of match.adopt)
|
|
40
|
+
add('adopt', ref, id);
|
|
41
|
+
for (const [ref, id] of match.replace)
|
|
42
|
+
add('replace', ref, id);
|
|
43
|
+
return { byContainer, adopted: new Set(match.adopt.keys()) };
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=legacy.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits one markdown memory file into items (a top-level bullet or a paragraph), each tagged with its heading. Pure.
|
|
3
|
+
*/
|
|
4
|
+
export interface MarkdownItem {
|
|
5
|
+
readonly heading: string;
|
|
6
|
+
readonly headingSlug: string;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
}
|
|
9
|
+
/** Lowercase, punctuation runs folded to '-', cut to 40 characters; 'top' when nothing is left. */
|
|
10
|
+
export declare function slugHeading(heading: string): string;
|
|
11
|
+
/** Split markdown into memory items in file order; headings set context and are never items. */
|
|
12
|
+
export declare function splitMarkdownItems(markdown: string): MarkdownItem[];
|
|
13
|
+
//# sourceMappingURL=markdown.d.ts.map
|