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/projects.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// The `hippo projects` verb: list a store's project names, fold an old worktree name into its repo, repair
|
|
1
|
+
// The `hippo projects` verb: list a store's project names, fold an old worktree name into its repo, repair old tags in one pass.
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { execFileSync } from 'child_process';
|
|
4
4
|
import { closeHippoDb, openHippoDb } from '../db.js';
|
|
5
|
-
import { listProjects, mergeProjects,
|
|
5
|
+
import { listProjects, mergeProjects, repairProjects } from '../project-merge.js';
|
|
6
6
|
import { resolveTenantId } from '../tenant.js';
|
|
7
7
|
import { resolveAuthRoot } from './shared.js';
|
|
8
8
|
import { printError } from './output.js';
|
|
@@ -64,13 +64,16 @@ export function cmdProjects(hippoRoot, args, flags) {
|
|
|
64
64
|
return;
|
|
65
65
|
}
|
|
66
66
|
if (sub === 'repair') {
|
|
67
|
-
const r =
|
|
67
|
+
const r = repairProjects(db, root, { tenantId, dryRun: !apply });
|
|
68
68
|
if (flags['json']) {
|
|
69
69
|
console.log(JSON.stringify(r, null, 2));
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
|
-
console.log(`${apply ? 'Repaired' : 'Dry run: would repair'}
|
|
73
|
-
console.log(` ${r.
|
|
72
|
+
console.log(`${apply ? 'Repaired' : 'Dry run: would repair'} ${root}:`);
|
|
73
|
+
console.log(` ${count(r.copies.length, 'imported note copy', 'imported note copies')} under the wrong project set aside (the note stays under its own, or user-global)`);
|
|
74
|
+
for (const f of r.folds)
|
|
75
|
+
console.log(` ${f.from} folded into ${f.into} (its sessions' folders resolve there now)`);
|
|
76
|
+
console.log(` sleep's user-global merged rows: ${r.toProject.length} re-tagged to their parents' project`);
|
|
74
77
|
console.log(` ${r.setAside.length} set aside (parents in two projects; sleep re-merges them per project)`);
|
|
75
78
|
console.log(` ${r.untraced.length} left as they are (no parent left to show which project; check them with hippo inspect <id>)`);
|
|
76
79
|
console.log(apply ? `Backup: ${r.backup}\nEvery id is in the audit log: hippo audit list --op project_repair` : 'Nothing written. Add --apply to run it.');
|
package/dist/cli/recall.js
CHANGED
|
@@ -441,9 +441,7 @@ function auditRecall(hippoRoot, globalRoot, query, fit) {
|
|
|
441
441
|
/** Traces the recall, books retrieval for a non-empty list, then prints JSON or the fitted block. */
|
|
442
442
|
function writeRecallResult(hippoRoot, query, o, fit, localIndex) {
|
|
443
443
|
const { tenantId, sessionId, showWhy, asJson, includeContinuity } = o;
|
|
444
|
-
const { results, recallText
|
|
445
|
-
const { activeSnapshot, sessionHandoff, recentSessionEvents } = fit.continuity;
|
|
446
|
-
const { anchoring: cmdAnchoringHint, availability: cmdAvailabilityHint, summary: cmdSuppressionSummary } = fit.hints;
|
|
444
|
+
const { results, recallText } = fit;
|
|
447
445
|
// The token ledger books the block this recall prints, on whichever exit it takes.
|
|
448
446
|
const emit = (text) => {
|
|
449
447
|
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
@@ -466,59 +464,46 @@ function writeRecallResult(hippoRoot, query, o, fit, localIndex) {
|
|
|
466
464
|
explainMode: showWhy,
|
|
467
465
|
results: [],
|
|
468
466
|
});
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
query,
|
|
472
|
-
results: [],
|
|
473
|
-
total: 0,
|
|
474
|
-
suppressionSummary: cmdSuppressionSummary,
|
|
475
|
-
// HTTP and MCP surface the hint whatever matched, so the zero-result JSON keeps it for parity.
|
|
476
|
-
...(cmdPlanningFallacyHint ? { planningFallacyHint: cmdPlanningFallacyHint } : {}),
|
|
477
|
-
...(cmdPlanningFallacyWatching ? { planningFallacyWatching: cmdPlanningFallacyWatching } : {}),
|
|
478
|
-
...(cmdAnchoringHint ? { anchoringHint: cmdAnchoringHint } : {}),
|
|
479
|
-
...(cmdAvailabilityHint ? { availabilityHint: cmdAvailabilityHint } : {}),
|
|
480
|
-
};
|
|
481
|
-
if (includeContinuity) {
|
|
482
|
-
out.continuity = {
|
|
483
|
-
activeSnapshot,
|
|
484
|
-
sessionHandoff,
|
|
485
|
-
recentSessionEvents,
|
|
486
|
-
};
|
|
487
|
-
out.continuityTokens = continuityTokens;
|
|
488
|
-
}
|
|
489
|
-
emit(JSON.stringify(out));
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
emit(recallText);
|
|
467
|
+
// HTTP and MCP surface the hint whatever matched, so the zero-result JSON keeps it for parity.
|
|
468
|
+
emit(asJson ? JSON.stringify({ query, results: [], total: 0, ...recallJsonTail(fit, includeContinuity) }) : recallText);
|
|
493
469
|
return;
|
|
494
470
|
}
|
|
495
471
|
recordRetrieval(hippoRoot, query, o, results, localIndex);
|
|
496
472
|
if (asJson) {
|
|
497
473
|
const output = results.map((r) => recallJsonRow(r, query, showWhy, o.primaryIsGlobal || (isInitialized(o.globalRoot) && !localIndex.entries[r.entry.id])));
|
|
498
|
-
|
|
474
|
+
emit(JSON.stringify({
|
|
499
475
|
query,
|
|
500
476
|
budget: o.budget,
|
|
501
477
|
results: output,
|
|
502
478
|
total: output.length,
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
...(cmdPlanningFallacyWatching ? { planningFallacyWatching: cmdPlanningFallacyWatching } : {}),
|
|
506
|
-
...(cmdAnchoringHint ? { anchoringHint: cmdAnchoringHint } : {}),
|
|
507
|
-
...(cmdAvailabilityHint ? { availabilityHint: cmdAvailabilityHint } : {}),
|
|
508
|
-
};
|
|
509
|
-
if (includeContinuity) {
|
|
510
|
-
jsonOut.continuity = {
|
|
511
|
-
activeSnapshot,
|
|
512
|
-
sessionHandoff,
|
|
513
|
-
recentSessionEvents,
|
|
514
|
-
};
|
|
515
|
-
jsonOut.continuityTokens = continuityTokens;
|
|
516
|
-
}
|
|
517
|
-
emit(JSON.stringify(jsonOut));
|
|
479
|
+
...recallJsonTail(fit, includeContinuity),
|
|
480
|
+
}));
|
|
518
481
|
return;
|
|
519
482
|
}
|
|
520
483
|
emit(recallText);
|
|
521
484
|
}
|
|
485
|
+
/** The JSON keys after the result list: suppression summary, any bias hints, then continuity when asked for. */
|
|
486
|
+
function recallJsonTail(fit, includeContinuity) {
|
|
487
|
+
const { cmdPlanningFallacyHint, cmdPlanningFallacyWatching, continuityTokens } = fit;
|
|
488
|
+
const { activeSnapshot, sessionHandoff, recentSessionEvents } = fit.continuity;
|
|
489
|
+
const { anchoring: cmdAnchoringHint, availability: cmdAvailabilityHint, summary: cmdSuppressionSummary } = fit.hints;
|
|
490
|
+
const tail = {
|
|
491
|
+
suppressionSummary: cmdSuppressionSummary,
|
|
492
|
+
...(cmdPlanningFallacyHint ? { planningFallacyHint: cmdPlanningFallacyHint } : {}),
|
|
493
|
+
...(cmdPlanningFallacyWatching ? { planningFallacyWatching: cmdPlanningFallacyWatching } : {}),
|
|
494
|
+
...(cmdAnchoringHint ? { anchoringHint: cmdAnchoringHint } : {}),
|
|
495
|
+
...(cmdAvailabilityHint ? { availabilityHint: cmdAvailabilityHint } : {}),
|
|
496
|
+
};
|
|
497
|
+
if (includeContinuity) {
|
|
498
|
+
tail.continuity = {
|
|
499
|
+
activeSnapshot,
|
|
500
|
+
sessionHandoff,
|
|
501
|
+
recentSessionEvents,
|
|
502
|
+
};
|
|
503
|
+
tail.continuityTokens = continuityTokens;
|
|
504
|
+
}
|
|
505
|
+
return tail;
|
|
506
|
+
}
|
|
522
507
|
/** Strengthens the returned rows and persists last_retrieval_ids and last_trace_id in one saveIndex call. */
|
|
523
508
|
function recordRetrieval(hippoRoot, query, o, results, localIndex) {
|
|
524
509
|
const { globalRoot, tenantId, sessionId, showWhy } = o;
|
package/dist/cli/remember.js
CHANGED
|
@@ -41,17 +41,8 @@ function rememberTags(flags, cwd) {
|
|
|
41
41
|
}
|
|
42
42
|
return { requested, all };
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const targetRoot = useGlobal ? getGlobalRoot() : hippoRoot;
|
|
47
|
-
if (useGlobal) {
|
|
48
|
-
initGlobal();
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
requireInit(hippoRoot);
|
|
52
|
-
}
|
|
53
|
-
const { requested: requestedTags, all: allTags } = rememberTags(flags, process.cwd());
|
|
54
|
-
// Resolve explicit confidence flag (default: 'verified' for manual remember)
|
|
44
|
+
// Resolve explicit confidence flag (default: 'verified' for manual remember)
|
|
45
|
+
function rememberConfidence(flags) {
|
|
55
46
|
let confidence = 'verified';
|
|
56
47
|
if (flags['observed'])
|
|
57
48
|
confidence = 'observed';
|
|
@@ -59,10 +50,9 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
59
50
|
confidence = 'inferred';
|
|
60
51
|
if (flags['verified'])
|
|
61
52
|
confidence = 'verified';
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// A3 envelope flags
|
|
53
|
+
return confidence;
|
|
54
|
+
}
|
|
55
|
+
function parseKindFlag(flags) {
|
|
66
56
|
const kindFlagRaw = typeof flags['kind'] === 'string' ? flags['kind'] : undefined;
|
|
67
57
|
const kindFlag = kindFlagRaw === undefined ? undefined : kindFlagRaw.toLowerCase();
|
|
68
58
|
// CLI surface intentionally restricted: 'raw' is reserved for ingestion connectors
|
|
@@ -76,6 +66,11 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
76
66
|
printError(`(kind='raw' is reserved for ingestion connectors; kind='archived' is internal.)`);
|
|
77
67
|
process.exit(1);
|
|
78
68
|
}
|
|
69
|
+
return kindFlag;
|
|
70
|
+
}
|
|
71
|
+
// A3 envelope flags
|
|
72
|
+
function parseRememberEnvelope(flags) {
|
|
73
|
+
const kind = parseKindFlag(flags);
|
|
79
74
|
const ownerRaw = typeof flags['owner'] === 'string' ? flags['owner'] : null;
|
|
80
75
|
const ownerCheck = validateOwner(ownerRaw, { strict: isStrictOwnerEnv() });
|
|
81
76
|
if (!ownerCheck.ok) {
|
|
@@ -84,9 +79,26 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
84
79
|
}
|
|
85
80
|
if (ownerCheck.message)
|
|
86
81
|
printError(ownerCheck.message);
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
82
|
+
const owner = ownerCheck.value ?? null;
|
|
83
|
+
const artifactRef = typeof flags['artifact-ref'] === 'string' ? flags['artifact-ref'] : null;
|
|
84
|
+
const scope = typeof flags['scope'] === 'string' ? flags['scope'].trim() || null : null;
|
|
85
|
+
return { kind, owner, artifactRef, scope };
|
|
86
|
+
}
|
|
87
|
+
async function cmdRemember(hippoRoot, text, flags) {
|
|
88
|
+
const useGlobal = Boolean(flags['global']);
|
|
89
|
+
const targetRoot = useGlobal ? getGlobalRoot() : hippoRoot;
|
|
90
|
+
if (useGlobal) {
|
|
91
|
+
initGlobal();
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
requireInit(hippoRoot);
|
|
95
|
+
}
|
|
96
|
+
const { requested: requestedTags, all: allTags } = rememberTags(flags, process.cwd());
|
|
97
|
+
const confidence = rememberConfidence(flags);
|
|
98
|
+
// Schema fit needs the store, which the routed request has no access to, so it stays here.
|
|
99
|
+
const existing = loadAllEntries(targetRoot, resolveTenantId({}));
|
|
100
|
+
const schemaFit = computeSchemaFit(text, requestedTags, existing);
|
|
101
|
+
const envelope = parseRememberEnvelope(flags);
|
|
90
102
|
// A5 stub auth: stamp tenant_id from env (HIPPO_TENANT) so recall isolation
|
|
91
103
|
// can filter on this row. Default tenant 'default' for unauthenticated CLI.
|
|
92
104
|
const tenantId = resolveTenantId({});
|
|
@@ -98,33 +110,43 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
98
110
|
source: useGlobal ? 'cli-global' : 'cli',
|
|
99
111
|
confidence,
|
|
100
112
|
schema_fit: schemaFit,
|
|
101
|
-
kind:
|
|
102
|
-
scope:
|
|
103
|
-
owner:
|
|
104
|
-
artifact_ref:
|
|
113
|
+
kind: envelope.kind,
|
|
114
|
+
scope: envelope.scope,
|
|
115
|
+
owner: envelope.owner,
|
|
116
|
+
artifact_ref: envelope.artifactRef,
|
|
105
117
|
tenantId,
|
|
106
118
|
baseHalfLifeDays: rememberConfig.defaultHalfLifeDays,
|
|
107
119
|
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const salienceResult = computeSalience(text, entry.tags, existing, {
|
|
111
|
-
recentWindow: rememberConfig.salience.recentWindow,
|
|
112
|
-
overlapThreshold: rememberConfig.salience.overlapThreshold,
|
|
113
|
-
minContentLength: rememberConfig.salience.minContentLength,
|
|
114
|
-
maxRepeatErrors: rememberConfig.salience.maxRepeatErrors,
|
|
115
|
-
});
|
|
116
|
-
if (salienceResult.decision === 'skip') {
|
|
117
|
-
console.log(`Skipped (salience: ${salienceResult.reason}, score ${salienceResult.score.toFixed(2)})`);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (salienceResult.decision === 'start_weak') {
|
|
121
|
-
entry.strength = salienceResult.score;
|
|
122
|
-
entry.half_life_days = Math.max(1, entry.half_life_days * 0.5);
|
|
123
|
-
console.log(`Weakened (salience: ${salienceResult.reason}, strength ${salienceResult.score.toFixed(2)})`);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
120
|
+
if (!passesSalienceGate(entry, text, existing, rememberConfig, flags))
|
|
121
|
+
return;
|
|
126
122
|
writeEntry(targetRoot, entry);
|
|
127
123
|
updateStats(targetRoot, { remembered: 1 });
|
|
124
|
+
printRemembered(entry, useGlobal);
|
|
125
|
+
void embedMemory(targetRoot, entry);
|
|
126
|
+
await extractRememberFacts(targetRoot, entry, flags);
|
|
127
|
+
}
|
|
128
|
+
/** False when the gate skips the write; a start_weak verdict weakens `entry` in place. */
|
|
129
|
+
function passesSalienceGate(entry, text, existing, rememberConfig, flags) {
|
|
130
|
+
if (!rememberConfig.salience.enabled || Boolean(flags['pin']) || Boolean(flags['force']))
|
|
131
|
+
return true;
|
|
132
|
+
const salienceResult = computeSalience(text, entry.tags, existing, {
|
|
133
|
+
recentWindow: rememberConfig.salience.recentWindow,
|
|
134
|
+
overlapThreshold: rememberConfig.salience.overlapThreshold,
|
|
135
|
+
minContentLength: rememberConfig.salience.minContentLength,
|
|
136
|
+
maxRepeatErrors: rememberConfig.salience.maxRepeatErrors,
|
|
137
|
+
});
|
|
138
|
+
if (salienceResult.decision === 'skip') {
|
|
139
|
+
console.log(`Skipped (salience: ${salienceResult.reason}, score ${salienceResult.score.toFixed(2)})`);
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (salienceResult.decision === 'start_weak') {
|
|
143
|
+
entry.strength = salienceResult.score;
|
|
144
|
+
entry.half_life_days = Math.max(1, entry.half_life_days * 0.5);
|
|
145
|
+
console.log(`Weakened (salience: ${salienceResult.reason}, strength ${salienceResult.score.toFixed(2)})`);
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
function printRemembered(entry, useGlobal) {
|
|
128
150
|
const prefix = useGlobal ? '[global] ' : '';
|
|
129
151
|
console.log(`${prefix}Remembered [${entry.id}]`);
|
|
130
152
|
console.log(` Layer: ${entry.layer} | Strength: ${fmt(entry.strength)} | Half-life: ${entry.half_life_days}d | Confidence: ${entry.confidence}`);
|
|
@@ -134,7 +156,8 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
134
156
|
console.log(' Pinned (no decay)');
|
|
135
157
|
for (const w of vetSecrets(entry.content, entry.tags, false).warnings)
|
|
136
158
|
printError(`Warning: ${w}`);
|
|
137
|
-
|
|
159
|
+
}
|
|
160
|
+
async function extractRememberFacts(targetRoot, entry, flags) {
|
|
138
161
|
const config = loadConfig(targetRoot);
|
|
139
162
|
const shouldExtract = flags['extract'] || config.extraction.enabled === true;
|
|
140
163
|
const apiKey = envAnthropicApiKey() ?? '';
|
|
@@ -272,6 +295,18 @@ function cmdTrace(hippoRoot, id, flags) {
|
|
|
272
295
|
printError(`Memory not found: ${id}`);
|
|
273
296
|
process.exit(1);
|
|
274
297
|
}
|
|
298
|
+
const t = {
|
|
299
|
+
entry, id, sourceLabel,
|
|
300
|
+
...traceStats(entry),
|
|
301
|
+
...traceLineage(hippoRoot, globalRoot, entry, id, tenantId),
|
|
302
|
+
};
|
|
303
|
+
if (asJson) {
|
|
304
|
+
printTraceJson(t);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
printTraceText(t);
|
|
308
|
+
}
|
|
309
|
+
function traceStats(entry) {
|
|
275
310
|
const now = evalNow();
|
|
276
311
|
const strength = calculateStrength(entry, now);
|
|
277
312
|
const halfLife = entry.half_life_days;
|
|
@@ -285,6 +320,9 @@ function cmdTrace(hippoRoot, id, flags) {
|
|
|
285
320
|
const conf = confidenceLabel(entry, now).text;
|
|
286
321
|
// Projected strength: same decay curve, just push `now` out.
|
|
287
322
|
const projectedAt = (days) => calculateStrength(entry, new Date(now.getTime() + days * 86_400_000));
|
|
323
|
+
return { strength, halfLife, rewardFactor, effHalfLife, ageDays, sinceLast, facets, conf, projectedAt };
|
|
324
|
+
}
|
|
325
|
+
function traceLineage(hippoRoot, globalRoot, entry, id, tenantId) {
|
|
288
326
|
// Parents (consolidation lineage) — schema v9 field.
|
|
289
327
|
const parents = Array.isArray(entry.parents) ? entry.parents : [];
|
|
290
328
|
const parentPreviews = parents.map((pid) => {
|
|
@@ -297,35 +335,40 @@ function cmdTrace(hippoRoot, id, flags) {
|
|
|
297
335
|
...(isInitialized(globalRoot) ? listMemoryConflicts(globalRoot, 'open', tenantId) : []),
|
|
298
336
|
];
|
|
299
337
|
const myConflicts = allConflicts.filter((c) => c.memory_a_id === id || c.memory_b_id === id);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
338
|
+
return { parentPreviews, myConflicts };
|
|
339
|
+
}
|
|
340
|
+
function printTraceJson(t) {
|
|
341
|
+
const { entry, facets } = t;
|
|
342
|
+
console.log(JSON.stringify({
|
|
343
|
+
id: entry.id,
|
|
344
|
+
source: t.sourceLabel,
|
|
345
|
+
layer: entry.layer,
|
|
346
|
+
confidence: facets.tier,
|
|
347
|
+
aged_out: facets.agedOut,
|
|
348
|
+
pinned: entry.pinned,
|
|
349
|
+
starred: entry.starred,
|
|
350
|
+
tags: entry.tags,
|
|
351
|
+
content: entry.content,
|
|
352
|
+
created: entry.created,
|
|
353
|
+
age_days: t.ageDays,
|
|
354
|
+
last_retrieved: entry.last_retrieved,
|
|
355
|
+
days_since_last_retrieval: t.sinceLast,
|
|
356
|
+
retrieval_count: entry.retrieval_count,
|
|
357
|
+
strength_now: t.strength,
|
|
358
|
+
half_life_days: t.halfLife,
|
|
359
|
+
reward_factor: t.rewardFactor,
|
|
360
|
+
effective_half_life_days: t.effHalfLife,
|
|
361
|
+
projected_strength_30d: t.projectedAt(30),
|
|
362
|
+
projected_strength_90d: t.projectedAt(90),
|
|
363
|
+
outcome_positive: entry.outcome_positive,
|
|
364
|
+
outcome_negative: entry.outcome_negative,
|
|
365
|
+
parents: t.parentPreviews,
|
|
366
|
+
open_conflicts: t.myConflicts,
|
|
367
|
+
}, null, 2));
|
|
368
|
+
}
|
|
369
|
+
function printTraceText(t) {
|
|
370
|
+
const { entry, id, sourceLabel, conf, ageDays, strength, projectedAt, halfLife, rewardFactor, effHalfLife, sinceLast } = t;
|
|
371
|
+
const { parentPreviews, myConflicts } = t;
|
|
329
372
|
console.log(`Memory: ${entry.id} [${sourceLabel}]`);
|
|
330
373
|
console.log('='.repeat(50));
|
|
331
374
|
console.log(`Content: ${entry.content.replace(/\s+/g, ' ').slice(0, 160)}${entry.content.length > 160 ? '...' : ''}`);
|
|
@@ -12,7 +12,7 @@ import { readSessionScan, recordSessionDigest } from '../session-digest.js';
|
|
|
12
12
|
import { openHippoDb, closeHippoDb } from '../db.js';
|
|
13
13
|
import { captureToolFailure } from '../capture-error.js';
|
|
14
14
|
import { estimateTokens, isSubagentPayload, readApiCalls, recordRereads, recordTokenUse, } from '../token-ledger.js';
|
|
15
|
-
import { currentMachine,
|
|
15
|
+
import { currentMachine, importSessionFolder } from '../agent-memories/sync.js';
|
|
16
16
|
import { summaryLine } from '../agent-memories/report.js';
|
|
17
17
|
import { deriveOriginProject } from '../project-identity.js';
|
|
18
18
|
import { getGlobalRoot } from '../shared.js';
|
|
@@ -127,48 +127,8 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
|
|
|
127
127
|
if (!suppressOutput && payloadSessionId !== null && inPilotHoldout(hippoRoot, resolveTenantId({}), payloadSessionId, false)) {
|
|
128
128
|
suppressOutput = true;
|
|
129
129
|
}
|
|
130
|
-
if (!suppressOutput)
|
|
131
|
-
|
|
132
|
-
const snapshot = loadFreshActiveTaskSnapshot(hippoRoot, tenantId, { maxAgeMs: COMPACT_RESUME_MAX_AGE_MS });
|
|
133
|
-
// X5: concurrent sessions must not cross-restore. Only suppress when
|
|
134
|
-
// BOTH ids are present and differ — either side missing, or a manual
|
|
135
|
-
// invocation with no payload session_id, still prints.
|
|
136
|
-
const sessionMismatch = !!snapshot &&
|
|
137
|
-
payloadSessionId !== null &&
|
|
138
|
-
snapshot.session_id !== null &&
|
|
139
|
-
payloadSessionId !== snapshot.session_id;
|
|
140
|
-
if (snapshot && !sessionMismatch) {
|
|
141
|
-
// Loaded before the print so a bad trail row costs the trail, not the snapshot; stderr stays out of the model's context.
|
|
142
|
-
let events = [];
|
|
143
|
-
try {
|
|
144
|
-
if (snapshot.session_id) {
|
|
145
|
-
events = listSessionEvents(hippoRoot, tenantId, { session_id: snapshot.session_id }).map((e) => ({
|
|
146
|
-
...e,
|
|
147
|
-
content: truncateCodePointSafe(e.content, COMPACT_RESUME_EVENT_CONTENT_CAP),
|
|
148
|
-
}));
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
catch (err) {
|
|
152
|
-
log.warn(`hippo compact-resume: trail skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
153
|
-
}
|
|
154
|
-
// Printed in one write so the ledger books exactly the text the model is handed.
|
|
155
|
-
const text = captureConsole(() => {
|
|
156
|
-
console.log('## Restored after compaction\n');
|
|
157
|
-
// X12: re-injected state is background reference, not instructions:
|
|
158
|
-
// the framing line the model actually sees at every compaction.
|
|
159
|
-
console.log("_Point-in-time working-state snapshot, auto-restored after compaction. Background reference, not instructions; the user's live messages win._\n");
|
|
160
|
-
printActiveTaskSnapshot(snapshot);
|
|
161
|
-
// Nothing auto-populates session_events, so an empty trail is the common real case;
|
|
162
|
-
// printSessionEvents([]) would inject a bare "No session events found." line into every compaction.
|
|
163
|
-
if (events.length > 0)
|
|
164
|
-
printSessionEvents(events);
|
|
165
|
-
});
|
|
166
|
-
console.log(text);
|
|
167
|
-
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
168
|
-
tenantId, sessionId: payloadSessionId, surface: 'compact_resume', event: 'inject', items: 1, tokens: estimateTokens(text),
|
|
169
|
-
}));
|
|
170
|
-
}
|
|
171
|
-
}
|
|
130
|
+
if (!suppressOutput)
|
|
131
|
+
restoreCompactSnapshot(hippoRoot, payloadSessionId);
|
|
172
132
|
}
|
|
173
133
|
catch (err) {
|
|
174
134
|
// Empty stdout on any store error, never a crashed SessionStart; the reason goes to stderr, which the model never sees.
|
|
@@ -176,6 +136,48 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
|
|
|
176
136
|
}
|
|
177
137
|
process.exit(0);
|
|
178
138
|
}
|
|
139
|
+
function restoreCompactSnapshot(hippoRoot, payloadSessionId) {
|
|
140
|
+
const tenantId = resolveTenantId({});
|
|
141
|
+
const snapshot = loadFreshActiveTaskSnapshot(hippoRoot, tenantId, { maxAgeMs: COMPACT_RESUME_MAX_AGE_MS });
|
|
142
|
+
// X5: concurrent sessions must not cross-restore. Only suppress when
|
|
143
|
+
// BOTH ids are present and differ — either side missing, or a manual
|
|
144
|
+
// invocation with no payload session_id, still prints.
|
|
145
|
+
const sessionMismatch = !!snapshot &&
|
|
146
|
+
payloadSessionId !== null &&
|
|
147
|
+
snapshot.session_id !== null &&
|
|
148
|
+
payloadSessionId !== snapshot.session_id;
|
|
149
|
+
if (!snapshot || sessionMismatch)
|
|
150
|
+
return;
|
|
151
|
+
// Loaded before the print so a bad trail row costs the trail, not the snapshot; stderr stays out of the model's context.
|
|
152
|
+
let events = [];
|
|
153
|
+
try {
|
|
154
|
+
if (snapshot.session_id) {
|
|
155
|
+
events = listSessionEvents(hippoRoot, tenantId, { session_id: snapshot.session_id }).map((e) => ({
|
|
156
|
+
...e,
|
|
157
|
+
content: truncateCodePointSafe(e.content, COMPACT_RESUME_EVENT_CONTENT_CAP),
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
log.warn(`hippo compact-resume: trail skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
}
|
|
164
|
+
// Printed in one write so the ledger books exactly the text the model is handed.
|
|
165
|
+
const text = captureConsole(() => {
|
|
166
|
+
console.log('## Restored after compaction\n');
|
|
167
|
+
// X12: re-injected state is background reference, not instructions:
|
|
168
|
+
// the framing line the model actually sees at every compaction.
|
|
169
|
+
console.log("_Point-in-time working-state snapshot, auto-restored after compaction. Background reference, not instructions; the user's live messages win._\n");
|
|
170
|
+
printActiveTaskSnapshot(snapshot);
|
|
171
|
+
// Nothing auto-populates session_events, so an empty trail is the common real case;
|
|
172
|
+
// printSessionEvents([]) would inject a bare "No session events found." line into every compaction.
|
|
173
|
+
if (events.length > 0)
|
|
174
|
+
printSessionEvents(events);
|
|
175
|
+
});
|
|
176
|
+
console.log(text);
|
|
177
|
+
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
178
|
+
tenantId, sessionId: payloadSessionId, surface: 'compact_resume', event: 'inject', items: 1, tokens: estimateTokens(text),
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
179
181
|
/**
|
|
180
182
|
* SessionEnd entry point. Claude Code / OpenCode fire this on /exit while
|
|
181
183
|
* tearing down the TUI, which kills any child that is still running when
|
|
@@ -249,6 +251,42 @@ export async function cmdSessionEndWorker(hippoRoot, flags) {
|
|
|
249
251
|
flushRereadLog();
|
|
250
252
|
return;
|
|
251
253
|
}
|
|
254
|
+
await sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath);
|
|
255
|
+
flushRereadLog();
|
|
256
|
+
const digestLog = (message) => appendSessionEndCloseLog(closeLogFile, message);
|
|
257
|
+
const scan = transcriptPath ? readSessionScan(transcriptPath, digestLog) : null;
|
|
258
|
+
captureEndedSession(hippoRoot, store, flags, transcriptPath, scan);
|
|
259
|
+
recordSessionDigest(hippoRoot, scan, {
|
|
260
|
+
key: closeSessionId || path.basename(transcriptPath ?? '', '.jsonl'),
|
|
261
|
+
tenantId: resolveTenantId({}),
|
|
262
|
+
log: digestLog,
|
|
263
|
+
});
|
|
264
|
+
// DF1 T3: close the ending session's own active task snapshot AFTER
|
|
265
|
+
// sleep+capture complete — neither producer (runPreCompact,
|
|
266
|
+
// `hippo snapshot save`) runs inside session-end, so this can never
|
|
267
|
+
// destroy same-run work. Scoped to `--session-id`: a concurrent session's
|
|
268
|
+
// active snapshot is untouched (closeTaskSnapshotsForSession's own WHERE
|
|
269
|
+
// clause). Absent session id -> no-op plus one log line; session-end is
|
|
270
|
+
// not guaranteed to fire at all (crash, kill -9), so the freshness bound
|
|
271
|
+
// in loadFreshActiveTaskSnapshot is the backstop layer, not this close.
|
|
272
|
+
// Handoff write happens BEFORE the snapshot close below, while the
|
|
273
|
+
// snapshot writeSessionEndHandoff reads is still active.
|
|
274
|
+
if (closeSessionId)
|
|
275
|
+
writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile);
|
|
276
|
+
try {
|
|
277
|
+
if (closeSessionId) {
|
|
278
|
+
const closed = closeTaskSnapshotsForSession(store, resolveTenantId({}), closeSessionId);
|
|
279
|
+
appendSessionEndCloseLog(closeLogFile, `closed ${closed} active snapshot(s) for session ${closeSessionId}`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
appendSessionEndCloseLog(closeLogFile, 'skip: no session_id in SessionEnd payload, active snapshot left untouched');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
appendSessionEndCloseLog(closeLogFile, `snapshot close failed: ${err.message}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath) {
|
|
252
290
|
// Sleeping the global store from here would learn this folder's git commits into it; it has its own daily sleep.
|
|
253
291
|
if (isInitialized(hippoRoot)) {
|
|
254
292
|
try {
|
|
@@ -263,9 +301,8 @@ export async function cmdSessionEndWorker(hippoRoot, flags) {
|
|
|
263
301
|
appendSessionEndCloseLog(closeLogFile, 'skip sleep: this folder has no store of its own', { startFresh: true });
|
|
264
302
|
logSessionEndImport(closeLogFile, transcriptPath);
|
|
265
303
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const scan = transcriptPath ? readSessionScan(transcriptPath, digestLog) : null;
|
|
304
|
+
}
|
|
305
|
+
function captureEndedSession(hippoRoot, store, flags, transcriptPath, scan) {
|
|
269
306
|
try {
|
|
270
307
|
const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined;
|
|
271
308
|
// With no stdin of its own, capture would read this as a manual run and scan every project.
|
|
@@ -289,54 +326,27 @@ export async function cmdSessionEndWorker(hippoRoot, flags) {
|
|
|
289
326
|
catch {
|
|
290
327
|
// Same treatment — the failure line is already in the log.
|
|
291
328
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
tenantId: resolveTenantId({}),
|
|
295
|
-
log: digestLog,
|
|
296
|
-
});
|
|
297
|
-
// DF1 T3: close the ending session's own active task snapshot AFTER
|
|
298
|
-
// sleep+capture complete — neither producer (runPreCompact,
|
|
299
|
-
// `hippo snapshot save`) runs inside session-end, so this can never
|
|
300
|
-
// destroy same-run work. Scoped to `--session-id`: a concurrent session's
|
|
301
|
-
// active snapshot is untouched (closeTaskSnapshotsForSession's own WHERE
|
|
302
|
-
// clause). Absent session id -> no-op plus one log line; session-end is
|
|
303
|
-
// not guaranteed to fire at all (crash, kill -9), so the freshness bound
|
|
304
|
-
// in loadFreshActiveTaskSnapshot is the backstop layer, not this close.
|
|
305
|
-
// Handoff write happens BEFORE the snapshot close below, while the
|
|
306
|
-
// snapshot writeSessionEndHandoff reads is still active.
|
|
307
|
-
if (closeSessionId) {
|
|
308
|
-
try {
|
|
309
|
-
const tenantId = resolveTenantId({});
|
|
310
|
-
const ownSnapshot = loadActiveTaskSnapshot(store, tenantId)?.session_id === closeSessionId;
|
|
311
|
-
// A never-compacted session has no snapshot; read even when it has one, as another session's PreCompact can take the slot before the write.
|
|
312
|
-
const derived = transcriptPath
|
|
313
|
-
? transcriptWorkingState(transcriptPath, (message) => appendSessionEndCloseLog(closeLogFile, message))
|
|
314
|
-
: null;
|
|
315
|
-
if (!ownSnapshot && !derived) {
|
|
316
|
-
appendSessionEndCloseLog(closeLogFile, 'skip: no snapshot or transcript for session');
|
|
317
|
-
}
|
|
318
|
-
else {
|
|
319
|
-
const evidence = collectHandoffEvidence(process.cwd(), 'unknown');
|
|
320
|
-
const handoff = writeSessionEndHandoff(store, tenantId, closeSessionId, evidence, derived);
|
|
321
|
-
appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : `skip: kept the existing handoff for session ${closeSessionId}`);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
catch (err) {
|
|
325
|
-
// SAFETY: catch clauses bind unknown, but Node/V8 always throws an Error here.
|
|
326
|
-
appendSessionEndCloseLog(closeLogFile, `handoff write failed: ${err.message}`);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
+
}
|
|
330
|
+
function writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile) {
|
|
329
331
|
try {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
332
|
+
const tenantId = resolveTenantId({});
|
|
333
|
+
const ownSnapshot = loadActiveTaskSnapshot(store, tenantId)?.session_id === closeSessionId;
|
|
334
|
+
// A never-compacted session has no snapshot; read even when it has one, as another session's PreCompact can take the slot before the write.
|
|
335
|
+
const derived = transcriptPath
|
|
336
|
+
? transcriptWorkingState(transcriptPath, (message) => appendSessionEndCloseLog(closeLogFile, message))
|
|
337
|
+
: null;
|
|
338
|
+
if (!ownSnapshot && !derived) {
|
|
339
|
+
appendSessionEndCloseLog(closeLogFile, 'skip: no snapshot or transcript for session');
|
|
333
340
|
}
|
|
334
341
|
else {
|
|
335
|
-
|
|
342
|
+
const evidence = collectHandoffEvidence(process.cwd(), 'unknown');
|
|
343
|
+
const handoff = writeSessionEndHandoff(store, tenantId, closeSessionId, evidence, derived);
|
|
344
|
+
appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : `skip: kept the existing handoff for session ${closeSessionId}`);
|
|
336
345
|
}
|
|
337
346
|
}
|
|
338
347
|
catch (err) {
|
|
339
|
-
|
|
348
|
+
// SAFETY: catch clauses bind unknown, but Node/V8 always throws an Error here.
|
|
349
|
+
appendSessionEndCloseLog(closeLogFile, `handoff write failed: ${err.message}`);
|
|
340
350
|
}
|
|
341
351
|
}
|
|
342
352
|
/** Books the ending session's re-reads in each store its ledger rows can land in (project and global); returns the log lines. */
|
|
@@ -558,8 +568,8 @@ export async function handlePostCompact({ hippoRoot, flags }) {
|
|
|
558
568
|
stdinText: text,
|
|
559
569
|
logFile: logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag,
|
|
560
570
|
// Passed in, since capture.ts importing the sync would close an import cycle.
|
|
561
|
-
afterSave: (transcriptPath,
|
|
562
|
-
const report =
|
|
571
|
+
afterSave: (transcriptPath, cwd, log) => {
|
|
572
|
+
const report = importSessionFolder(store, transcriptPath, cwd, { machine: currentMachine(), busyWaitMs: COMPACTION_DB_WAIT_MS });
|
|
563
573
|
const summary = summaryLine(report);
|
|
564
574
|
if (summary !== null)
|
|
565
575
|
log(summary);
|