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,47 @@
|
|
|
1
|
+
import type { DatabaseSyncLike } from '../db.js';
|
|
2
|
+
import { type MemoryEntry } from '../memory.js';
|
|
3
|
+
import { type Tally } from './report.js';
|
|
4
|
+
import type { AgentMemoryTool } from './tools.js';
|
|
5
|
+
import type { Container } from './types.js';
|
|
6
|
+
export declare const SYNC_ACTOR = "agent-memories";
|
|
7
|
+
export interface StoreSession {
|
|
8
|
+
readonly db: DatabaseSyncLike;
|
|
9
|
+
readonly hippoRoot: string;
|
|
10
|
+
readonly tenantId: string;
|
|
11
|
+
readonly baseHalfLifeDays: number;
|
|
12
|
+
/** Stamped on written rows; undefined lets the store stamp its own project. */
|
|
13
|
+
readonly originProject: string | undefined;
|
|
14
|
+
/** Whether the text is already stored live by another path, as seen where the new row goes. */
|
|
15
|
+
readonly isDuplicate: (text: string) => boolean;
|
|
16
|
+
readonly dryRun: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ContainerWork {
|
|
19
|
+
readonly tool: AgentMemoryTool;
|
|
20
|
+
readonly container: Container;
|
|
21
|
+
/** `containerPrefix(tool, containerId)`: every row of the container has a source starting with it. */
|
|
22
|
+
readonly prefix: string;
|
|
23
|
+
/** Legacy rows that take an item's key as they are, by key (plan design 10, first round). */
|
|
24
|
+
readonly adopt: ReadonlyMap<string, readonly MemoryEntry[]>;
|
|
25
|
+
/** Legacy rows a new row of the key supersedes, by key (second round). */
|
|
26
|
+
readonly replace: ReadonlyMap<string, readonly MemoryEntry[]>;
|
|
27
|
+
}
|
|
28
|
+
export interface ContainerOutcome {
|
|
29
|
+
readonly tally: Tally;
|
|
30
|
+
/** Rows to mirror after commit, as they now stand. */
|
|
31
|
+
readonly mirror: readonly MemoryEntry[];
|
|
32
|
+
/** Rows set aside, whose mirrors are purged after commit. */
|
|
33
|
+
readonly purge: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
/** Throws SQLITE_BUSY when another writer holds the store past its busy timeout; nothing is written then. */
|
|
36
|
+
export declare function syncContainer(s: StoreSession, work: ContainerWork): ContainerOutcome;
|
|
37
|
+
export type SetAsideWhy = 'note-gone' | 'note-changed' | 'handover';
|
|
38
|
+
export type SetAsideResult = {
|
|
39
|
+
readonly kind: 'untagged';
|
|
40
|
+
readonly entry: MemoryEntry;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'dormant';
|
|
43
|
+
readonly id: string;
|
|
44
|
+
};
|
|
45
|
+
/** Design 6's set-aside on the caller's transaction: a pinned row only loses the tag, any other goes dormant, restorable. */
|
|
46
|
+
export declare function setAsideRow(db: DatabaseSyncLike, tag: string, row: MemoryEntry, why: SetAsideWhy): SetAsideResult;
|
|
47
|
+
//# sourceMappingURL=apply.d.ts.map
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// One container's sync in one transaction on the caller's handle: lookup, plan, then every write (plan designs 6 to 8).
|
|
2
|
+
import { appendAuditEvent } from '../audit.js';
|
|
3
|
+
import { deleteDormantRow, dormantSnapshotsBySourcePrefix, insertDormantRow, readDormantSnapshot } from '../dormant.js';
|
|
4
|
+
import { gatedWrite } from '../gated-write.js';
|
|
5
|
+
import { Layer, calculateStrength, createMemory } from '../memory.js';
|
|
6
|
+
import { findRejectedValue, rejectionDigest } from '../rejection.js';
|
|
7
|
+
import { redactSecretsStrict } from '../secret-detect.js';
|
|
8
|
+
import { deleteEntryRowInTx, markSummaryDirtyInTx, selectLiveEntriesBySourcePrefix, setEntryTagsInTx, stampOriginProject } from '../store.js';
|
|
9
|
+
import { itemHash } from './keys.js';
|
|
10
|
+
import { planContainer } from './plan.js';
|
|
11
|
+
import { emptyTally } from './report.js';
|
|
12
|
+
import { MIN_ITEM_CHARS, itemSource, splitSource, storedText } from './source.js';
|
|
13
|
+
export const SYNC_ACTOR = 'agent-memories';
|
|
14
|
+
/** Throws SQLITE_BUSY when another writer holds the store past its busy timeout; nothing is written then. */
|
|
15
|
+
export function syncContainer(s, work) {
|
|
16
|
+
// A dry run takes no lock up front and rolls every write back.
|
|
17
|
+
s.db.exec(s.dryRun ? 'BEGIN' : 'BEGIN IMMEDIATE');
|
|
18
|
+
try {
|
|
19
|
+
const out = new ContainerRun(s, work).run();
|
|
20
|
+
s.db.exec(s.dryRun ? 'ROLLBACK' : 'COMMIT');
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
try {
|
|
25
|
+
s.db.exec('ROLLBACK');
|
|
26
|
+
}
|
|
27
|
+
catch { /* already rolled back; keep the original error */ }
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Design 6's set-aside on the caller's transaction: a pinned row only loses the tag, any other goes dormant, restorable. */
|
|
32
|
+
export function setAsideRow(db, tag, row, why) {
|
|
33
|
+
const untagged = { ...row, tags: row.tags.filter((t) => t !== tag) };
|
|
34
|
+
const audit = (metadata) => appendAuditEvent(db, { tenantId: row.tenantId, actor: SYNC_ACTOR, op: 'agent_memory_set_aside', targetId: row.id, metadata });
|
|
35
|
+
if (row.pinned) {
|
|
36
|
+
setEntryTagsInTx(db, untagged);
|
|
37
|
+
audit({ why, untagged: true });
|
|
38
|
+
return { kind: 'untagged', entry: untagged };
|
|
39
|
+
}
|
|
40
|
+
const now = new Date();
|
|
41
|
+
// Sleep's dormant move skips kept rows, so the steps are written out here without its filter.
|
|
42
|
+
insertDormantRow(db, { entry: untagged, strength: calculateStrength(row, now), reason: 'source-deleted', dormantAt: now.toISOString() });
|
|
43
|
+
deleteEntryRowInTx(db, row, SYNC_ACTOR);
|
|
44
|
+
audit({ why });
|
|
45
|
+
return { kind: 'dormant', id: row.id };
|
|
46
|
+
}
|
|
47
|
+
class ContainerRun {
|
|
48
|
+
s;
|
|
49
|
+
w;
|
|
50
|
+
tally = emptyTally();
|
|
51
|
+
mirror = [];
|
|
52
|
+
purge = [];
|
|
53
|
+
rows = new Map();
|
|
54
|
+
items = new Map();
|
|
55
|
+
tag;
|
|
56
|
+
constructor(s, w) {
|
|
57
|
+
this.s = s;
|
|
58
|
+
this.w = w;
|
|
59
|
+
this.tag = w.tool.tag;
|
|
60
|
+
for (const item of w.container.items)
|
|
61
|
+
this.items.set(item.key, item);
|
|
62
|
+
}
|
|
63
|
+
run() {
|
|
64
|
+
this.adoptLegacy();
|
|
65
|
+
const live = selectLiveEntriesBySourcePrefix(this.s.db, this.s.tenantId, this.w.prefix);
|
|
66
|
+
for (const row of [...live, ...[...this.w.replace.values()].flat()])
|
|
67
|
+
this.rows.set(row.id, row);
|
|
68
|
+
const refusals = this.refusals();
|
|
69
|
+
const liveRows = live.map((row) => this.liveRow(row));
|
|
70
|
+
const plan = planContainer({
|
|
71
|
+
textKeyed: this.w.container.textKeyed,
|
|
72
|
+
items: this.w.container.items.map((item) => ({ key: item.key, hash: itemHash(item.text), refused: refusals.has(item.key) })),
|
|
73
|
+
skipped: this.w.container.skipped,
|
|
74
|
+
live: liveRows,
|
|
75
|
+
dormant: this.dormantRows(new Set(liveRows.map((r) => r.key)), refusals),
|
|
76
|
+
legacy: new Map([...this.w.replace].map(([key, rows]) => [key, rows.map((r) => r.id)])),
|
|
77
|
+
isDuplicate: (item) => this.s.isDuplicate(storedText(this.item(item.key).text)),
|
|
78
|
+
});
|
|
79
|
+
for (const reason of refusals.values())
|
|
80
|
+
this.tally[reason]++;
|
|
81
|
+
this.tally.unread += this.w.container.skipped.length;
|
|
82
|
+
this.apply(plan);
|
|
83
|
+
return { tally: this.tally, mirror: this.mirror, purge: this.purge };
|
|
84
|
+
}
|
|
85
|
+
apply(plan) {
|
|
86
|
+
this.tally.unchanged += plan.unchanged;
|
|
87
|
+
this.tally.duplicate += plan.duplicates;
|
|
88
|
+
for (const id of plan.retag)
|
|
89
|
+
this.retag(this.row(id));
|
|
90
|
+
for (const { id, by } of plan.collapse)
|
|
91
|
+
if (this.supersede(this.row(id), by))
|
|
92
|
+
this.tally.collapsed++;
|
|
93
|
+
for (const write of plan.writes)
|
|
94
|
+
this.write(write);
|
|
95
|
+
for (const { key, dormantId } of plan.restores)
|
|
96
|
+
this.restore(key, dormantId);
|
|
97
|
+
for (const id of plan.setAside)
|
|
98
|
+
this.setAside(this.row(id));
|
|
99
|
+
}
|
|
100
|
+
/** Same text as a current note: the legacy row takes the note's key, keeping its id, recall count and outcomes. */
|
|
101
|
+
adoptLegacy() {
|
|
102
|
+
for (const [key, rows] of this.w.adopt) {
|
|
103
|
+
const item = this.items.get(key);
|
|
104
|
+
if (item === undefined)
|
|
105
|
+
continue;
|
|
106
|
+
const source = itemSource(this.w.prefix, key, item.text);
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
const moved = this.s.db.prepare(`UPDATE memories SET source = ? WHERE id = ? AND tenant_id = ? AND source = ? AND superseded_by IS NULL`).run(source, row.id, row.tenantId, row.source);
|
|
109
|
+
if (Number(moved.changes ?? 0) === 0)
|
|
110
|
+
continue;
|
|
111
|
+
this.tally.adopted++;
|
|
112
|
+
this.mirror.push({ ...row, source });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
refusals() {
|
|
117
|
+
const out = new Map();
|
|
118
|
+
for (const item of this.w.container.items) {
|
|
119
|
+
const reason = this.refusal(item);
|
|
120
|
+
if (reason !== null)
|
|
121
|
+
out.set(item.key, reason);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
// The rejection lookup reads the capped text, as the write would store it, so a rejected note writes no audit row.
|
|
126
|
+
refusal(item) {
|
|
127
|
+
if (item.text.trim().length < MIN_ITEM_CHARS)
|
|
128
|
+
return 'short';
|
|
129
|
+
// Imported rows reach prompts, so the bar is text leaving the machine: Bearer headers and JWTs count too.
|
|
130
|
+
if (redactSecretsStrict(item.text) !== item.text)
|
|
131
|
+
return 'secret';
|
|
132
|
+
if (findRejectedValue(this.s.db, this.s.tenantId, rejectionDigest(storedText(item.text))) !== null)
|
|
133
|
+
return 'rejected';
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
liveRow(row) {
|
|
137
|
+
return { id: row.id, ...splitSource(row.source, this.w.prefix), tagged: row.tags.includes(this.tag), created: row.created };
|
|
138
|
+
}
|
|
139
|
+
/** Read only when a present key has no live row, which after the first import is rare. */
|
|
140
|
+
dormantRows(liveKeys, refusals) {
|
|
141
|
+
const needed = this.w.container.items.some((item) => !refusals.has(item.key) && !liveKeys.has(item.key));
|
|
142
|
+
if (!needed)
|
|
143
|
+
return [];
|
|
144
|
+
return dormantSnapshotsBySourcePrefix(this.s.db, this.s.tenantId, this.w.prefix)
|
|
145
|
+
.filter((snap) => !snap.entry.superseded_by)
|
|
146
|
+
.map((snap) => ({ id: snap.entry.id, ...splitSource(snap.entry.source, this.w.prefix), dormantAt: snap.dormantAt }));
|
|
147
|
+
}
|
|
148
|
+
retag(row) {
|
|
149
|
+
const tagged = { ...row, tags: [...row.tags, this.tag] };
|
|
150
|
+
setEntryTagsInTx(this.s.db, tagged);
|
|
151
|
+
this.tally.retagged++;
|
|
152
|
+
this.mirror.push(tagged);
|
|
153
|
+
}
|
|
154
|
+
/** api.supersede's steps on this transaction; false when another writer superseded the row first. */
|
|
155
|
+
supersede(old, newId) {
|
|
156
|
+
const result = this.s.db.prepare(`UPDATE memories SET superseded_by = ? WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL`)
|
|
157
|
+
.run(newId, old.id, old.tenantId);
|
|
158
|
+
if (Number(result.changes ?? 0) === 0)
|
|
159
|
+
return false;
|
|
160
|
+
if (old.dag_parent_id)
|
|
161
|
+
markSummaryDirtyInTx(this.s.db, old.dag_parent_id, old.tenantId, SYNC_ACTOR);
|
|
162
|
+
appendAuditEvent(this.s.db, { tenantId: old.tenantId, actor: SYNC_ACTOR, op: 'supersede', targetId: old.id, metadata: { newId } });
|
|
163
|
+
this.mirror.push({ ...old, superseded_by: newId });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
write(planned) {
|
|
167
|
+
const entry = this.newRow(this.item(planned.key));
|
|
168
|
+
if (!this.gated(entry))
|
|
169
|
+
return;
|
|
170
|
+
this.tally[planned.supersedes.length > 0 ? 'replaced' : 'imported']++;
|
|
171
|
+
for (const id of planned.supersedes)
|
|
172
|
+
this.supersede(this.row(id), entry.id);
|
|
173
|
+
}
|
|
174
|
+
newRow(item) {
|
|
175
|
+
const base = createMemory(storedText(item.text), {
|
|
176
|
+
layer: Layer.Episodic,
|
|
177
|
+
tags: [this.tag],
|
|
178
|
+
source: itemSource(this.w.prefix, item.key, item.text),
|
|
179
|
+
confidence: 'observed',
|
|
180
|
+
kind: 'distilled',
|
|
181
|
+
tenantId: this.s.tenantId,
|
|
182
|
+
baseHalfLifeDays: this.s.baseHalfLifeDays,
|
|
183
|
+
});
|
|
184
|
+
// The note's own time when it is earlier than now; the loader reads any form but toISOString as drift.
|
|
185
|
+
const created = Number.isFinite(item.updatedAt)
|
|
186
|
+
? new Date(Math.min(item.updatedAt, Date.parse(base.created))).toISOString()
|
|
187
|
+
: base.created;
|
|
188
|
+
const origin = this.s.originProject === undefined ? {} : { origin_project: this.s.originProject };
|
|
189
|
+
return stampOriginProject(this.s.hippoRoot, { ...base, created, valid_from: created, ...origin });
|
|
190
|
+
}
|
|
191
|
+
gated(entry) {
|
|
192
|
+
const result = gatedWrite(this.s.db, this.s.hippoRoot, entry, { actor: SYNC_ACTOR, worthCheck: false });
|
|
193
|
+
if (result === 'written') {
|
|
194
|
+
this.mirror.push(entry);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
this.tally[result === 'skipped:rejected' ? 'rejected' : 'secret']++;
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
/** A deleted note came back unchanged: its old row returns with its id and history, under the sync's own audit op. */
|
|
201
|
+
restore(key, dormantId) {
|
|
202
|
+
const snap = readDormantSnapshot(this.s.db, this.s.tenantId, dormantId);
|
|
203
|
+
const taken = this.s.db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(dormantId) !== undefined;
|
|
204
|
+
if (snap === null || taken) {
|
|
205
|
+
this.write({ key, hash: '', supersedes: [] });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const now = new Date();
|
|
209
|
+
const tags = Array.isArray(snap.entry.tags) ? snap.entry.tags.filter((t) => t !== this.tag) : [];
|
|
210
|
+
const revived = {
|
|
211
|
+
...createMemory('dormant snapshot defaults', { baseHalfLifeDays: this.s.baseHalfLifeDays }),
|
|
212
|
+
...snap.entry,
|
|
213
|
+
tags: [...tags, this.tag],
|
|
214
|
+
last_retrieved: now.toISOString(),
|
|
215
|
+
};
|
|
216
|
+
if (!this.gated(stampOriginProject(this.s.hippoRoot, { ...revived, strength: calculateStrength(revived, now) })))
|
|
217
|
+
return;
|
|
218
|
+
deleteDormantRow(this.s.db, this.s.tenantId, dormantId);
|
|
219
|
+
appendAuditEvent(this.s.db, {
|
|
220
|
+
tenantId: this.s.tenantId,
|
|
221
|
+
actor: SYNC_ACTOR,
|
|
222
|
+
op: 'agent_memory_restore',
|
|
223
|
+
targetId: dormantId,
|
|
224
|
+
metadata: { reason: snap.reason, dormantAt: snap.dormantAt },
|
|
225
|
+
});
|
|
226
|
+
this.tally.restored++;
|
|
227
|
+
}
|
|
228
|
+
setAside(row) {
|
|
229
|
+
const why = this.items.has(splitSource(row.source, this.w.prefix).key) ? 'note-changed' : 'note-gone';
|
|
230
|
+
const result = setAsideRow(this.s.db, this.tag, row, why);
|
|
231
|
+
if (result.kind === 'untagged') {
|
|
232
|
+
this.tally.untagged++;
|
|
233
|
+
this.mirror.push(result.entry);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
this.tally.setAside++;
|
|
237
|
+
this.purge.push(result.id);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
row(id) {
|
|
241
|
+
const row = this.rows.get(id);
|
|
242
|
+
if (row === undefined)
|
|
243
|
+
throw new Error(`agent memory sync: planned row ${id} was not in the lookup`);
|
|
244
|
+
return row;
|
|
245
|
+
}
|
|
246
|
+
item(key) {
|
|
247
|
+
const item = this.items.get(key);
|
|
248
|
+
if (item === undefined)
|
|
249
|
+
throw new Error(`agent memory sync: planned key ${key} was not read`);
|
|
250
|
+
return item;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=apply.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Adapter, AdapterContext, Listing } from './types.js';
|
|
2
|
+
/** Claude Code's auto memory folder names for a project: its checkout, which subfolders share, or the folder itself outside a repository. */
|
|
3
|
+
export declare function claudeMemoryFolderNames(projectRoot: string, platform: NodeJS.Platform): Set<string>;
|
|
4
|
+
/** Claude Code's rule: a linked worktree shares its main checkout's folder, or the git folder's when that sits outside a checkout (a bare repository, or --separate-git-dir); any other checkout, a submodule included, keeps its own. */
|
|
5
|
+
export declare function claudeCheckoutRoot(top: string, gitDir: string, common: string): string;
|
|
6
|
+
/** Claude Code's folder name for a path: non-alphanumerics made '-', and a name over 200 characters cut to 200 plus a base-36 hash of the whole path. */
|
|
7
|
+
export declare function claudeFolderName(root: string): string;
|
|
8
|
+
export declare const claudeCodeAdapter: Adapter;
|
|
9
|
+
/** Post-compact's read: the session's own notes folder and nothing else, so no git call runs inside the hook's time limit. */
|
|
10
|
+
export declare function claudeTranscriptListing(ctx: AdapterContext, transcriptPath: string): Listing;
|
|
11
|
+
//# sourceMappingURL=claude-code.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Claude Code's auto memory: frontmatter `.md` notes in a per-project folder, plus the `autoMemoryDirectory` user folder.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { realpathOrResolve } from '../project-identity.js';
|
|
5
|
+
import { isStringValue } from '../capture.js';
|
|
6
|
+
import { isJsonObject } from '../hooks.js';
|
|
7
|
+
import { expandHome, frontmatterField, itemTime, readTextFile, splitFrontmatter } from './files.js';
|
|
8
|
+
import { markdownNotes, readFolderStore, uniqueFolders } from './folder-store.js';
|
|
9
|
+
import { gitLayout } from './git.js';
|
|
10
|
+
// Keeps a pinned name from carrying a separator or `..` out of the projects folder.
|
|
11
|
+
const PROJECT_DIR_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
12
|
+
/** Claude Code's auto memory folder names for a project: its checkout, which subfolders share, or the folder itself outside a repository. */
|
|
13
|
+
export function claudeMemoryFolderNames(projectRoot, platform) {
|
|
14
|
+
const roots = [projectRoot, realpathOrResolve(projectRoot)];
|
|
15
|
+
const layout = gitLayout(projectRoot);
|
|
16
|
+
if (layout)
|
|
17
|
+
roots.push(claudeCheckoutRoot(layout.top, layout.gitDir, layout.common));
|
|
18
|
+
return new Set(roots.map((root) => (platform === 'win32' ? claudeFolderName(root).toLowerCase() : claudeFolderName(root))));
|
|
19
|
+
}
|
|
20
|
+
/** Claude Code's rule: a linked worktree shares its main checkout's folder, or the git folder's when that sits outside a checkout (a bare repository, or --separate-git-dir); any other checkout, a submodule included, keeps its own. */
|
|
21
|
+
export function claudeCheckoutRoot(top, gitDir, common) {
|
|
22
|
+
if (gitDir === common)
|
|
23
|
+
return top;
|
|
24
|
+
if (path.basename(common) === '.git')
|
|
25
|
+
return path.dirname(common);
|
|
26
|
+
return fs.existsSync(path.join(common, '.git')) ? top : common;
|
|
27
|
+
}
|
|
28
|
+
/** Claude Code's folder name for a path: non-alphanumerics made '-', and a name over 200 characters cut to 200 plus a base-36 hash of the whole path. */
|
|
29
|
+
export function claudeFolderName(root) {
|
|
30
|
+
const full = path.resolve(root);
|
|
31
|
+
const name = full.replace(/[^a-zA-Z0-9]/g, '-');
|
|
32
|
+
if (name.length <= 200)
|
|
33
|
+
return name;
|
|
34
|
+
let hash = 0;
|
|
35
|
+
for (let i = 0; i < full.length; i++)
|
|
36
|
+
hash = ((hash << 5) - hash + full.charCodeAt(i)) | 0;
|
|
37
|
+
return `${name.slice(0, 200)}-${Math.abs(hash).toString(36)}`;
|
|
38
|
+
}
|
|
39
|
+
export const claudeCodeAdapter = {
|
|
40
|
+
tool: 'claude-code',
|
|
41
|
+
list(ctx, scope) {
|
|
42
|
+
const config = ctx.env.CLAUDE_CONFIG_DIR || path.join(ctx.home, '.claude');
|
|
43
|
+
const warnings = [];
|
|
44
|
+
const folders = scope === 'project' ? projectFolders(ctx, config) : userFolders(ctx, config, warnings);
|
|
45
|
+
return { tool: 'claude-code', home: config, containers: readFolders(folders, scope, ctx.platform), warnings };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
/** Post-compact's read: the session's own notes folder and nothing else, so no git call runs inside the hook's time limit. */
|
|
49
|
+
export function claudeTranscriptListing(ctx, transcriptPath) {
|
|
50
|
+
const config = ctx.env.CLAUDE_CONFIG_DIR || path.join(ctx.home, '.claude');
|
|
51
|
+
const folder = path.join(path.dirname(transcriptPath), 'memory');
|
|
52
|
+
return { tool: 'claude-code', home: config, containers: readFolders([folder], 'project', ctx.platform), warnings: [] };
|
|
53
|
+
}
|
|
54
|
+
function projectFolders(ctx, config) {
|
|
55
|
+
const projects = path.join(config, 'projects');
|
|
56
|
+
const names = ctx.projectRoot === undefined ? [] : [...claudeMemoryFolderNames(ctx.projectRoot, ctx.platform)];
|
|
57
|
+
const pinned = ctx.env.CLAUDE_CODE_PROJECT_DIR_NAME;
|
|
58
|
+
// Claude reads the pinned name only alongside a pinned config folder.
|
|
59
|
+
if (ctx.env.CLAUDE_CONFIG_DIR && pinned !== undefined && PROJECT_DIR_NAME.test(pinned))
|
|
60
|
+
names.push(pinned);
|
|
61
|
+
const folders = names.map((name) => path.join(projects, name, 'memory'));
|
|
62
|
+
if (ctx.transcriptPath)
|
|
63
|
+
folders.push(path.join(path.dirname(ctx.transcriptPath), 'memory'));
|
|
64
|
+
return folders;
|
|
65
|
+
}
|
|
66
|
+
function userFolders(ctx, config, warnings) {
|
|
67
|
+
const dir = autoMemoryDirectory(path.join(config, 'settings.json'), ctx.home, warnings);
|
|
68
|
+
return dir === null ? [] : [dir];
|
|
69
|
+
}
|
|
70
|
+
// User settings only: a cloned repository's own settings must not point the import at another project's notes.
|
|
71
|
+
function autoMemoryDirectory(settings, home, warnings) {
|
|
72
|
+
if (!fs.existsSync(settings))
|
|
73
|
+
return null;
|
|
74
|
+
const file = readTextFile(settings);
|
|
75
|
+
if (!file.ok) {
|
|
76
|
+
warnings.push(file.reason);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
let json;
|
|
80
|
+
try {
|
|
81
|
+
// SAFETY: JSON.parse yields JSON; the object and string checks below decide what is used.
|
|
82
|
+
json = JSON.parse(file.text);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
warnings.push(`${settings}: ${err instanceof Error ? err.message : String(err)}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const value = isJsonObject(json) ? json.autoMemoryDirectory : undefined;
|
|
89
|
+
if (!isStringValue(value) || value === '')
|
|
90
|
+
return null;
|
|
91
|
+
const dir = expandHome(value, home);
|
|
92
|
+
if (path.isAbsolute(dir))
|
|
93
|
+
return dir;
|
|
94
|
+
warnings.push(`${settings}: autoMemoryDirectory "${value}" is not absolute or under ~/, so it is ignored`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
// Claude counts a note only when it carries frontmatter; `modified` there beats the file time.
|
|
98
|
+
const NOTES = {
|
|
99
|
+
recursive: false,
|
|
100
|
+
include: markdownNotes,
|
|
101
|
+
item(text, mtimeMs) {
|
|
102
|
+
const { yaml, body } = splitFrontmatter(text);
|
|
103
|
+
if (yaml === null)
|
|
104
|
+
return null;
|
|
105
|
+
return { text: body.trim(), updatedAt: itemTime(frontmatterField(yaml, 'modified'), mtimeMs) };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
function readFolders(folders, scope, platform) {
|
|
109
|
+
return uniqueFolders(folders, platform)
|
|
110
|
+
.map((dir) => readFolderStore(dir, scope, NOTES))
|
|
111
|
+
.filter((c) => c !== null);
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=claude-code.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Codex CLI's memory_summary.md, the one memory file Codex puts in its prompt, read as a single text-keyed file.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { codexHomeDir } from '../hooks.js';
|
|
5
|
+
import { readTextFile } from './files.js';
|
|
6
|
+
import { textItemKeys } from './keys.js';
|
|
7
|
+
import { splitMarkdownItems } from './markdown.js';
|
|
8
|
+
// "What's in Memory" only indexes MEMORY.md, so it is left out.
|
|
9
|
+
const KEPT_HEADINGS = new Set(['user profile', 'user preferences', 'general tips']);
|
|
10
|
+
const PROFILE_HEADING = /^##[ \t]+User Profile[ \t]*$/im;
|
|
11
|
+
export const codexAdapter = {
|
|
12
|
+
tool: 'codex',
|
|
13
|
+
list(ctx, scope) {
|
|
14
|
+
const home = codexHomeDir(ctx.home, ctx.env);
|
|
15
|
+
const containers = [];
|
|
16
|
+
if (scope === 'user') {
|
|
17
|
+
const container = readSummary(path.join(home, 'memories', 'memory_summary.md'));
|
|
18
|
+
if (container !== null)
|
|
19
|
+
containers.push(container);
|
|
20
|
+
}
|
|
21
|
+
return { tool: 'codex', home, containers, warnings: [] };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
function readSummary(file) {
|
|
25
|
+
if (!fs.existsSync(file))
|
|
26
|
+
return null;
|
|
27
|
+
const read = readTextFile(file);
|
|
28
|
+
if (!read.ok)
|
|
29
|
+
return unreadable(file, read.reason);
|
|
30
|
+
if (!isCodexSummary(read.text)) {
|
|
31
|
+
return unreadable(file, `${file}: not a Codex memory summary (needs "v1" first and a "## User Profile" heading)`);
|
|
32
|
+
}
|
|
33
|
+
const kept = splitMarkdownItems(read.text)
|
|
34
|
+
.filter((item) => KEPT_HEADINGS.has(item.heading.trim().toLowerCase()))
|
|
35
|
+
.map((item) => ({ headingSlug: item.headingSlug, text: `${item.heading}: ${item.text}` }));
|
|
36
|
+
const keys = textItemKeys(kept);
|
|
37
|
+
const items = kept.map(({ text }, i) => ({ key: keys[i], text, updatedAt: read.mtimeMs }));
|
|
38
|
+
return { scope: 'user', path: file, readable: true, items, skipped: [], warnings: [], textKeyed: true };
|
|
39
|
+
}
|
|
40
|
+
function isCodexSummary(text) {
|
|
41
|
+
const first = text.split(/\r?\n/).find((line) => line.trim() !== '');
|
|
42
|
+
return first?.trim() === 'v1' && PROFILE_HEADING.test(text);
|
|
43
|
+
}
|
|
44
|
+
function unreadable(file, warning) {
|
|
45
|
+
return { scope: 'user', path: file, readable: false, items: [], skipped: [], warnings: [warning], textKeyed: true };
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=codex.js.map
|
|
@@ -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
|