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,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
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What one sync does to one container's rows: a pure function of the items read and the rows found (plan design 6).
|
|
2
|
+
export function planContainer(input) {
|
|
3
|
+
const live = groupByKey(input.live);
|
|
4
|
+
const dormant = groupByKey(input.dormant);
|
|
5
|
+
const draft = { unchanged: 0, retag: [], collapse: [], writes: [], fresh: [], restores: [], setAside: [], duplicates: 0, refused: 0 };
|
|
6
|
+
for (const item of input.items)
|
|
7
|
+
planItem(input, item, live.get(item.key) ?? [], dormant.get(item.key) ?? [], draft);
|
|
8
|
+
const read = new Set([...input.items.map((i) => i.key), ...input.skipped]);
|
|
9
|
+
const gone = new Map();
|
|
10
|
+
for (const [key, rows] of live) {
|
|
11
|
+
const tagged = rows.filter((r) => r.tagged).map((r) => r.id);
|
|
12
|
+
if (!read.has(key) && tagged.length > 0)
|
|
13
|
+
gone.set(key, tagged);
|
|
14
|
+
}
|
|
15
|
+
if (input.textKeyed)
|
|
16
|
+
pairEdits(gone, draft.fresh);
|
|
17
|
+
for (const ids of gone.values())
|
|
18
|
+
draft.setAside.push(...ids);
|
|
19
|
+
const { fresh, ...plan } = draft;
|
|
20
|
+
return { ...plan, writes: [...plan.writes, ...fresh] };
|
|
21
|
+
}
|
|
22
|
+
function planItem(input, item, rows, snaps, draft) {
|
|
23
|
+
const tagged = rows.filter((r) => r.tagged);
|
|
24
|
+
if (item.refused) {
|
|
25
|
+
draft.refused++;
|
|
26
|
+
draft.setAside.push(...tagged.map((r) => r.id));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const same = rows.filter((r) => r.hash === item.hash);
|
|
30
|
+
if (same.length > 0) {
|
|
31
|
+
const kept = newestRow(same.filter((r) => r.tagged)) ?? newestRow(same);
|
|
32
|
+
if (kept === undefined)
|
|
33
|
+
return;
|
|
34
|
+
if (!kept.tagged)
|
|
35
|
+
draft.retag.push(kept.id);
|
|
36
|
+
const others = tagged.filter((r) => r.id !== kept.id);
|
|
37
|
+
draft.collapse.push(...others.map((r) => ({ id: r.id, by: kept.id })));
|
|
38
|
+
if (kept.tagged && others.length === 0)
|
|
39
|
+
draft.unchanged++;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const legacy = input.legacy?.get(item.key) ?? [];
|
|
43
|
+
if (rows.length > 0) {
|
|
44
|
+
if (input.isDuplicate(item)) {
|
|
45
|
+
draft.duplicates++;
|
|
46
|
+
draft.setAside.push(...tagged.map((r) => r.id));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
draft.writes.push({ key: item.key, hash: item.hash, supersedes: [...rows.map((r) => r.id), ...legacy] });
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const snap = newestSnapshot(snaps.filter((s) => s.hash === item.hash));
|
|
54
|
+
if (snap !== undefined)
|
|
55
|
+
draft.restores.push({ key: item.key, dormantId: snap.id });
|
|
56
|
+
else if (input.isDuplicate(item))
|
|
57
|
+
draft.duplicates++;
|
|
58
|
+
else
|
|
59
|
+
draft.fresh.push({ key: item.key, hash: item.hash, supersedes: legacy });
|
|
60
|
+
}
|
|
61
|
+
/** A single-file store keys items by text, so an edit reads as one key gone and one new under the same heading. */
|
|
62
|
+
function pairEdits(gone, fresh) {
|
|
63
|
+
const goneBy = groupBy([...gone.keys()], headingOf);
|
|
64
|
+
const freshBy = groupBy(fresh.map((w, i) => ({ w, i })), (f) => headingOf(f.w.key));
|
|
65
|
+
for (const [heading, keys] of goneBy) {
|
|
66
|
+
const news = freshBy.get(heading) ?? [];
|
|
67
|
+
if (keys.length !== 1 || news.length !== 1)
|
|
68
|
+
continue;
|
|
69
|
+
const { w, i } = news[0];
|
|
70
|
+
fresh[i] = { ...w, supersedes: [...w.supersedes, ...(gone.get(keys[0]) ?? [])] };
|
|
71
|
+
gone.delete(keys[0]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function headingOf(key) {
|
|
75
|
+
return key.slice(0, Math.max(0, key.lastIndexOf('/')));
|
|
76
|
+
}
|
|
77
|
+
// Byte order, as memory.ts's timestamp note asks: localeCompare may skip the ISO punctuation.
|
|
78
|
+
const byteOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
79
|
+
function newestRow(rows) {
|
|
80
|
+
return [...rows].sort((a, b) => byteOrder(b.created, a.created) || byteOrder(b.id, a.id))[0];
|
|
81
|
+
}
|
|
82
|
+
function newestSnapshot(rows) {
|
|
83
|
+
return [...rows].sort((a, b) => byteOrder(b.dormantAt, a.dormantAt) || byteOrder(b.id, a.id))[0];
|
|
84
|
+
}
|
|
85
|
+
function groupByKey(rows) {
|
|
86
|
+
return groupBy(rows, (r) => r.key);
|
|
87
|
+
}
|
|
88
|
+
function groupBy(values, keyOf) {
|
|
89
|
+
const out = new Map();
|
|
90
|
+
for (const value of values) {
|
|
91
|
+
const key = keyOf(value);
|
|
92
|
+
const list = out.get(key);
|
|
93
|
+
if (list)
|
|
94
|
+
list.push(value);
|
|
95
|
+
else
|
|
96
|
+
out.set(key, [value]);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** Plan design 10: same text first, then one legacy row per file name; anything else is left to decay. */
|
|
101
|
+
export function matchLegacy(rows, targets) {
|
|
102
|
+
const byText = new Map();
|
|
103
|
+
for (const t of targets)
|
|
104
|
+
if (!byText.has(t.textKey))
|
|
105
|
+
byText.set(t.textKey, t.ref);
|
|
106
|
+
const adopt = new Map();
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
const ref = byText.get(row.textKey);
|
|
109
|
+
if (ref !== undefined)
|
|
110
|
+
adopt.set(row.id, ref);
|
|
111
|
+
}
|
|
112
|
+
const adoptedRefs = new Set(adopt.values());
|
|
113
|
+
const leftByFile = groupBy(rows.filter((r) => !adopt.has(r.id)), (r) => r.file);
|
|
114
|
+
const targetsByFile = groupBy(targets.filter((t) => !adoptedRefs.has(t.ref)), (t) => t.file);
|
|
115
|
+
const replace = new Map();
|
|
116
|
+
for (const [file, left] of leftByFile) {
|
|
117
|
+
const matches = targetsByFile.get(file) ?? [];
|
|
118
|
+
if (left.length === 1 && matches.length === 1)
|
|
119
|
+
replace.set(matches[0].ref, left[0].id);
|
|
120
|
+
}
|
|
121
|
+
return { adopt, replace };
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=plan.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Adapter } from './types.js';
|
|
2
|
+
/** Qwen's own folder-name rule, so a project's container is the folder Qwen made for it. */
|
|
3
|
+
export declare function sanitizeCwd(p: string, platform: NodeJS.Platform): string;
|
|
4
|
+
export declare const qwenCodeAdapter: Adapter;
|
|
5
|
+
//# sourceMappingURL=qwen-code.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Qwen Code's auto memory: markdown notes under its base folder, or inside the project when it keeps them local.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { splitFrontmatter } from './files.js';
|
|
5
|
+
import { markdownNotes, readFolderStore } from './folder-store.js';
|
|
6
|
+
/** Qwen's own folder-name rule, so a project's container is the folder Qwen made for it. */
|
|
7
|
+
export function sanitizeCwd(p, platform) {
|
|
8
|
+
return (platform === 'win32' ? p.toLowerCase() : p).replace(/[^a-zA-Z0-9]/g, '-');
|
|
9
|
+
}
|
|
10
|
+
function baseDir(ctx) {
|
|
11
|
+
const { env } = ctx;
|
|
12
|
+
return env.QWEN_CODE_MEMORY_BASE_DIR || env.QWEN_RUNTIME_DIR || env.QWEN_HOME || path.join(ctx.home, '.qwen');
|
|
13
|
+
}
|
|
14
|
+
/** The nearest folder at or above `start` holding a `.git` entry; a file counts, so a linked worktree keeps its own. */
|
|
15
|
+
function gitRoot(start) {
|
|
16
|
+
for (let dir = start;;) {
|
|
17
|
+
if (fs.existsSync(path.join(dir, '.git')))
|
|
18
|
+
return dir;
|
|
19
|
+
const parent = path.dirname(dir);
|
|
20
|
+
if (parent === dir)
|
|
21
|
+
return null;
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function projectDir(ctx, base, projectRoot) {
|
|
26
|
+
const root = path.resolve(projectRoot);
|
|
27
|
+
if (ctx.env.QWEN_CODE_MEMORY_LOCAL === '1')
|
|
28
|
+
return path.join(root, '.qwen', 'memory');
|
|
29
|
+
const key = ctx.env.QWEN_CODE_MEMORY_PROJECT_SCOPE === 'workspace' ? root : (gitRoot(root) ?? root);
|
|
30
|
+
return path.join(base, 'projects', sanitizeCwd(key, ctx.platform), 'memory');
|
|
31
|
+
}
|
|
32
|
+
const NOTES = {
|
|
33
|
+
recursive: true,
|
|
34
|
+
include: markdownNotes,
|
|
35
|
+
item: (text, mtimeMs) => ({ text: splitFrontmatter(text).body.trim(), updatedAt: mtimeMs }),
|
|
36
|
+
};
|
|
37
|
+
export const qwenCodeAdapter = {
|
|
38
|
+
tool: 'qwen-code',
|
|
39
|
+
list(ctx, scope) {
|
|
40
|
+
const base = baseDir(ctx);
|
|
41
|
+
let dir = null;
|
|
42
|
+
if (scope === 'user')
|
|
43
|
+
dir = path.join(base, 'memories');
|
|
44
|
+
else if (ctx.projectRoot !== undefined)
|
|
45
|
+
dir = projectDir(ctx, base, ctx.projectRoot);
|
|
46
|
+
const container = dir === null ? null : readFolderStore(dir, scope, NOTES);
|
|
47
|
+
return { tool: 'qwen-code', home: base, containers: container ? [container] : [], warnings: [] };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=qwen-code.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ToolId } from './tools.js';
|
|
2
|
+
import type { Scope } from './types.js';
|
|
3
|
+
export interface Tally {
|
|
4
|
+
imported: number;
|
|
5
|
+
replaced: number;
|
|
6
|
+
restored: number;
|
|
7
|
+
setAside: number;
|
|
8
|
+
untagged: number;
|
|
9
|
+
unchanged: number;
|
|
10
|
+
adopted: number;
|
|
11
|
+
collapsed: number;
|
|
12
|
+
retagged: number;
|
|
13
|
+
handedOver: number;
|
|
14
|
+
duplicate: number;
|
|
15
|
+
secret: number;
|
|
16
|
+
short: number;
|
|
17
|
+
rejected: number;
|
|
18
|
+
unread: number;
|
|
19
|
+
unreadable: number;
|
|
20
|
+
busy: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function emptyTally(): Tally;
|
|
23
|
+
export declare function addTally(into: Tally, from: Tally): void;
|
|
24
|
+
export interface ContainerSeen {
|
|
25
|
+
readonly scope: Scope;
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly store: string;
|
|
28
|
+
readonly items: number;
|
|
29
|
+
readonly readable: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface ToolReport {
|
|
32
|
+
readonly tool: ToolId;
|
|
33
|
+
readonly label: string;
|
|
34
|
+
readonly homes: string[];
|
|
35
|
+
readonly containers: ContainerSeen[];
|
|
36
|
+
readonly tally: Tally;
|
|
37
|
+
/** Dry run only: kept rows in containers this run did not list, such as a moved project's old folder. */
|
|
38
|
+
unlisted: number;
|
|
39
|
+
}
|
|
40
|
+
export interface ImportReport {
|
|
41
|
+
readonly tools: ToolReport[];
|
|
42
|
+
readonly warnings: string[];
|
|
43
|
+
}
|
|
44
|
+
export declare function emptyReport(): ImportReport;
|
|
45
|
+
export declare function toolReport(report: ImportReport, tool: ToolId): ToolReport;
|
|
46
|
+
export declare function mergeReports(into: ImportReport, from: ImportReport): void;
|
|
47
|
+
export declare function totalTally(report: ImportReport): Tally;
|
|
48
|
+
/** `hippo import --agents`: each tool's home, its folders and what moved; in a dry run, what would have. */
|
|
49
|
+
export declare function detailLines(report: ImportReport, dryRun: boolean): string[];
|
|
50
|
+
/** "Imported 12 agent memories (Claude Code 10, Codex 2); 1 replaced, 1 set aside, 1 skipped for a secret.", or null when nothing moved. */
|
|
51
|
+
export declare function summaryLine(report: ImportReport): string | null;
|
|
52
|
+
//# sourceMappingURL=report.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// What a sync did, per tool, and the one line a command prints about it (plan design 11).
|
|
2
|
+
import { AGENT_MEMORY_TOOLS } from './tools.js';
|
|
3
|
+
export function emptyTally() {
|
|
4
|
+
return {
|
|
5
|
+
imported: 0, replaced: 0, restored: 0, setAside: 0, untagged: 0, unchanged: 0, adopted: 0, collapsed: 0, retagged: 0,
|
|
6
|
+
handedOver: 0, duplicate: 0, secret: 0, short: 0, rejected: 0, unread: 0, unreadable: 0, busy: 0,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
const TALLY_FIELDS = Object.keys(emptyTally()).filter(isTallyField);
|
|
10
|
+
function isTallyField(key) {
|
|
11
|
+
return key in emptyTally();
|
|
12
|
+
}
|
|
13
|
+
export function addTally(into, from) {
|
|
14
|
+
for (const field of TALLY_FIELDS)
|
|
15
|
+
into[field] += from[field];
|
|
16
|
+
}
|
|
17
|
+
export function emptyReport() {
|
|
18
|
+
return { tools: [], warnings: [] };
|
|
19
|
+
}
|
|
20
|
+
export function toolReport(report, tool) {
|
|
21
|
+
const found = report.tools.find((t) => t.tool === tool);
|
|
22
|
+
if (found)
|
|
23
|
+
return found;
|
|
24
|
+
const label = AGENT_MEMORY_TOOLS.find((t) => t.id === tool)?.label ?? tool;
|
|
25
|
+
const created = { tool, label, homes: [], containers: [], tally: emptyTally(), unlisted: 0 };
|
|
26
|
+
report.tools.push(created);
|
|
27
|
+
return created;
|
|
28
|
+
}
|
|
29
|
+
export function mergeReports(into, from) {
|
|
30
|
+
for (const t of from.tools) {
|
|
31
|
+
const target = toolReport(into, t.tool);
|
|
32
|
+
for (const home of t.homes)
|
|
33
|
+
if (!target.homes.includes(home))
|
|
34
|
+
target.homes.push(home);
|
|
35
|
+
target.containers.push(...t.containers);
|
|
36
|
+
addTally(target.tally, t.tally);
|
|
37
|
+
target.unlisted += t.unlisted;
|
|
38
|
+
}
|
|
39
|
+
into.warnings.push(...from.warnings);
|
|
40
|
+
}
|
|
41
|
+
export function totalTally(report) {
|
|
42
|
+
const total = emptyTally();
|
|
43
|
+
for (const t of report.tools)
|
|
44
|
+
addTally(total, t.tally);
|
|
45
|
+
return total;
|
|
46
|
+
}
|
|
47
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
48
|
+
const TALLY_WORDS = {
|
|
49
|
+
imported: 'new', replaced: 'replaced', restored: 'brought back', setAside: 'set aside', untagged: 'pinned and kept',
|
|
50
|
+
unchanged: 'unchanged', adopted: 'taken over from the old Claude import', collapsed: 'duplicates folded', retagged: 'retagged',
|
|
51
|
+
handedOver: 'handed over from the global store', duplicate: 'already stored', secret: 'skipped for a secret', short: 'too short',
|
|
52
|
+
rejected: 'skipped as rejected', unread: 'files skipped', unreadable: 'unreadable folders', busy: 'busy folders',
|
|
53
|
+
};
|
|
54
|
+
/** `hippo import --agents`: each tool's home, its folders and what moved; in a dry run, what would have. */
|
|
55
|
+
export function detailLines(report, dryRun) {
|
|
56
|
+
const lines = [dryRun ? 'Agent memories (dry run, nothing written):' : 'Agent memories:'];
|
|
57
|
+
for (const t of report.tools) {
|
|
58
|
+
const found = t.containers.length === 0 ? ' (no memory folders found)' : '';
|
|
59
|
+
lines.push(` ${t.label}: ${t.homes.length === 0 ? 'not found' : t.homes.join(', ')}${found}`);
|
|
60
|
+
for (const c of t.containers) {
|
|
61
|
+
lines.push(` ${c.scope} ${c.path}: ${c.readable ? plural(c.items, 'note', 'notes') : 'unreadable'}, into ${c.store}`);
|
|
62
|
+
}
|
|
63
|
+
const moved = TALLY_FIELDS.filter((f) => t.tally[f] > 0).map((f) => `${t.tally[f]} ${TALLY_WORDS[f]}`);
|
|
64
|
+
if (moved.length > 0)
|
|
65
|
+
lines.push(` ${dryRun ? 'would be ' : ''}${moved.join(', ')}`);
|
|
66
|
+
if (t.unlisted > 0)
|
|
67
|
+
lines.push(` ${plural(t.unlisted, 'kept row sits', 'kept rows sit')} in folders not listed this run (a moved project's old copy)`);
|
|
68
|
+
}
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
71
|
+
/** "Imported 12 agent memories (Claude Code 10, Codex 2); 1 replaced, 1 set aside, 1 skipped for a secret.", or null when nothing moved. */
|
|
72
|
+
export function summaryLine(report) {
|
|
73
|
+
const total = totalTally(report);
|
|
74
|
+
const parts = [
|
|
75
|
+
total.replaced > 0 ? `${total.replaced} replaced` : '',
|
|
76
|
+
total.restored > 0 ? `${total.restored} brought back` : '',
|
|
77
|
+
total.setAside > 0 ? `${total.setAside} set aside` : '',
|
|
78
|
+
total.untagged > 0 ? `${plural(total.untagged, 'pinned memory', 'pinned memories')} kept with the note gone` : '',
|
|
79
|
+
total.secret > 0 ? `${total.secret} skipped for a secret` : '',
|
|
80
|
+
total.rejected > 0 ? `${total.rejected} skipped as rejected` : '',
|
|
81
|
+
].filter((p) => p !== '');
|
|
82
|
+
if (total.imported === 0)
|
|
83
|
+
return parts.length === 0 ? null : `Agent memories: ${parts.join(', ')}.`;
|
|
84
|
+
const byTool = report.tools.filter((t) => t.tally.imported > 0).map((t) => `${t.label} ${t.tally.imported}`);
|
|
85
|
+
const head = `Imported ${plural(total.imported, 'agent memory', 'agent memories')} (${byTool.join(', ')})`;
|
|
86
|
+
return parts.length === 0 ? `${head}.` : `${head}; ${parts.join(', ')}.`;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ToolId } from './tools.js';
|
|
2
|
+
import type { Scope } from './types.js';
|
|
3
|
+
export declare const CONTENT_CAP = 1500;
|
|
4
|
+
export declare const MIN_ITEM_CHARS = 10;
|
|
5
|
+
/** `p-`/`u-` and 12 hex of the real path, so no user path reaches a row; `origin` parts projects sharing a folder in the global store. */
|
|
6
|
+
export declare function containerId(dir: string, scope: Scope, platform: NodeJS.Platform, origin?: string): string;
|
|
7
|
+
export declare function containerPrefix(tool: ToolId, container: string): string;
|
|
8
|
+
export declare function itemSource(prefix: string, key: string, text: string): string;
|
|
9
|
+
/** The key and hash of a source under `prefix`; the hash follows the last `#`, since a file name may hold one too. */
|
|
10
|
+
export declare function splitSource(source: string, prefix: string): {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
readonly hash: string;
|
|
13
|
+
};
|
|
14
|
+
/** The text as stored: emails masked as on every capture path, then the old Claude import's cap; the hash still sees the raw note. */
|
|
15
|
+
export declare function storedText(text: string): string;
|
|
16
|
+
//# sourceMappingURL=source.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// An imported row's source, `agent-memory:<tool>:<container>/<item>#<hash>`, and the stored text: plan design 3 and 4.
|
|
2
|
+
import { realpathOrResolve } from '../project-identity.js';
|
|
3
|
+
import { truncateCodePointSafe } from '../capture.js';
|
|
4
|
+
import { maskEmails } from '../secret-detect.js';
|
|
5
|
+
import { itemHash, sha256Hex } from './keys.js';
|
|
6
|
+
import { toolSourcePrefix } from './tools.js';
|
|
7
|
+
export const CONTENT_CAP = 1500;
|
|
8
|
+
export const MIN_ITEM_CHARS = 10;
|
|
9
|
+
/** `p-`/`u-` and 12 hex of the real path, so no user path reaches a row; `origin` parts projects sharing a folder in the global store. */
|
|
10
|
+
export function containerId(dir, scope, platform, origin = '') {
|
|
11
|
+
const real = realpathOrResolve(dir).replace(/\\/g, '/');
|
|
12
|
+
const folder = platform === 'win32' ? real.toLowerCase() : real;
|
|
13
|
+
return `${scope === 'project' ? 'p' : 'u'}-${sha256Hex(origin === '' ? folder : `${folder}\n${origin}`).slice(0, 12)}`;
|
|
14
|
+
}
|
|
15
|
+
export function containerPrefix(tool, container) {
|
|
16
|
+
return `${toolSourcePrefix(tool)}${container}/`;
|
|
17
|
+
}
|
|
18
|
+
export function itemSource(prefix, key, text) {
|
|
19
|
+
return `${prefix}${key}#${itemHash(text)}`;
|
|
20
|
+
}
|
|
21
|
+
/** The key and hash of a source under `prefix`; the hash follows the last `#`, since a file name may hold one too. */
|
|
22
|
+
export function splitSource(source, prefix) {
|
|
23
|
+
const rest = source.slice(prefix.length);
|
|
24
|
+
const cut = rest.lastIndexOf('#');
|
|
25
|
+
return cut < 0 ? { key: rest, hash: '' } : { key: rest.slice(0, cut), hash: rest.slice(cut + 1) };
|
|
26
|
+
}
|
|
27
|
+
/** The text as stored: emails masked as on every capture path, then the old Claude import's cap; the hash still sees the raw note. */
|
|
28
|
+
export function storedText(text) {
|
|
29
|
+
const masked = maskEmails(text);
|
|
30
|
+
return masked.length > CONTENT_CAP ? `${truncateCodePointSafe(masked, CONTENT_CAP)} [truncated]` : masked;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=source.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ImportReport } from './report.js';
|
|
2
|
+
import { type ToolId } from './tools.js';
|
|
3
|
+
import type { Adapter } from './types.js';
|
|
4
|
+
export declare const ADAPTERS: readonly Adapter[];
|
|
5
|
+
/** Overrides config because `hippo init` creates the store in the same command that imports (plan design 11). */
|
|
6
|
+
export declare const TOOLS_ENV = "HIPPO_AGENT_MEMORY_TOOLS";
|
|
7
|
+
/** Everything the adapters may know about the machine; tests hand in a scratch one. */
|
|
8
|
+
export interface Machine {
|
|
9
|
+
readonly home: string;
|
|
10
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
11
|
+
readonly platform: NodeJS.Platform;
|
|
12
|
+
}
|
|
13
|
+
export declare function currentMachine(): Machine;
|
|
14
|
+
export interface SyncOptions {
|
|
15
|
+
readonly machine: Machine;
|
|
16
|
+
/** Writes nothing: each container's transaction rolls back, and a missing store is stood in for by an empty one. */
|
|
17
|
+
readonly dryRun?: boolean;
|
|
18
|
+
/** How long a busy store is waited on before its containers are skipped. */
|
|
19
|
+
readonly busyWaitMs?: number;
|
|
20
|
+
}
|
|
21
|
+
/** init, sleep, `import --agents` and the daily runner: a local store gets its project pass then the user pass; the global store the user pass only. */
|
|
22
|
+
export declare function importForStore(hippoRoot: string, opts: SyncOptions): ImportReport;
|
|
23
|
+
/** A local store's project pass with legacy adoption; `init --scan` runs it per repository and the user pass once. */
|
|
24
|
+
export declare function importProjectMemories(hippoRoot: string, opts: SyncOptions): ImportReport;
|
|
25
|
+
/** Every tool's user-level memory into the global store, created on demand, with no origin. */
|
|
26
|
+
export declare function importUserMemories(invokingRoot: string, opts: SyncOptions): ImportReport;
|
|
27
|
+
/** Session end in a folder with no store of its own: the session's project into the global store with its origin, then the user pass. */
|
|
28
|
+
export declare function importAtSessionEnd(cwd: string, transcriptPath: string | undefined, opts: SyncOptions): ImportReport;
|
|
29
|
+
/** Post-compact: the transcript folder's notes only, with no git call, no legacy adoption and no user pass, as the hook has 10 seconds. */
|
|
30
|
+
export declare function importAtCompaction(hippoRoot: string, transcriptPath: string, originProject: string | undefined, opts: SyncOptions): ImportReport;
|
|
31
|
+
/** The variable when set (empty or `none` is off); else the tools both the invoking and the target store's config allow. */
|
|
32
|
+
export declare function allowedTools(env: Machine['env'], invoking: string, target: string, warnings: string[]): Set<ToolId>;
|
|
33
|
+
//# sourceMappingURL=sync.d.ts.map
|