hippo-memory 1.52.8 → 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -101
- 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 +53 -20
- package/dist/api.js +141 -97
- package/dist/audit.d.ts +2 -1
- package/dist/audit.js +68 -2
- package/dist/capture.d.ts +48 -22
- package/dist/capture.js +186 -161
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +750 -797
- package/dist/codex-patch.d.ts +12 -0
- package/dist/codex-patch.js +71 -0
- 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 +546 -0
- package/dist/config.d.ts +4 -1
- package/dist/config.js +13 -4
- package/dist/connectors/slack/types.d.ts +0 -1
- package/dist/consolidate.js +87 -34
- package/dist/context-render.d.ts +36 -0
- package/dist/context-render.js +154 -0
- package/dist/dag.js +3 -2
- package/dist/db.d.ts +5 -1
- package/dist/db.js +46 -14
- package/dist/dedupe.d.ts +6 -6
- package/dist/dedupe.js +10 -9
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +69 -4
- package/dist/dormant.d.ts +9 -3
- package/dist/dormant.js +26 -2
- package/dist/embedding-provider.d.ts +2 -1
- package/dist/embedding-provider.js +2 -1
- package/dist/embeddings.js +23 -3
- package/dist/extract.js +5 -1
- package/dist/forward-claim-detector.d.ts +1 -1
- package/dist/forward-claim-detector.js +1 -1
- package/dist/gated-write.d.ts +9 -0
- package/dist/gated-write.js +24 -0
- package/dist/graph-recall.d.ts +3 -1
- package/dist/graph-recall.js +5 -3
- package/dist/hooks.d.ts +18 -2
- package/dist/hooks.js +128 -32
- package/dist/importers.js +5 -12
- package/dist/judgment.d.ts +30 -0
- package/dist/judgment.js +122 -0
- package/dist/mcp/server.js +171 -210
- package/dist/memory.d.ts +19 -2
- package/dist/memory.js +35 -3
- package/dist/merged-row.d.ts +6 -0
- package/dist/merged-row.js +35 -0
- package/dist/multihop.d.ts +2 -1
- package/dist/multihop.js +7 -4
- package/dist/physics-state.d.ts +0 -4
- package/dist/physics-state.js +0 -6
- package/dist/predictions.d.ts +2 -17
- package/dist/predictions.js +2 -15
- package/dist/reject-flow.d.ts +7 -5
- package/dist/reject-flow.js +41 -12
- package/dist/salience.js +12 -5
- package/dist/same-text.d.ts +17 -0
- package/dist/same-text.js +38 -0
- package/dist/scheduler.d.ts +4 -0
- package/dist/scheduler.js +8 -0
- package/dist/search.d.ts +7 -0
- package/dist/search.js +16 -32
- package/dist/secret-detect.d.ts +2 -0
- package/dist/secret-detect.js +6 -0
- package/dist/server-detect.js +9 -33
- package/dist/server.js +6 -62
- package/dist/session-digest.d.ts +79 -0
- package/dist/session-digest.js +528 -0
- package/dist/shared.d.ts +10 -2
- package/dist/shared.js +44 -36
- package/dist/store.d.ts +9 -2
- package/dist/store.js +25 -2
- package/dist/token-ledger.d.ts +46 -8
- package/dist/token-ledger.js +140 -21
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/README.md +4 -4
- package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
|
@@ -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
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits one markdown memory file into items (a top-level bullet or a paragraph), each tagged with its heading. Pure.
|
|
3
|
+
*/
|
|
4
|
+
const HEADING = /^#{1,6}\s+/;
|
|
5
|
+
// Column 0 only: an indented marker is a sub-bullet and stays inside its item.
|
|
6
|
+
const BULLET = /^(?:[-*+]|\d+[.)]) /;
|
|
7
|
+
const RULE = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
|
|
8
|
+
// A backtick fence's info string holds no backtick, so inline code at line start is not a fence.
|
|
9
|
+
const FENCE = /^\s*(?:(`{3,})[^`]*|(~{3,}).*)$/;
|
|
10
|
+
const MAX_SLUG_LENGTH = 40;
|
|
11
|
+
/** Lowercase, punctuation runs folded to '-', cut to 40 characters; 'top' when nothing is left. */
|
|
12
|
+
export function slugHeading(heading) {
|
|
13
|
+
const slug = heading
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
16
|
+
.replace(/^-+|-+$/g, '')
|
|
17
|
+
.slice(0, MAX_SLUG_LENGTH)
|
|
18
|
+
.replace(/-+$/, '');
|
|
19
|
+
return slug === '' ? 'top' : slug;
|
|
20
|
+
}
|
|
21
|
+
/** Split markdown into memory items in file order; headings set context and are never items. */
|
|
22
|
+
export function splitMarkdownItems(markdown) {
|
|
23
|
+
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
24
|
+
const items = [];
|
|
25
|
+
let heading = '';
|
|
26
|
+
let headingSlug = slugHeading(heading);
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < lines.length) {
|
|
29
|
+
const line = lines[i];
|
|
30
|
+
if (isBlank(line) || RULE.test(line)) {
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
else if (HEADING.test(line)) {
|
|
34
|
+
heading = headingText(line);
|
|
35
|
+
headingSlug = slugHeading(heading);
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const block = readBlock(lines, i);
|
|
40
|
+
if (block.text !== '')
|
|
41
|
+
items.push({ heading, headingSlug, text: block.text });
|
|
42
|
+
i = block.end;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return items;
|
|
46
|
+
}
|
|
47
|
+
function isBlank(line) {
|
|
48
|
+
return line.trim() === '';
|
|
49
|
+
}
|
|
50
|
+
function isIndented(line) {
|
|
51
|
+
return line.startsWith(' ') || line.startsWith('\t');
|
|
52
|
+
}
|
|
53
|
+
function headingText(line) {
|
|
54
|
+
// A closing run of '#' counts only after a space, so `## C#` keeps its name.
|
|
55
|
+
return line.replace(HEADING, '').trim().replace(/(?:^|\s+)#+$/, '').trim();
|
|
56
|
+
}
|
|
57
|
+
// Rules are tested first so `- - -` is a rule, not a bullet.
|
|
58
|
+
function startsBlock(line) {
|
|
59
|
+
return RULE.test(line) || HEADING.test(line) || BULLET.test(line);
|
|
60
|
+
}
|
|
61
|
+
function fenceOf(line) {
|
|
62
|
+
const match = FENCE.exec(line);
|
|
63
|
+
return match === null ? null : (match[1] ?? match[2] ?? null);
|
|
64
|
+
}
|
|
65
|
+
function closesFence(line, fence) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
return trimmed.length >= fence.length && trimmed.replaceAll(fence[0], '') === '';
|
|
68
|
+
}
|
|
69
|
+
// An unclosed fence runs to the end of the file.
|
|
70
|
+
function skipFence(lines, start, fence) {
|
|
71
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
72
|
+
if (closesFence(lines[i], fence))
|
|
73
|
+
return i + 1;
|
|
74
|
+
}
|
|
75
|
+
return lines.length;
|
|
76
|
+
}
|
|
77
|
+
// A line opening a fence carries its whole body with it, so body lines never start a heading or bullet.
|
|
78
|
+
function afterLine(lines, i) {
|
|
79
|
+
const fence = fenceOf(lines[i]);
|
|
80
|
+
return fence === null ? i + 1 : skipFence(lines, i, fence);
|
|
81
|
+
}
|
|
82
|
+
// Where a bullet resumes after blank lines: the next line when it is indented, else -1.
|
|
83
|
+
function resumeAfterBlank(lines, i) {
|
|
84
|
+
let next = i;
|
|
85
|
+
while (next < lines.length && isBlank(lines[next]))
|
|
86
|
+
next++;
|
|
87
|
+
return next < lines.length && isIndented(lines[next]) ? next : -1;
|
|
88
|
+
}
|
|
89
|
+
// A bullet spans blank lines between indented lines; a paragraph ends at the first blank line.
|
|
90
|
+
function blockEnd(lines, start, isBullet) {
|
|
91
|
+
let i = start + 1;
|
|
92
|
+
while (i < lines.length) {
|
|
93
|
+
const line = lines[i];
|
|
94
|
+
if (isBlank(line)) {
|
|
95
|
+
const resume = isBullet ? resumeAfterBlank(lines, i) : -1;
|
|
96
|
+
if (resume < 0)
|
|
97
|
+
return i;
|
|
98
|
+
i = resume;
|
|
99
|
+
}
|
|
100
|
+
else if (!isIndented(line) && startsBlock(line)) {
|
|
101
|
+
return i;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
i = afterLine(lines, i);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return lines.length;
|
|
108
|
+
}
|
|
109
|
+
function readBlock(lines, start) {
|
|
110
|
+
const fence = fenceOf(lines[start]);
|
|
111
|
+
if (fence !== null) {
|
|
112
|
+
const end = skipFence(lines, start, fence);
|
|
113
|
+
return { end, text: lines.slice(start, end).join('\n').trim() };
|
|
114
|
+
}
|
|
115
|
+
const isBullet = BULLET.test(lines[start]);
|
|
116
|
+
const end = blockEnd(lines, start, isBullet);
|
|
117
|
+
const body = lines.slice(start, end);
|
|
118
|
+
if (!isBullet)
|
|
119
|
+
return { end, text: body.join('\n').trim() };
|
|
120
|
+
body[0] = body[0].replace(BULLET, '');
|
|
121
|
+
return { end, text: body.map((l) => l.trimEnd()).join('\n').trim() };
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=markdown.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// OpenClaw keeps its durable memory in one MEMORY.md; the daily notes, USER.md and DREAMS.md are left out on purpose.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readTextFile } from './files.js';
|
|
5
|
+
import { textItemKeys } from './keys.js';
|
|
6
|
+
import { splitMarkdownItems } from './markdown.js';
|
|
7
|
+
export const openclawAdapter = {
|
|
8
|
+
tool: 'openclaw',
|
|
9
|
+
list(ctx, scope) {
|
|
10
|
+
const home = workspaceDir(ctx);
|
|
11
|
+
return { tool: 'openclaw', home, containers: scope === 'user' ? userContainers(home) : [], warnings: [] };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
// SHORTCUT: agents.defaults.workspace in openclaw.json is not read (the file allows comments, and there is no parser for that here); read it if users ask.
|
|
15
|
+
function workspaceDir(ctx) {
|
|
16
|
+
const { OPENCLAW_WORKSPACE_DIR: workspace } = ctx.env;
|
|
17
|
+
return path.resolve(workspace || path.join(stateDir(ctx), 'workspace'));
|
|
18
|
+
}
|
|
19
|
+
function stateDir(ctx) {
|
|
20
|
+
const { OPENCLAW_STATE_DIR: state, OPENCLAW_HOME: openclawHome, OPENCLAW_PROFILE: profile } = ctx.env;
|
|
21
|
+
if (state)
|
|
22
|
+
return state;
|
|
23
|
+
const suffix = profile && profile !== 'default' ? `-${profile}` : '';
|
|
24
|
+
return path.join(openclawHome || ctx.home, `.openclaw${suffix}`);
|
|
25
|
+
}
|
|
26
|
+
function userContainers(workspace) {
|
|
27
|
+
const file = path.join(workspace, 'MEMORY.md');
|
|
28
|
+
if (!fs.existsSync(file))
|
|
29
|
+
return [];
|
|
30
|
+
const read = readTextFile(file);
|
|
31
|
+
if (!read.ok) {
|
|
32
|
+
return [{ scope: 'user', path: file, readable: false, items: [], skipped: [], warnings: [read.reason], textKeyed: true }];
|
|
33
|
+
}
|
|
34
|
+
const stored = splitMarkdownItems(read.text).map(({ heading, headingSlug, text }) => ({
|
|
35
|
+
headingSlug,
|
|
36
|
+
text: heading === '' ? text : `${heading}: ${text}`,
|
|
37
|
+
}));
|
|
38
|
+
const keys = textItemKeys(stored);
|
|
39
|
+
const items = stored.map(({ text }, i) => ({ key: keys[i], text, updatedAt: read.mtimeMs }));
|
|
40
|
+
return [{ scope: 'user', path: file, readable: true, items, skipped: [], warnings: [], textKeyed: true }];
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=openclaw.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface PlanItem {
|
|
2
|
+
readonly key: string;
|
|
3
|
+
readonly hash: string;
|
|
4
|
+
/** Read but not storable: under 10 characters, a secret, or a rejected value. */
|
|
5
|
+
readonly refused: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface LiveRow {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly key: string;
|
|
10
|
+
readonly hash: string;
|
|
11
|
+
/** Carries the tool's tag; an untagged row is one the user restored, or a pin whose note went. */
|
|
12
|
+
readonly tagged: boolean;
|
|
13
|
+
readonly created: string;
|
|
14
|
+
}
|
|
15
|
+
export interface DormantRow {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly hash: string;
|
|
19
|
+
readonly dormantAt: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PlanInput {
|
|
22
|
+
readonly textKeyed: boolean;
|
|
23
|
+
readonly items: readonly PlanItem[];
|
|
24
|
+
readonly skipped: readonly string[];
|
|
25
|
+
readonly live: readonly LiveRow[];
|
|
26
|
+
/** Snapshots that are readable and not superseded. */
|
|
27
|
+
readonly dormant: readonly DormantRow[];
|
|
28
|
+
/** Legacy rows a new row of the key replaces (plan design 10, second round). */
|
|
29
|
+
readonly legacy?: ReadonlyMap<string, readonly string[]>;
|
|
30
|
+
/** Asked only when a row would be written: the text is already stored live by another path. */
|
|
31
|
+
readonly isDuplicate: (item: PlanItem) => boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface PlannedWrite {
|
|
34
|
+
readonly key: string;
|
|
35
|
+
readonly hash: string;
|
|
36
|
+
readonly supersedes: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
export interface ContainerPlan {
|
|
39
|
+
readonly unchanged: number;
|
|
40
|
+
/** Untagged rows whose tag goes back on. */
|
|
41
|
+
readonly retag: readonly string[];
|
|
42
|
+
/** Tagged rows superseded by the row kept for their key. */
|
|
43
|
+
readonly collapse: readonly {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly by: string;
|
|
46
|
+
}[];
|
|
47
|
+
readonly writes: readonly PlannedWrite[];
|
|
48
|
+
readonly restores: readonly {
|
|
49
|
+
readonly key: string;
|
|
50
|
+
readonly dormantId: string;
|
|
51
|
+
}[];
|
|
52
|
+
readonly setAside: readonly string[];
|
|
53
|
+
readonly duplicates: number;
|
|
54
|
+
readonly refused: number;
|
|
55
|
+
}
|
|
56
|
+
export declare function planContainer(input: PlanInput): ContainerPlan;
|
|
57
|
+
export interface LegacyRow {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
/** The file name its `claude-memory:<file>` source names. */
|
|
60
|
+
readonly file: string;
|
|
61
|
+
/** duplicateKey of its content. */
|
|
62
|
+
readonly textKey: string;
|
|
63
|
+
}
|
|
64
|
+
export interface LegacyTarget {
|
|
65
|
+
/** Opaque to this function: the sync's handle on one container's item. */
|
|
66
|
+
readonly ref: string;
|
|
67
|
+
readonly file: string;
|
|
68
|
+
readonly textKey: string;
|
|
69
|
+
}
|
|
70
|
+
export interface LegacyMatch {
|
|
71
|
+
/** Legacy row id to the target whose key it takes, keeping its id and history (same text: a renamed note too). */
|
|
72
|
+
readonly adopt: ReadonlyMap<string, string>;
|
|
73
|
+
/** Target ref to the legacy row a new row of that target replaces (different text, one legacy row of that file). */
|
|
74
|
+
readonly replace: ReadonlyMap<string, string>;
|
|
75
|
+
}
|
|
76
|
+
/** Plan design 10: same text first, then one legacy row per file name; anything else is left to decay. */
|
|
77
|
+
export declare function matchLegacy(rows: readonly LegacyRow[], targets: readonly LegacyTarget[]): LegacyMatch;
|
|
78
|
+
//# sourceMappingURL=plan.d.ts.map
|