hippo-memory 1.61.0 → 1.62.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 +28 -53
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/claude-code.d.ts +3 -1
- package/dist/agent-memories/claude-code.js +53 -9
- package/dist/agent-memories/sync.d.ts +3 -3
- package/dist/agent-memories/sync.js +14 -6
- package/dist/agent-memories/types.d.ts +0 -2
- package/dist/api/assemble.js +55 -54
- package/dist/api/context-select.d.ts +49 -0
- package/dist/api/context-select.js +344 -0
- package/dist/api/context.d.ts +2 -2
- package/dist/api/context.js +195 -522
- package/dist/api/drill-down.js +36 -33
- package/dist/api/promote.js +55 -66
- package/dist/api/recall.js +303 -438
- package/dist/api/sleep.js +203 -218
- package/dist/capture/compact.d.ts +1 -1
- package/dist/capture/compact.js +2 -2
- package/dist/cli/briefs.js +324 -306
- package/dist/cli/context.js +44 -34
- package/dist/cli/continuity.js +283 -271
- package/dist/cli/curate.js +35 -34
- package/dist/cli/decisions.js +333 -333
- package/dist/cli/explain.js +66 -60
- package/dist/cli/maintenance.js +62 -51
- package/dist/cli/playbooks.js +387 -370
- package/dist/cli/projects.js +8 -5
- package/dist/cli/recall.js +28 -43
- package/dist/cli/remember.js +113 -70
- package/dist/cli/session-hooks.js +100 -90
- package/dist/cli/setup.js +267 -246
- package/dist/cli/status.js +73 -64
- package/dist/cli/transfer.js +85 -99
- package/dist/compaction-record.d.ts +0 -2
- package/dist/compaction-record.js +1 -1
- package/dist/customer-notes.js +77 -68
- package/dist/dag.js +222 -186
- package/dist/decisions.js +93 -76
- package/dist/doctor.js +11 -7
- package/dist/goals.js +99 -86
- package/dist/incidents.js +45 -38
- package/dist/policies.js +85 -68
- package/dist/processes.js +87 -71
- package/dist/project-briefs.js +135 -108
- package/dist/project-merge.d.ts +12 -4
- package/dist/project-merge.js +130 -45
- package/dist/shared.d.ts +9 -0
- package/dist/shared.js +10 -8
- package/dist/skills.js +81 -65
- package/dist/store/search-rows.d.ts +2 -2
- package/dist/store/search-rows.js +15 -9
- 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
package/dist/cli/status.js
CHANGED
|
@@ -31,36 +31,7 @@ export function cmdStatus(hippoRoot) {
|
|
|
31
31
|
const entries = loadAllEntries(hippoRoot);
|
|
32
32
|
const stats = loadStats(hippoRoot);
|
|
33
33
|
const now = evalNow();
|
|
34
|
-
const byLayer =
|
|
35
|
-
[Layer.Buffer]: 0,
|
|
36
|
-
[Layer.Episodic]: 0,
|
|
37
|
-
[Layer.Semantic]: 0,
|
|
38
|
-
[Layer.Trace]: 0,
|
|
39
|
-
};
|
|
40
|
-
const byConfidence = {
|
|
41
|
-
verified: 0,
|
|
42
|
-
observed: 0,
|
|
43
|
-
inferred: 0,
|
|
44
|
-
stale: 0,
|
|
45
|
-
};
|
|
46
|
-
let totalStrength = 0;
|
|
47
|
-
let pinned = 0;
|
|
48
|
-
let atRisk = 0; // strength < 0.2
|
|
49
|
-
let agedOut = 0;
|
|
50
|
-
for (const e of entries) {
|
|
51
|
-
const s = calculateStrength(e, now);
|
|
52
|
-
byLayer[e.layer] = (byLayer[e.layer] ?? 0) + 1;
|
|
53
|
-
totalStrength += s;
|
|
54
|
-
if (e.pinned)
|
|
55
|
-
pinned++;
|
|
56
|
-
if (s < 0.2)
|
|
57
|
-
atRisk++;
|
|
58
|
-
const facets = confidenceFacets(e, now);
|
|
59
|
-
byConfidence[facets.tier] = (byConfidence[facets.tier] ?? 0) + 1;
|
|
60
|
-
if (facets.agedOut)
|
|
61
|
-
agedOut++;
|
|
62
|
-
}
|
|
63
|
-
const avgStrength = entries.length > 0 ? totalStrength / entries.length : 0;
|
|
34
|
+
const { byLayer, byConfidence, pinned, atRisk, agedOut, avgStrength } = tallyStatus(entries, now);
|
|
64
35
|
console.log('Hippo Status');
|
|
65
36
|
console.log('---------------------------');
|
|
66
37
|
console.log(`Total memories: ${entries.length}`);
|
|
@@ -92,7 +63,44 @@ export function cmdStatus(hippoRoot) {
|
|
|
92
63
|
else {
|
|
93
64
|
console.log(`Last sleep: never`);
|
|
94
65
|
}
|
|
95
|
-
|
|
66
|
+
printEmbeddingStatus(hippoRoot, entries);
|
|
67
|
+
printPhysicsStatus(hippoRoot);
|
|
68
|
+
}
|
|
69
|
+
function tallyStatus(entries, now) {
|
|
70
|
+
const byLayer = {
|
|
71
|
+
[Layer.Buffer]: 0,
|
|
72
|
+
[Layer.Episodic]: 0,
|
|
73
|
+
[Layer.Semantic]: 0,
|
|
74
|
+
[Layer.Trace]: 0,
|
|
75
|
+
};
|
|
76
|
+
const byConfidence = {
|
|
77
|
+
verified: 0,
|
|
78
|
+
observed: 0,
|
|
79
|
+
inferred: 0,
|
|
80
|
+
stale: 0,
|
|
81
|
+
};
|
|
82
|
+
let totalStrength = 0;
|
|
83
|
+
let pinned = 0;
|
|
84
|
+
let atRisk = 0; // strength < 0.2
|
|
85
|
+
let agedOut = 0;
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
const s = calculateStrength(e, now);
|
|
88
|
+
byLayer[e.layer] = (byLayer[e.layer] ?? 0) + 1;
|
|
89
|
+
totalStrength += s;
|
|
90
|
+
if (e.pinned)
|
|
91
|
+
pinned++;
|
|
92
|
+
if (s < 0.2)
|
|
93
|
+
atRisk++;
|
|
94
|
+
const facets = confidenceFacets(e, now);
|
|
95
|
+
byConfidence[facets.tier] = (byConfidence[facets.tier] ?? 0) + 1;
|
|
96
|
+
if (facets.agedOut)
|
|
97
|
+
agedOut++;
|
|
98
|
+
}
|
|
99
|
+
const avgStrength = entries.length > 0 ? totalStrength / entries.length : 0;
|
|
100
|
+
return { byLayer, byConfidence, pinned, atRisk, agedOut, avgStrength };
|
|
101
|
+
}
|
|
102
|
+
// Embedding status (provider-aware)
|
|
103
|
+
function printEmbeddingStatus(hippoRoot, entries) {
|
|
96
104
|
const embedProvider = (() => {
|
|
97
105
|
try {
|
|
98
106
|
return resolveEmbeddingProvider(hippoRoot);
|
|
@@ -105,42 +113,43 @@ export function cmdStatus(hippoRoot) {
|
|
|
105
113
|
console.log('');
|
|
106
114
|
if (!embedProvider) {
|
|
107
115
|
console.log(`Embeddings: misconfigured (check embeddings.provider / apiBaseUrl), BM25 only`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const embeddingsDisabled = loadConfig(hippoRoot).embeddings.enabled === false;
|
|
119
|
+
const embAvail = embedProvider.isAvailable();
|
|
120
|
+
if (embeddingsDisabled) {
|
|
121
|
+
console.log(`Embeddings: disabled in config (embeddings.enabled = false), BM25 only`);
|
|
122
|
+
}
|
|
123
|
+
else if (embedProvider.kind === 'local') {
|
|
124
|
+
console.log(`Embeddings: ${embAvail ? `available [${embedProvider.id}]` : 'not installed (BM25 only)'}`);
|
|
125
|
+
}
|
|
126
|
+
else if (embAvail) {
|
|
127
|
+
console.log(`Embeddings: ${embedProvider.kind} api [${embedProvider.id}]`);
|
|
108
128
|
}
|
|
109
129
|
else {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const activeEmbedded = Object.keys(embIndex).filter((id) => activeIds.has(id)).length;
|
|
130
|
-
const orphaned = Object.keys(embIndex).length - activeEmbedded;
|
|
131
|
-
const dims = Object.values(embIndex)[0]?.length;
|
|
132
|
-
let line = `Embedded: ${activeEmbedded}/${entries.length} memories`;
|
|
133
|
-
if (dims)
|
|
134
|
-
line += ` (${dims}-dim)`;
|
|
135
|
-
if (orphaned > 0)
|
|
136
|
-
line += ` (${orphaned} orphaned, run \`hippo embed\` to prune)`;
|
|
137
|
-
console.log(line);
|
|
138
|
-
if (embeddingModelRequiresReindex(hippoRoot, embedProvider.id, embIndex)) {
|
|
139
|
-
console.log(` model changed, run \`hippo embed\` to reindex`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
130
|
+
console.log(`Embeddings: ${embedProvider.kind} configured but ${embedProvider.keyEnv} not set (BM25 only)`);
|
|
131
|
+
}
|
|
132
|
+
// Show cached counts whenever vectors exist on disk (even when disabled or
|
|
133
|
+
// the key was removed), so the user still sees what is already indexed.
|
|
134
|
+
const embIndex = loadEmbeddingIndex(hippoRoot);
|
|
135
|
+
if (!embAvail && Object.keys(embIndex).length === 0)
|
|
136
|
+
return;
|
|
137
|
+
const activeIds = new Set(entries.map((e) => e.id));
|
|
138
|
+
const activeEmbedded = Object.keys(embIndex).filter((id) => activeIds.has(id)).length;
|
|
139
|
+
const orphaned = Object.keys(embIndex).length - activeEmbedded;
|
|
140
|
+
const dims = Object.values(embIndex)[0]?.length;
|
|
141
|
+
let line = `Embedded: ${activeEmbedded}/${entries.length} memories`;
|
|
142
|
+
if (dims)
|
|
143
|
+
line += ` (${dims}-dim)`;
|
|
144
|
+
if (orphaned > 0)
|
|
145
|
+
line += ` (${orphaned} orphaned, run \`hippo embed\` to prune)`;
|
|
146
|
+
console.log(line);
|
|
147
|
+
if (embeddingModelRequiresReindex(hippoRoot, embedProvider.id, embIndex)) {
|
|
148
|
+
console.log(` model changed, run \`hippo embed\` to reindex`);
|
|
142
149
|
}
|
|
143
|
-
|
|
150
|
+
}
|
|
151
|
+
// Physics status
|
|
152
|
+
function printPhysicsStatus(hippoRoot) {
|
|
144
153
|
try {
|
|
145
154
|
const db = openHippoDb(hippoRoot);
|
|
146
155
|
try {
|
package/dist/cli/transfer.js
CHANGED
|
@@ -112,18 +112,8 @@ export function cmdImport(hippoRoot, args, flags) {
|
|
|
112
112
|
? [String(flags['tag'])]
|
|
113
113
|
: [];
|
|
114
114
|
const targetRoot = useGlobal ? getGlobalRoot() : hippoRoot;
|
|
115
|
-
if (flags['agents'])
|
|
116
|
-
|
|
117
|
-
// A folder without a store of its own imports as session end would there, so its notes are not hidden.
|
|
118
|
-
const report = useGlobal || isInitialized(hippoRoot)
|
|
119
|
-
? importForStore(useGlobal ? getGlobalRoot() : hippoRoot, opts)
|
|
120
|
-
: importAtSessionEnd(process.cwd(), undefined, opts);
|
|
121
|
-
for (const line of detailLines(report, dryRun))
|
|
122
|
-
console.log(line);
|
|
123
|
-
for (const warning of report.warnings)
|
|
124
|
-
printError(`hippo: agent memories: ${warning}`);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
115
|
+
if (flags['agents'])
|
|
116
|
+
return importAgentMemories(hippoRoot, useGlobal, dryRun);
|
|
127
117
|
if (useGlobal) {
|
|
128
118
|
initGlobal();
|
|
129
119
|
}
|
|
@@ -142,93 +132,29 @@ export function cmdImport(hippoRoot, args, flags) {
|
|
|
142
132
|
// It writes through api.remember/archiveRaw which are tenant-scoped, so we
|
|
143
133
|
// resolve the tenant and pass it through. --global is not supported for
|
|
144
134
|
// vault import (the connector raw-archive path is tenant-local).
|
|
145
|
-
if (flags['vault'])
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
printError('hippo import --vault: --scope requires a value (e.g. --scope vault:private:notes).');
|
|
169
|
-
process.exit(1);
|
|
170
|
-
}
|
|
171
|
-
const tenantId = resolveTenantId({});
|
|
172
|
-
const vaultOptions = {
|
|
173
|
-
...importOptions,
|
|
174
|
-
tenantId,
|
|
175
|
-
name: flags['name'] ? String(flags['name']) : undefined,
|
|
176
|
-
scope: flags['scope'] ? String(flags['scope']) : undefined,
|
|
177
|
-
};
|
|
178
|
-
const vaultResult = importVault(folderPath, vaultOptions);
|
|
179
|
-
console.log(`\nImport Vault: ${folderPath}${dryRun ? ' (dry run - no writes)' : ''}`);
|
|
180
|
-
console.log(` Notes found: ${vaultResult.total}`);
|
|
181
|
-
console.log(` ${dryRun ? 'Would import: ' : 'Imported: '}${vaultResult.imported}`);
|
|
182
|
-
console.log(` Skipped (unchanged): ${vaultResult.skipped}`);
|
|
183
|
-
if ((vaultResult.rejected ?? 0) > 0) {
|
|
184
|
-
console.log(` Rejected (tombstoned): ${vaultResult.rejected}`);
|
|
185
|
-
}
|
|
186
|
-
warnRedacted(vaultResult.redacted);
|
|
187
|
-
console.log(` ${dryRun ? 'Would archive: ' : 'Archived (removed): '}${vaultResult.archived ?? 0}`);
|
|
188
|
-
console.log(` Store: ${hippoRoot}`);
|
|
189
|
-
// Batch producer, same contract as the single-file import below: vault rows
|
|
190
|
-
// write through api.remember (which never embeds), so backfill them here.
|
|
191
|
-
// Floating promise is deliberate; see the comment at the single-file site.
|
|
192
|
-
if (!dryRun && vaultResult.imported >= 1) {
|
|
193
|
-
void embedAll(hippoRoot).catch(() => { });
|
|
194
|
-
}
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
// Determine which importer to use based on flag
|
|
198
|
-
let filePath;
|
|
199
|
-
let importer;
|
|
200
|
-
let importerName = '';
|
|
201
|
-
if (flags['chatgpt']) {
|
|
202
|
-
filePath = String(flags['chatgpt']);
|
|
203
|
-
importer = importChatGPT;
|
|
204
|
-
importerName = 'ChatGPT';
|
|
205
|
-
}
|
|
206
|
-
else if (flags['claude']) {
|
|
207
|
-
filePath = String(flags['claude']);
|
|
208
|
-
importer = importClaude;
|
|
209
|
-
importerName = 'Claude';
|
|
210
|
-
}
|
|
211
|
-
else if (flags['cursor']) {
|
|
212
|
-
filePath = String(flags['cursor']);
|
|
213
|
-
importer = importCursor;
|
|
214
|
-
importerName = 'Cursor';
|
|
215
|
-
}
|
|
216
|
-
else if (flags['file']) {
|
|
217
|
-
filePath = String(flags['file']);
|
|
218
|
-
importer = importGenericFile;
|
|
219
|
-
importerName = 'File';
|
|
220
|
-
}
|
|
221
|
-
else if (flags['markdown']) {
|
|
222
|
-
filePath = String(flags['markdown']);
|
|
223
|
-
importer = importMarkdown;
|
|
224
|
-
importerName = 'Markdown';
|
|
225
|
-
}
|
|
226
|
-
else if (args[0]) {
|
|
227
|
-
// Positional: try to auto-detect from extension
|
|
228
|
-
filePath = args[0];
|
|
229
|
-
importer = importGenericFile;
|
|
230
|
-
importerName = 'File';
|
|
231
|
-
}
|
|
135
|
+
if (flags['vault'])
|
|
136
|
+
return importVaultFolder(hippoRoot, flags, importOptions, useGlobal, dryRun);
|
|
137
|
+
importFromFile(targetRoot, args, flags, importOptions, useGlobal, dryRun);
|
|
138
|
+
}
|
|
139
|
+
// Determine which importer to use based on flag
|
|
140
|
+
function pickImporter(args, flags) {
|
|
141
|
+
if (flags['chatgpt'])
|
|
142
|
+
return { filePath: String(flags['chatgpt']), importer: importChatGPT, importerName: 'ChatGPT' };
|
|
143
|
+
if (flags['claude'])
|
|
144
|
+
return { filePath: String(flags['claude']), importer: importClaude, importerName: 'Claude' };
|
|
145
|
+
if (flags['cursor'])
|
|
146
|
+
return { filePath: String(flags['cursor']), importer: importCursor, importerName: 'Cursor' };
|
|
147
|
+
if (flags['file'])
|
|
148
|
+
return { filePath: String(flags['file']), importer: importGenericFile, importerName: 'File' };
|
|
149
|
+
if (flags['markdown'])
|
|
150
|
+
return { filePath: String(flags['markdown']), importer: importMarkdown, importerName: 'Markdown' };
|
|
151
|
+
// Positional: try to auto-detect from extension
|
|
152
|
+
if (args[0])
|
|
153
|
+
return { filePath: args[0], importer: importGenericFile, importerName: 'File' };
|
|
154
|
+
return { filePath: undefined, importer: undefined, importerName: '' };
|
|
155
|
+
}
|
|
156
|
+
function importFromFile(targetRoot, args, flags, importOptions, useGlobal, dryRun) {
|
|
157
|
+
const { filePath, importer, importerName } = pickImporter(args, flags);
|
|
232
158
|
if (!filePath || !importer) {
|
|
233
159
|
printError('Usage: hippo import <--chatgpt|--claude|--cursor|--file|--markdown|--vault> <path>, or hippo import --agents [--dry-run]');
|
|
234
160
|
process.exit(1);
|
|
@@ -272,6 +198,66 @@ export function cmdImport(hippoRoot, args, flags) {
|
|
|
272
198
|
console.log(` Store: ${storeLabel}`);
|
|
273
199
|
}
|
|
274
200
|
}
|
|
201
|
+
function importAgentMemories(hippoRoot, useGlobal, dryRun) {
|
|
202
|
+
const opts = { machine: currentMachine(), dryRun };
|
|
203
|
+
// A folder without a store of its own imports as session end would there, so its notes are not hidden.
|
|
204
|
+
const report = useGlobal || isInitialized(hippoRoot)
|
|
205
|
+
? importForStore(useGlobal ? getGlobalRoot() : hippoRoot, opts)
|
|
206
|
+
: importAtSessionEnd(process.cwd(), undefined, opts);
|
|
207
|
+
for (const line of detailLines(report, dryRun))
|
|
208
|
+
console.log(line);
|
|
209
|
+
for (const warning of report.warnings)
|
|
210
|
+
printError(`hippo: agent memories: ${warning}`);
|
|
211
|
+
}
|
|
212
|
+
function importVaultFolder(hippoRoot, flags, importOptions, useGlobal, dryRun) {
|
|
213
|
+
const folderPath = String(flags['vault']);
|
|
214
|
+
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
|
|
215
|
+
printError(`Vault folder not found (or not a directory): ${folderPath}`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
if (useGlobal) {
|
|
219
|
+
printError('hippo import --vault does not support --global (raw rows are tenant-local).');
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
if (typeof flags['name'] !== 'string' || !flags['name'].trim()) {
|
|
223
|
+
// --name is the vault identity key for the destructive source-deletion sync; inferring it from the folder basename let
|
|
224
|
+
// same-basename vaults collide and clobber each other (codex R10 P2). A valueless `--name` parses as boolean true, and
|
|
225
|
+
// String(true) === "true" would silently import under vault:true:* - reject a non-string so it fails fast instead (codex R11 P2).
|
|
226
|
+
printError('hippo import --vault requires --name <vault> (a non-empty identity key for source-deletion sync).');
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
if (flags['scope'] !== undefined && (typeof flags['scope'] !== 'string' || !flags['scope'].trim())) {
|
|
230
|
+
// Same valueless-flag trap: a bare `--scope` must not become scope "true".
|
|
231
|
+
// Example uses the source-prefixed private form, since a bare `private` scope
|
|
232
|
+
// is NOT treated as private by recall and importVault rejects it (R13 P2).
|
|
233
|
+
printError('hippo import --vault: --scope requires a value (e.g. --scope vault:private:notes).');
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
const tenantId = resolveTenantId({});
|
|
237
|
+
const vaultOptions = {
|
|
238
|
+
...importOptions,
|
|
239
|
+
tenantId,
|
|
240
|
+
name: flags['name'] ? String(flags['name']) : undefined,
|
|
241
|
+
scope: flags['scope'] ? String(flags['scope']) : undefined,
|
|
242
|
+
};
|
|
243
|
+
const vaultResult = importVault(folderPath, vaultOptions);
|
|
244
|
+
console.log(`\nImport Vault: ${folderPath}${dryRun ? ' (dry run - no writes)' : ''}`);
|
|
245
|
+
console.log(` Notes found: ${vaultResult.total}`);
|
|
246
|
+
console.log(` ${dryRun ? 'Would import: ' : 'Imported: '}${vaultResult.imported}`);
|
|
247
|
+
console.log(` Skipped (unchanged): ${vaultResult.skipped}`);
|
|
248
|
+
if ((vaultResult.rejected ?? 0) > 0) {
|
|
249
|
+
console.log(` Rejected (tombstoned): ${vaultResult.rejected}`);
|
|
250
|
+
}
|
|
251
|
+
warnRedacted(vaultResult.redacted);
|
|
252
|
+
console.log(` ${dryRun ? 'Would archive: ' : 'Archived (removed): '}${vaultResult.archived ?? 0}`);
|
|
253
|
+
console.log(` Store: ${hippoRoot}`);
|
|
254
|
+
// Batch producer, same contract as the single-file import below: vault rows
|
|
255
|
+
// write through api.remember (which never embeds), so backfill them here.
|
|
256
|
+
// Floating promise is deliberate; see the comment at the single-file site.
|
|
257
|
+
if (!dryRun && vaultResult.imported >= 1) {
|
|
258
|
+
void embedAll(hippoRoot).catch(() => { });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
275
261
|
// ---------------------------------------------------------------------------
|
|
276
262
|
// Promote command
|
|
277
263
|
// ---------------------------------------------------------------------------
|
|
@@ -25,8 +25,6 @@ export interface CompactionRecord {
|
|
|
25
25
|
status: CompactionStatus;
|
|
26
26
|
}
|
|
27
27
|
export type Log = (message: string) => void;
|
|
28
|
-
/** Where the session ran: rows written through the global store keep the project the session was in. */
|
|
29
|
-
export declare function compactionOrigin(hippoRoot: string, cwd: string | null): string;
|
|
30
28
|
interface ScrubbedSummary {
|
|
31
29
|
summary: string;
|
|
32
30
|
items: string[];
|
|
@@ -30,7 +30,7 @@ const CLAIMED_SUFFIX = '.claimed';
|
|
|
30
30
|
/** The marker redactSecretsStrict writes; an item holding it was a secret before it was stored. */
|
|
31
31
|
const REDACTED = '[REDACTED]';
|
|
32
32
|
/** Where the session ran: rows written through the global store keep the project the session was in. */
|
|
33
|
-
|
|
33
|
+
function compactionOrigin(hippoRoot, cwd) {
|
|
34
34
|
if (!isGlobalStoreRoot(hippoRoot))
|
|
35
35
|
return deriveOriginProject(path.dirname(hippoRoot));
|
|
36
36
|
// No cwd means user-global, as stampOriginProject gives the global store; undefined would fall back to the hook's own cwd.
|
package/dist/customer-notes.js
CHANGED
|
@@ -94,9 +94,79 @@ const NOTE_COLS = `
|
|
|
94
94
|
function buildNoteContent(customer, note) {
|
|
95
95
|
return `${customer}\n\n${note}`;
|
|
96
96
|
}
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
97
|
+
// Preflight the supersede target BEFORE inserting the new row (so the new
|
|
98
|
+
// autoincrement id can never be its own supersede target); read the
|
|
99
|
+
// predecessor version in the same SELECT for server-derived versioning.
|
|
100
|
+
// Mirrors saveProjectBrief / saveSkill (codex P1 2026-05-28).
|
|
101
|
+
function preflightNoteSupersede(db, tenantId, supersedesId) {
|
|
102
|
+
// SAFETY: SELECT projects exactly status, version; .get() returns that
|
|
103
|
+
// shape for the matching row, or undefined when no note/tenant pair matches.
|
|
104
|
+
const pred = db.prepare(`SELECT status, version FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
|
|
105
|
+
if (!pred) {
|
|
106
|
+
throw new NotFoundError(`saveCustomerNote: note ${supersedesId} to supersede not found for tenant ${tenantId}`);
|
|
107
|
+
}
|
|
108
|
+
if (pred.status !== 'active') {
|
|
109
|
+
throw new ConflictError(`saveCustomerNote: note ${supersedesId} is not active (status='${pred.status}'); only active notes can be superseded.`);
|
|
110
|
+
}
|
|
111
|
+
return pred.version + 1;
|
|
112
|
+
}
|
|
113
|
+
function insertNoteRow(db, memoryId, w, version) {
|
|
114
|
+
const result = db.prepare(`
|
|
115
|
+
INSERT INTO customer_notes(
|
|
116
|
+
memory_id, tenant_id, customer, note, version,
|
|
117
|
+
status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
118
|
+
) VALUES (?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
119
|
+
`).run(memoryId, w.tenantId, w.customer, w.note, version, w.changeSummary, w.now);
|
|
120
|
+
return Number(result.lastInsertRowid ?? 0);
|
|
121
|
+
}
|
|
122
|
+
function supersedeNoteRow(db, w, supersedesId, noteId, version) {
|
|
123
|
+
const sup = db.prepare(`
|
|
124
|
+
UPDATE customer_notes
|
|
125
|
+
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
126
|
+
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
127
|
+
`).run(noteId, w.now, supersedesId, w.tenantId, noteId);
|
|
128
|
+
if (sup.changes === 0) {
|
|
129
|
+
throw new ConflictError(`saveCustomerNote: note ${supersedesId} could not be superseded (no longer active or self-reference).`);
|
|
130
|
+
}
|
|
131
|
+
appendAuditEvent(db, {
|
|
132
|
+
tenantId: w.tenantId,
|
|
133
|
+
actor: w.actor,
|
|
134
|
+
op: 'customer_note_supersede',
|
|
135
|
+
targetId: String(supersedesId),
|
|
136
|
+
metadata: {
|
|
137
|
+
note_id: supersedesId,
|
|
138
|
+
superseded_by: noteId,
|
|
139
|
+
new_version: version,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
|
|
144
|
+
function writeNoteRow(db, memoryId, w) {
|
|
145
|
+
const version = w.supersedesId !== undefined ? preflightNoteSupersede(db, w.tenantId, w.supersedesId) : 1;
|
|
146
|
+
const noteId = insertNoteRow(db, memoryId, w, version);
|
|
147
|
+
if (w.supersedesId !== undefined)
|
|
148
|
+
supersedeNoteRow(db, w, w.supersedesId, noteId, version);
|
|
149
|
+
// SAFETY: SELECT ${NOTE_COLS} projects exactly the CustomerNoteRow columns;
|
|
150
|
+
// .get() returns that row, or undefined only if the just-inserted id can't
|
|
151
|
+
// be found.
|
|
152
|
+
const row = db.prepare(`SELECT ${NOTE_COLS} FROM customer_notes WHERE id = ?`)
|
|
153
|
+
.get(noteId);
|
|
154
|
+
if (!row)
|
|
155
|
+
throw new Error('saveCustomerNote: failed to reload saved note row');
|
|
156
|
+
// GDPR-light metadata: ids + flags only, no note text.
|
|
157
|
+
appendAuditEvent(db, {
|
|
158
|
+
tenantId: w.tenantId,
|
|
159
|
+
actor: w.actor,
|
|
160
|
+
op: 'customer_note_create',
|
|
161
|
+
targetId: String(noteId),
|
|
162
|
+
metadata: {
|
|
163
|
+
note_id: noteId,
|
|
164
|
+
customer: w.customer,
|
|
165
|
+
version,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
return row;
|
|
169
|
+
}
|
|
100
170
|
/**
|
|
101
171
|
* Create a customer_note (or a new version that supersedes an existing one). Writes
|
|
102
172
|
* the memory mirror + the customer_notes row atomically inside writeEntry's SAVEPOINT.
|
|
@@ -125,75 +195,14 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
125
195
|
baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
|
|
126
196
|
tenantId,
|
|
127
197
|
});
|
|
198
|
+
const w = {
|
|
199
|
+
tenantId, actor, customer, note: opts.note, changeSummary, supersedesId: opts.supersedesNoteId, now,
|
|
200
|
+
};
|
|
128
201
|
let savedRow;
|
|
129
202
|
writeEntry(hippoRoot, mem, {
|
|
130
203
|
actor,
|
|
131
204
|
afterWrite: (db, memoryId) => {
|
|
132
|
-
|
|
133
|
-
// autoincrement id can never be its own supersede target); read the
|
|
134
|
-
// predecessor version in the same SELECT for server-derived versioning.
|
|
135
|
-
// Mirrors saveProjectBrief / saveSkill (codex P1 2026-05-28).
|
|
136
|
-
let version = 1;
|
|
137
|
-
if (opts.supersedesNoteId !== undefined) {
|
|
138
|
-
// SAFETY: SELECT projects exactly status, version; .get() returns that
|
|
139
|
-
// shape for the matching row, or undefined when no note/tenant pair matches.
|
|
140
|
-
const pred = db.prepare(`SELECT status, version FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesNoteId, tenantId);
|
|
141
|
-
if (!pred) {
|
|
142
|
-
throw new NotFoundError(`saveCustomerNote: note ${opts.supersedesNoteId} to supersede not found for tenant ${tenantId}`);
|
|
143
|
-
}
|
|
144
|
-
if (pred.status !== 'active') {
|
|
145
|
-
throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} is not active (status='${pred.status}'); only active notes can be superseded.`);
|
|
146
|
-
}
|
|
147
|
-
version = pred.version + 1;
|
|
148
|
-
}
|
|
149
|
-
const result = db.prepare(`
|
|
150
|
-
INSERT INTO customer_notes(
|
|
151
|
-
memory_id, tenant_id, customer, note, version,
|
|
152
|
-
status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
153
|
-
) VALUES (?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
154
|
-
`).run(memoryId, tenantId, customer, opts.note, version, changeSummary, now);
|
|
155
|
-
const noteId = Number(result.lastInsertRowid ?? 0);
|
|
156
|
-
if (opts.supersedesNoteId !== undefined) {
|
|
157
|
-
const sup = db.prepare(`
|
|
158
|
-
UPDATE customer_notes
|
|
159
|
-
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
160
|
-
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
161
|
-
`).run(noteId, now, opts.supersedesNoteId, tenantId, noteId);
|
|
162
|
-
if (sup.changes === 0) {
|
|
163
|
-
throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} could not be superseded (no longer active or self-reference).`);
|
|
164
|
-
}
|
|
165
|
-
appendAuditEvent(db, {
|
|
166
|
-
tenantId,
|
|
167
|
-
actor,
|
|
168
|
-
op: 'customer_note_supersede',
|
|
169
|
-
targetId: String(opts.supersedesNoteId),
|
|
170
|
-
metadata: {
|
|
171
|
-
note_id: opts.supersedesNoteId,
|
|
172
|
-
superseded_by: noteId,
|
|
173
|
-
new_version: version,
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
// SAFETY: SELECT ${NOTE_COLS} projects exactly the CustomerNoteRow columns;
|
|
178
|
-
// .get() returns that row, or undefined only if the just-inserted id can't
|
|
179
|
-
// be found.
|
|
180
|
-
const row = db.prepare(`SELECT ${NOTE_COLS} FROM customer_notes WHERE id = ?`)
|
|
181
|
-
.get(noteId);
|
|
182
|
-
if (!row)
|
|
183
|
-
throw new Error('saveCustomerNote: failed to reload saved note row');
|
|
184
|
-
savedRow = row;
|
|
185
|
-
// GDPR-light metadata: ids + flags only, no note text.
|
|
186
|
-
appendAuditEvent(db, {
|
|
187
|
-
tenantId,
|
|
188
|
-
actor,
|
|
189
|
-
op: 'customer_note_create',
|
|
190
|
-
targetId: String(noteId),
|
|
191
|
-
metadata: {
|
|
192
|
-
note_id: noteId,
|
|
193
|
-
customer,
|
|
194
|
-
version,
|
|
195
|
-
},
|
|
196
|
-
});
|
|
205
|
+
savedRow = writeNoteRow(db, memoryId, w);
|
|
197
206
|
},
|
|
198
207
|
afterCommit: () => markGraphDirty(hippoRoot, tenantId, mem.id),
|
|
199
208
|
});
|