hippo-memory 1.52.8 → 1.52.9
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 +158 -98
- package/dist/api.d.ts +51 -18
- package/dist/api.js +121 -76
- package/dist/audit.d.ts +2 -1
- package/dist/audit.js +63 -0
- package/dist/capture.d.ts +37 -0
- package/dist/capture.js +111 -81
- package/dist/cli.js +627 -654
- package/dist/codex-patch.d.ts +12 -0
- package/dist/codex-patch.js +71 -0
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -4
- package/dist/connectors/slack/types.d.ts +0 -1
- package/dist/consolidate.js +85 -32
- package/dist/context-render.d.ts +36 -0
- package/dist/context-render.js +154 -0
- package/dist/dag.js +3 -2
- package/dist/db.js +6 -6
- package/dist/dedupe.d.ts +6 -6
- package/dist/dedupe.js +10 -9
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +35 -2
- package/dist/dormant.d.ts +4 -0
- package/dist/dormant.js +17 -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/graph-recall.d.ts +3 -1
- package/dist/graph-recall.js +5 -3
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +122 -27
- 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/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 +35 -30
- package/dist/store.d.ts +1 -0
- package/dist/store.js +4 -0
- 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
package/dist/dedupe.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Store-level deduplication. Scans for
|
|
3
|
-
*
|
|
2
|
+
* Store-level deduplication. Scans for memories with the same text apart
|
|
3
|
+
* from spacing, keeps the stronger copy (by strength + retrieval count),
|
|
4
4
|
* removes the rest.
|
|
5
5
|
*
|
|
6
6
|
* Extracted from cli.ts in Episode A (v1.11.3) so `api.sleep` can dedupe
|
|
@@ -25,6 +25,7 @@ import { loadAllEntries, deleteEntry } from './store.js';
|
|
|
25
25
|
import { compareEntryIdentity } from './compare.js';
|
|
26
26
|
import { canAutoDelete } from './memory.js';
|
|
27
27
|
import { derivationPartitionKey } from './recall-scope.js';
|
|
28
|
+
import { duplicateKey } from './same-text.js';
|
|
28
29
|
/** Quantization step for strength-tie comparisons. The historical 0.01
|
|
29
30
|
* epsilon (see `strengthBucket` below) applied via rounding instead of a
|
|
30
31
|
* raw abs-diff threshold, so the tiebreak is transitive. */
|
|
@@ -55,16 +56,15 @@ export function strengthBucket(strength) {
|
|
|
55
56
|
return Number.isFinite(s) ? Math.round(s / STRENGTH_TIE_EPSILON) : 0;
|
|
56
57
|
}
|
|
57
58
|
/**
|
|
58
|
-
* Scan the store for
|
|
59
|
-
*
|
|
60
|
-
*
|
|
59
|
+
* Scan the store for duplicates and remove the weaker copy: same text apart
|
|
60
|
+
* from spacing, since a near-duplicate can differ in a value (port, version,
|
|
61
|
+
* path, name), AND the same tenant: the scan is partitioned by
|
|
61
62
|
* tenantId, so byte-identical content in two tenants is never a duplicate
|
|
62
63
|
* pair (the tenant boundary is an isolation boundary; cross-tenant removal
|
|
63
64
|
* was the v1.32.0 known-issue data-loss bug).
|
|
64
|
-
* Keeps the one with higher strength (or more retrievals if tied).
|
|
65
|
+
* Keeps the one with higher strength (or more retrievals if tied). `threshold` is accepted for old callers and ignored.
|
|
65
66
|
*/
|
|
66
67
|
export function deduplicateStore(hippoRoot, options = {}) {
|
|
67
|
-
const threshold = options.threshold ?? 0.7;
|
|
68
68
|
const dryRun = options.dryRun ?? false;
|
|
69
69
|
// Only current distilled rows compete: raw rows are append-only (the delete
|
|
70
70
|
// trigger would abort sleep mid-loop) and superseded rows are history, as in consolidate.ts.
|
|
@@ -111,15 +111,16 @@ export function deduplicateStore(hippoRoot, options = {}) {
|
|
|
111
111
|
return retrievalDiff;
|
|
112
112
|
return compareEntryIdentity(a, b);
|
|
113
113
|
});
|
|
114
|
+
const texts = tenantEntries.map((e) => duplicateKey(e.content));
|
|
114
115
|
for (let i = 0; i < tenantEntries.length; i++) {
|
|
115
116
|
if (removed.has(tenantEntries[i].id))
|
|
116
117
|
continue;
|
|
117
118
|
for (let j = i + 1; j < tenantEntries.length; j++) {
|
|
118
119
|
if (removed.has(tenantEntries[j].id) || !canAutoDelete(tenantEntries[j]))
|
|
119
120
|
continue;
|
|
120
|
-
|
|
121
|
-
if (similarity <= threshold)
|
|
121
|
+
if (texts[j] !== texts[i])
|
|
122
122
|
continue;
|
|
123
|
+
const similarity = textOverlap(tenantEntries[i].content, tenantEntries[j].content);
|
|
123
124
|
removed.add(tenantEntries[j].id);
|
|
124
125
|
pairs.push({
|
|
125
126
|
kept: tenantEntries[i].id,
|
package/dist/doctor.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface DoctorReport {
|
|
|
19
19
|
/** Inputs for {@link runDoctor}; defaults come from the process. */
|
|
20
20
|
export interface DoctorOpts {
|
|
21
21
|
cwd?: string;
|
|
22
|
-
/** Home directory used to find agent configuration (~/.claude). */
|
|
22
|
+
/** Home directory used to find agent configuration (~/.claude, ~/.codex). */
|
|
23
23
|
home?: string;
|
|
24
24
|
version: string;
|
|
25
25
|
nodeVersion?: string;
|
package/dist/doctor.js
CHANGED
|
@@ -12,6 +12,7 @@ import { getGlobalRoot } from './shared.js';
|
|
|
12
12
|
import { isInitialized } from './store.js';
|
|
13
13
|
import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
|
|
14
14
|
import { isEmbeddingAvailable } from './embeddings.js';
|
|
15
|
+
import { CODEX_TRUST_LINE, codexHomeDir, isCodexPresent, isJsonObject } from './hooks.js';
|
|
15
16
|
/** Minimum Node.js version hippo supports (package.json engines). */
|
|
16
17
|
export const MIN_NODE = '22.16.0';
|
|
17
18
|
function versionAtLeast(actual, min) {
|
|
@@ -34,6 +35,29 @@ function readJson(file) {
|
|
|
34
35
|
return null;
|
|
35
36
|
}
|
|
36
37
|
}
|
|
38
|
+
// Trust lives in Codex's own config.toml rows; doctor reads only the hooks file and reminds.
|
|
39
|
+
function codexCheck(home) {
|
|
40
|
+
const file = path.join(codexHomeDir(home), 'hooks.json');
|
|
41
|
+
const parsed = readJson(file);
|
|
42
|
+
// Codex drops every hook in a hooks.json it cannot parse, hippo's included.
|
|
43
|
+
if (fs.existsSync(file) && !isJsonObject(parsed)) {
|
|
44
|
+
return { id: 'codex', status: 'warn', detail: "Codex's hooks.json is not a JSON object, so Codex runs no hook from it", fix: 'repair hooks.json, then run: hippo hook install codex' };
|
|
45
|
+
}
|
|
46
|
+
const text = JSON.stringify(parsed ?? '');
|
|
47
|
+
const codexHooks = [
|
|
48
|
+
['hippo context --pinned-only', 'per-prompt memory'],
|
|
49
|
+
['hippo compact-resume', 'resume after compaction'],
|
|
50
|
+
];
|
|
51
|
+
const missing = codexHooks.filter(([marker]) => !text.includes(marker)).map(([, what]) => what);
|
|
52
|
+
if (missing.length === 0)
|
|
53
|
+
return { id: 'codex', status: 'pass', detail: `Codex: hippo memory hooks installed. ${CODEX_TRUST_LINE}` };
|
|
54
|
+
return {
|
|
55
|
+
id: 'codex',
|
|
56
|
+
status: 'warn',
|
|
57
|
+
detail: missing.length === codexHooks.length ? "Codex found, but hippo's memory hooks are not installed" : `Codex: hippo hooks missing for ${missing.join(', ')}`,
|
|
58
|
+
fix: 'hippo hook install codex (then trust the hooks once in /hooks)',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
37
61
|
// Migration 46 creates failure_log; a read-only open no longer creates it on an older store.
|
|
38
62
|
const FAILURE_LOG_SCHEMA = 46;
|
|
39
63
|
/** The failed-tool-call count over the last 7 days, or why it could not be read. */
|
|
@@ -123,8 +147,15 @@ export function runDoctor(opts) {
|
|
|
123
147
|
const since = new Date(now.getTime() - 7 * 86_400_000).toISOString();
|
|
124
148
|
try {
|
|
125
149
|
// SAFETY: COUNT/SUM aggregate row.
|
|
126
|
-
const row = db.prepare(`SELECT COUNT(
|
|
127
|
-
|
|
150
|
+
const row = db.prepare(`SELECT COUNT(CASE WHEN event = 'inject' THEN 1 END) AS n,
|
|
151
|
+
COALESCE(SUM(CASE WHEN event = 'inject' THEN tokens END), 0) AS t,
|
|
152
|
+
COALESCE(SUM(CASE WHEN event = 'reread' THEN tokens END), 0) AS r
|
|
153
|
+
FROM token_ledger WHERE ts >= ?`).get(since);
|
|
154
|
+
checks.push({
|
|
155
|
+
id: 'tokens',
|
|
156
|
+
status: 'info',
|
|
157
|
+
detail: `${Number(row?.n ?? 0)} memory blocks sent to agents in 7 days, about ${Number(row?.t ?? 0)} tokens sent and ${Number(row?.r ?? 0)} re-read by later model calls (hippo tokens for detail)`,
|
|
158
|
+
});
|
|
128
159
|
}
|
|
129
160
|
catch {
|
|
130
161
|
checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
|
|
@@ -176,6 +207,8 @@ export function runDoctor(opts) {
|
|
|
176
207
|
else {
|
|
177
208
|
checks.push({ id: 'claude-code', status: 'info', detail: 'Claude Code not found; other agents can use hippo over MCP (hippo mcp)' });
|
|
178
209
|
}
|
|
210
|
+
if (isCodexPresent(home))
|
|
211
|
+
checks.push(codexCheck(home));
|
|
179
212
|
checks.push({ id: 'embeddings', status: 'info', detail: isEmbeddingAvailable() ? 'local embeddings available (hybrid search)' : 'embeddings not installed; recall uses BM25 (optional: hippo embed --help)' });
|
|
180
213
|
return { ok: !checks.some((c) => c.status === 'fail'), version: opts.version, store, checks };
|
|
181
214
|
}
|
package/dist/dormant.d.ts
CHANGED
|
@@ -71,6 +71,10 @@ export interface DormantSnapshot {
|
|
|
71
71
|
* has no dormant memory with that id (another tenant's id reads as absent).
|
|
72
72
|
*/
|
|
73
73
|
export declare function readDormantSnapshot(db: DatabaseSyncLike, tenantId: string, id: string): DormantSnapshot | null;
|
|
74
|
+
/** Every dormant memory of a tenant whose snapshot still reads back. */
|
|
75
|
+
export declare function listDormantSnapshots(db: DatabaseSyncLike, tenantId: string): DormantSnapshot[];
|
|
76
|
+
/** Put `entry` in place of a tenant's dormant memory `id`, keeping when and why that one went dormant. */
|
|
77
|
+
export declare function replaceDormantEntry(db: DatabaseSyncLike, tenantId: string, id: string, entry: MemoryEntry): void;
|
|
74
78
|
/** Whether a tenant has a dormant memory with this id (snapshot readable or not). */
|
|
75
79
|
export declare function hasDormantRow(db: DatabaseSyncLike, tenantId: string, id: string): boolean;
|
|
76
80
|
/** Delete a tenant's dormant memory. Returns false when there was none. */
|
package/dist/dormant.js
CHANGED
|
@@ -74,8 +74,23 @@ export function readDormantSnapshot(db, tenantId, id) {
|
|
|
74
74
|
// SAFETY: row's shape matches the seven columns named in the SELECT.
|
|
75
75
|
const row = db.prepare(`SELECT tenant_id, id, content, entry_json, reason, strength, dormant_at
|
|
76
76
|
FROM dormant_memories WHERE tenant_id = ? AND id = ?`).get(tenantId, id);
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
return row ? toSnapshot(row) : null;
|
|
78
|
+
}
|
|
79
|
+
/** Every dormant memory of a tenant whose snapshot still reads back. */
|
|
80
|
+
export function listDormantSnapshots(db, tenantId) {
|
|
81
|
+
// SAFETY: rows' shape matches the seven columns named in the SELECT.
|
|
82
|
+
const rows = db.prepare(`SELECT tenant_id, id, content, entry_json, reason, strength, dormant_at
|
|
83
|
+
FROM dormant_memories WHERE tenant_id = ?`).all(tenantId);
|
|
84
|
+
return rows.flatMap((row) => toSnapshot(row) ?? []);
|
|
85
|
+
}
|
|
86
|
+
function toSnapshot(row) {
|
|
87
|
+
const entry = parseSnapshot(row);
|
|
88
|
+
return entry ? { entry, reason: row.reason, strength: row.strength, dormantAt: row.dormant_at } : null;
|
|
89
|
+
}
|
|
90
|
+
/** Put `entry` in place of a tenant's dormant memory `id`, keeping when and why that one went dormant. */
|
|
91
|
+
export function replaceDormantEntry(db, tenantId, id, entry) {
|
|
92
|
+
db.prepare(`UPDATE dormant_memories SET id = ?, content = ?, entry_json = ? WHERE tenant_id = ? AND id = ?`)
|
|
93
|
+
.run(entry.id, entry.content, JSON.stringify(entry), tenantId, id);
|
|
79
94
|
}
|
|
80
95
|
/** Whether a tenant has a dormant memory with this id (snapshot readable or not). */
|
|
81
96
|
export function hasDormantRow(db, tenantId, id) {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* - API provider `id` is `${kind}:${model}`; switching to/from an API embedder
|
|
20
20
|
* (or a dimension change) flips the identity and triggers the existing
|
|
21
21
|
* reindex-on-change path.
|
|
22
|
-
* - `resolveEmbeddingProvider`
|
|
22
|
+
* - `resolveEmbeddingProvider` throws on an invalid config (unknown provider,
|
|
23
|
+
* bad apiBaseUrl); `embedMemory` turns that into a warning. `isAvailable()` is provider-aware
|
|
23
24
|
* (local -> dependency installed; api -> key present). `embed()` MAY throw on
|
|
24
25
|
* a hard transport/auth failure so a reindex can abort atomically; hot paths
|
|
25
26
|
* wrap it and fall back to BM25.
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* - API provider `id` is `${kind}:${model}`; switching to/from an API embedder
|
|
20
20
|
* (or a dimension change) flips the identity and triggers the existing
|
|
21
21
|
* reindex-on-change path.
|
|
22
|
-
* - `resolveEmbeddingProvider`
|
|
22
|
+
* - `resolveEmbeddingProvider` throws on an invalid config (unknown provider,
|
|
23
|
+
* bad apiBaseUrl); `embedMemory` turns that into a warning. `isAvailable()` is provider-aware
|
|
23
24
|
* (local -> dependency installed; api -> key present). `embed()` MAY throw on
|
|
24
25
|
* a hard transport/auth failure so a reindex can abort atomically; hot paths
|
|
25
26
|
* wrap it and fall back to BM25.
|
package/dist/embeddings.js
CHANGED
|
@@ -12,6 +12,7 @@ import { openHippoDb, closeHippoDb, getMeta, setMeta } from './db.js';
|
|
|
12
12
|
import { initializeParticle, savePhysicsState, loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
|
|
13
13
|
import { loadConfig } from './config.js';
|
|
14
14
|
import { resolveEmbeddingProvider } from './embedding-provider.js';
|
|
15
|
+
import { redactSecretsStrict } from './secret-detect.js';
|
|
15
16
|
// Use createRequire for synchronous module resolution check in ESM
|
|
16
17
|
const _require = createRequire(import.meta.url);
|
|
17
18
|
// Cached availability check
|
|
@@ -460,11 +461,29 @@ async function withEmbedLock(hippoRoot, fn) {
|
|
|
460
461
|
resolve();
|
|
461
462
|
}
|
|
462
463
|
}
|
|
464
|
+
// A bad key fails every write; one warning tells the user, N would bury the command's own output.
|
|
465
|
+
let _embedFailureWarned = false;
|
|
466
|
+
function warnEmbedFailureOnce(source, rawMessage) {
|
|
467
|
+
if (_embedFailureWarned)
|
|
468
|
+
return;
|
|
469
|
+
_embedFailureWarned = true;
|
|
470
|
+
// Strict scrub: this line can land in a hook log file, and an API may echo the key back in its error body.
|
|
471
|
+
const message = redactSecretsStrict(rawMessage).replace(/\s+/g, ' ').replace(/\.+$/, '');
|
|
472
|
+
console.error(`hippo: embedding failed (${source}): ${message}. Memories are stored without embeddings until this is fixed.`);
|
|
473
|
+
}
|
|
463
474
|
/**
|
|
464
475
|
* Embed a single memory entry and cache the result in the embedding index.
|
|
465
476
|
*/
|
|
466
477
|
export async function embedMemory(hippoRoot, entry, model) {
|
|
467
|
-
|
|
478
|
+
let provider;
|
|
479
|
+
try {
|
|
480
|
+
provider = resolveEmbeddingProvider(hippoRoot, { model });
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
// Callers fire and forget, so this must resolve: a bad config warns once instead of rejecting.
|
|
484
|
+
warnEmbedFailureOnce('config', err instanceof Error ? err.message : String(err));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
468
487
|
if (!provider.isAvailable())
|
|
469
488
|
return;
|
|
470
489
|
return withEmbedLock(hippoRoot, async () => {
|
|
@@ -513,8 +532,9 @@ export async function embedMemory(hippoRoot, entry, model) {
|
|
|
513
532
|
// Physics init is best-effort — don't break embedding
|
|
514
533
|
}
|
|
515
534
|
}
|
|
516
|
-
catch {
|
|
517
|
-
// Provider failure (API down / bad key). Best-effort: leave the index as-is.
|
|
535
|
+
catch (err) {
|
|
536
|
+
// Provider failure (API down / bad key). Best-effort: leave the index as-is, but say so once.
|
|
537
|
+
warnEmbedFailureOnce(provider.kind, err instanceof Error ? err.message : String(err));
|
|
518
538
|
}
|
|
519
539
|
}).catch((err) => {
|
|
520
540
|
console.error(`hippo: skipped embedding ${entry.id} (${err instanceof Error ? err.message : String(err)}); run 'hippo embed' to backfill`);
|
package/dist/extract.js
CHANGED
|
@@ -3,6 +3,7 @@ import { writeEntry } from './store.js';
|
|
|
3
3
|
import { loadConfig } from './config.js';
|
|
4
4
|
import { RejectedValueError } from './rejection.js';
|
|
5
5
|
import { redactSecrets } from './secret-detect.js';
|
|
6
|
+
import { neverAutoShareTags } from './shared.js';
|
|
6
7
|
function isJsonString(value) {
|
|
7
8
|
return typeof value === 'string';
|
|
8
9
|
}
|
|
@@ -81,7 +82,10 @@ export async function extractFacts(text, opts) {
|
|
|
81
82
|
}
|
|
82
83
|
const INHERITABLE_PREFIXES = ['conv:', 'session:', 'scope:', 'path:'];
|
|
83
84
|
export function storeExtractedFacts(hippoRoot, source, facts) {
|
|
84
|
-
const inheritedTags =
|
|
85
|
+
const inheritedTags = [
|
|
86
|
+
...source.tags.filter((t) => INHERITABLE_PREFIXES.some((p) => t.startsWith(p))),
|
|
87
|
+
...neverAutoShareTags([source]),
|
|
88
|
+
];
|
|
85
89
|
const entries = [];
|
|
86
90
|
let rejected = 0;
|
|
87
91
|
const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Kahneman 2003 inside-vs-outside view).
|
|
12
12
|
*
|
|
13
13
|
* Iteration signal: the `recall_autodebias_hint_no_class_match` audit op
|
|
14
|
-
* (emitted by
|
|
14
|
+
* (emitted by computePlanningFallacyOutput when a phrase matches but no class
|
|
15
15
|
* resolves) is the telemetry channel for deciding whether to add an
|
|
16
16
|
* embedding-based detector in J3.3.
|
|
17
17
|
*
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Kahneman 2003 inside-vs-outside view).
|
|
12
12
|
*
|
|
13
13
|
* Iteration signal: the `recall_autodebias_hint_no_class_match` audit op
|
|
14
|
-
* (emitted by
|
|
14
|
+
* (emitted by computePlanningFallacyOutput when a phrase matches but no class
|
|
15
15
|
* resolves) is the telemetry channel for deciding whether to add an
|
|
16
16
|
* embedding-based detector in J3.3.
|
|
17
17
|
*
|
package/dist/graph-recall.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type SearchResult } from './search.js';
|
|
1
|
+
import { type ResultCost, type SearchResult } from './search.js';
|
|
2
2
|
/** Hard cap on `--hops` (a higher value just walks more of a finite graph; this bounds
|
|
3
3
|
* worst-case work and keeps the flag honest). */
|
|
4
4
|
export declare const MAX_HOPS = 3;
|
|
@@ -23,6 +23,8 @@ export interface GraphExpandOpts {
|
|
|
23
23
|
asOf?: string;
|
|
24
24
|
/** Token budget for the augmented set (defaults to 4000, matching recall's default). */
|
|
25
25
|
budget?: number;
|
|
26
|
+
/** Budget cost per result; defaults to the memory text. */
|
|
27
|
+
cost?: ResultCost;
|
|
26
28
|
/** The recall --min-results floor: this many top base rows are kept regardless of
|
|
27
29
|
* budget, so graph expansion never violates the floor. Defaults to 1. */
|
|
28
30
|
minResults?: number;
|
package/dist/graph-recall.js
CHANGED
|
@@ -247,15 +247,17 @@ export function graphExpandRecall(baseResults, opts) {
|
|
|
247
247
|
// baseResults is score-ordered, so slice(0, N) is the top N.
|
|
248
248
|
const protectedCount = Math.min(Math.max(minResults, 1), baseResults.length);
|
|
249
249
|
const keep = new Set(baseResults.slice(0, protectedCount));
|
|
250
|
-
|
|
250
|
+
const price = opts.cost ?? ((r) => r.tokens);
|
|
251
|
+
let usedTokens = [...keep].reduce((s, r) => s + price(r), 0);
|
|
251
252
|
// T2 note: PLAIN stable score sort on purpose -- both input lists are
|
|
252
253
|
// deterministically ordered by this point, stability inherits that, and a
|
|
253
254
|
// base-vs-graph-hit tie keeps the BASE result first (the concat order),
|
|
254
255
|
// preserving pre-T2 semantics.
|
|
255
256
|
for (const r of [...baseResults.slice(protectedCount), ...allHits].sort((a, b) => b.score - a.score)) {
|
|
256
|
-
|
|
257
|
+
const tokens = price(r);
|
|
258
|
+
if (usedTokens + tokens > budget)
|
|
257
259
|
continue;
|
|
258
|
-
usedTokens +=
|
|
260
|
+
usedTokens += tokens;
|
|
259
261
|
keep.add(r);
|
|
260
262
|
}
|
|
261
263
|
// DISPLAY order: base order preserved (it may be MMR-diversified); each kept new hit
|
package/dist/hooks.d.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
|
|
17
17
|
* - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
|
|
18
18
|
* - 0.22.x: separate sleep + capture SessionEnd entries.
|
|
19
|
+
* Codex's hooks.json gets only two groups (per-prompt memory and
|
|
20
|
+
* compact-resume); see installCodexHooks.
|
|
19
21
|
*
|
|
20
22
|
* 2. Plugin install (OpenCode only). OpenCode does NOT share Claude Code's
|
|
21
23
|
* JSON-hook schema — its config has `additionalProperties: false` and no
|
|
@@ -28,7 +30,11 @@
|
|
|
28
30
|
* the installer + the migration that removes any pre-existing broken
|
|
29
31
|
* `hooks` block from opencode.json.
|
|
30
32
|
*/
|
|
31
|
-
|
|
33
|
+
import type { JsonValue, JsonObject } from './working-memory.js';
|
|
34
|
+
/** JSON-value plain-object check (excludes arrays and null), typeof-free for the same
|
|
35
|
+
* reason as isJsonString above. */
|
|
36
|
+
export declare function isJsonObject(value: JsonValue | undefined): value is JsonObject;
|
|
37
|
+
export type JsonHookTarget = 'claude-code' | 'codex';
|
|
32
38
|
export interface CodexWrapperPaths {
|
|
33
39
|
wrapperDir: string;
|
|
34
40
|
metadataPath: string;
|
|
@@ -93,6 +99,8 @@ export interface InstallResult {
|
|
|
93
99
|
migratedFromStop: boolean;
|
|
94
100
|
migratedLegacySessionEnd: boolean;
|
|
95
101
|
migratedSplitSessionEnd: boolean;
|
|
102
|
+
/** The file exists but is not JSON hippo can merge into, so it was left untouched. */
|
|
103
|
+
invalidJson: boolean;
|
|
96
104
|
}
|
|
97
105
|
export interface ToolDetection {
|
|
98
106
|
name: string;
|
|
@@ -141,6 +149,12 @@ declare const HIPPO_OPENCODE_PLUGIN_MARKER = "HIPPO_OPENCODE_PLUGIN_V1";
|
|
|
141
149
|
*/
|
|
142
150
|
export declare const OPENCODE_PLUGIN_SOURCE = "// HIPPO_OPENCODE_PLUGIN_V1\n// hippo-memory opencode plugin. DO NOT EDIT \u2014 regenerated on every\n// `hippo hook install opencode` from src/hooks.ts OPENCODE_PLUGIN_SOURCE\n// in https://github.com/kitfunso/hippo-memory. Local changes will be lost.\n\nexport const HippoPlugin = async ({ $ }) => {\n return {\n event: async ({ event }) => {\n // Defense in depth: opencode currently runs in Bun where $ is the shell\n // template helper. A non-Bun runtime would have $ as undefined; fail\n // closed instead of crashing the host session.\n if (typeof $ !== \"function\") return;\n try {\n if (event.type === \"session.idle\") {\n await $`hippo session-end`.quiet().nothrow();\n } else if (event.type === \"session.created\") {\n await $`hippo last-sleep`.quiet().nothrow();\n }\n } catch {\n // hippo CLI not on PATH or other failure \u2014 never crash the host session.\n }\n },\n };\n};\n";
|
|
143
151
|
export { HIPPO_OPENCODE_PLUGIN_MARKER };
|
|
152
|
+
/** Codex's config folder: $CODEX_HOME, else ~/.codex, as the Codex hooks docs describe. */
|
|
153
|
+
export declare function codexHomeDir(home?: string): string;
|
|
154
|
+
/** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
|
|
155
|
+
export declare function isCodexPresent(home?: string): boolean;
|
|
156
|
+
/** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
|
|
157
|
+
export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus the five most recent ones. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
|
|
144
158
|
/**
|
|
145
159
|
* Default log path consumed by `hippo last-sleep`. Shared fallback when
|
|
146
160
|
* a caller doesn't pass --path explicitly.
|
package/dist/hooks.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
|
|
17
17
|
* - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
|
|
18
18
|
* - 0.22.x: separate sleep + capture SessionEnd entries.
|
|
19
|
+
* Codex's hooks.json gets only two groups (per-prompt memory and
|
|
20
|
+
* compact-resume); see installCodexHooks.
|
|
19
21
|
*
|
|
20
22
|
* 2. Plugin install (OpenCode only). OpenCode does NOT share Claude Code's
|
|
21
23
|
* JSON-hook schema — its config has `additionalProperties: false` and no
|
|
@@ -40,7 +42,7 @@ function isJsonString(value) {
|
|
|
40
42
|
}
|
|
41
43
|
/** JSON-value plain-object check (excludes arrays and null), typeof-free for the same
|
|
42
44
|
* reason as isJsonString above. */
|
|
43
|
-
function isJsonObject(value) {
|
|
45
|
+
export function isJsonObject(value) {
|
|
44
46
|
return value !== undefined && value !== null && !Array.isArray(value) && value.constructor === Object;
|
|
45
47
|
}
|
|
46
48
|
const HIPPO_SLEEP_MARKER = 'hippo sleep';
|
|
@@ -121,6 +123,16 @@ export { HIPPO_OPENCODE_PLUGIN_MARKER };
|
|
|
121
123
|
function homeDir() {
|
|
122
124
|
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
123
125
|
}
|
|
126
|
+
/** Codex's config folder: $CODEX_HOME, else ~/.codex, as the Codex hooks docs describe. */
|
|
127
|
+
export function codexHomeDir(home = homeDir()) {
|
|
128
|
+
return process.env.CODEX_HOME || path.join(home, '.codex');
|
|
129
|
+
}
|
|
130
|
+
/** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
|
|
131
|
+
export function isCodexPresent(home = homeDir()) {
|
|
132
|
+
return fs.statSync(codexHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
|
|
133
|
+
}
|
|
134
|
+
/** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
|
|
135
|
+
export const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus the five most recent ones. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
|
|
124
136
|
/**
|
|
125
137
|
* Default log path consumed by `hippo last-sleep`. Shared fallback when
|
|
126
138
|
* a caller doesn't pass --path explicitly.
|
|
@@ -506,6 +518,12 @@ export function resolveJsonHookPaths(target) {
|
|
|
506
518
|
logFile: path.join(logsDir, 'claude-code-sleep.log'),
|
|
507
519
|
display: 'Claude Code',
|
|
508
520
|
};
|
|
521
|
+
case 'codex':
|
|
522
|
+
return {
|
|
523
|
+
settings: path.join(codexHomeDir(home), 'hooks.json'),
|
|
524
|
+
logFile: path.join(logsDir, 'codex-sleep.log'),
|
|
525
|
+
display: 'Codex',
|
|
526
|
+
};
|
|
509
527
|
}
|
|
510
528
|
}
|
|
511
529
|
function hookArrayContains(hookArray, marker) {
|
|
@@ -560,6 +578,59 @@ function hasLegacySplitSessionEnd(hookArray) {
|
|
|
560
578
|
const hasCapture = serialized.includes(HIPPO_CAPTURE_MARKER);
|
|
561
579
|
return (hasSleep || hasCapture) && !serialized.includes(HIPPO_SESSION_END_MARKER);
|
|
562
580
|
}
|
|
581
|
+
function nothingInstalled(target, settingsPath) {
|
|
582
|
+
return {
|
|
583
|
+
target,
|
|
584
|
+
settingsPath,
|
|
585
|
+
installedSessionEnd: false,
|
|
586
|
+
installedSessionStart: false,
|
|
587
|
+
installedUserPromptSubmit: false,
|
|
588
|
+
installedPreCompact: false,
|
|
589
|
+
installedCompactResume: false,
|
|
590
|
+
installedPostCompact: false,
|
|
591
|
+
installedCaptureError: false,
|
|
592
|
+
migratedPinnedInjectRecent: false,
|
|
593
|
+
migratedFromStop: false,
|
|
594
|
+
migratedLegacySessionEnd: false,
|
|
595
|
+
migratedSplitSessionEnd: false,
|
|
596
|
+
invalidJson: false,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/** A command hook with a Windows form: Codex runs hooks in PowerShell there, whose execution policy can block npm's hippo.ps1. */
|
|
600
|
+
function codexCommandHook(command, timeout) {
|
|
601
|
+
return { type: 'command', command, commandWindows: command.replace(/^hippo /, 'hippo.cmd '), timeout };
|
|
602
|
+
}
|
|
603
|
+
/** Codex keys trust to each hook's position and hash and re-asks for a changed one, so hippo only appends and never edits an entry. */
|
|
604
|
+
function installCodexHooks(settingsPath, settings) {
|
|
605
|
+
const result = nothingInstalled('codex', settingsPath);
|
|
606
|
+
if (!isJsonObject(settings))
|
|
607
|
+
return { ...result, invalidJson: true };
|
|
608
|
+
if (settings.hooks === undefined)
|
|
609
|
+
settings.hooks = {};
|
|
610
|
+
const hooks = settings.hooks;
|
|
611
|
+
const events = ['UserPromptSubmit', 'SessionStart'];
|
|
612
|
+
if (!isJsonObject(hooks) || events.some((e) => hooks[e] !== undefined && !Array.isArray(hooks[e]))) {
|
|
613
|
+
return { ...result, invalidJson: true };
|
|
614
|
+
}
|
|
615
|
+
const append = (event, marker, group) => {
|
|
616
|
+
const groups = hooks[event];
|
|
617
|
+
if (hookArrayContains(groups, marker))
|
|
618
|
+
return false;
|
|
619
|
+
hooks[event] = [...(Array.isArray(groups) ? groups : []), group];
|
|
620
|
+
return true;
|
|
621
|
+
};
|
|
622
|
+
const installedUserPromptSubmit = append('UserPromptSubmit', HIPPO_PINNED_INJECT_MARKER, {
|
|
623
|
+
hooks: [codexCommandHook(HIPPO_PINNED_INJECT_COMMAND, 5)],
|
|
624
|
+
});
|
|
625
|
+
const installedCompactResume = append('SessionStart', HIPPO_COMPACT_RESUME_MARKER, {
|
|
626
|
+
matcher: 'compact',
|
|
627
|
+
hooks: [codexCommandHook(HIPPO_COMPACT_RESUME_MARKER, 10)],
|
|
628
|
+
});
|
|
629
|
+
if (installedUserPromptSubmit || installedCompactResume) {
|
|
630
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
631
|
+
}
|
|
632
|
+
return { ...result, installedUserPromptSubmit, installedCompactResume };
|
|
633
|
+
}
|
|
563
634
|
export function installJsonHooks(target) {
|
|
564
635
|
const { settings: settingsPath, logFile } = resolveJsonHookPaths(target);
|
|
565
636
|
const dir = path.dirname(settingsPath);
|
|
@@ -571,23 +642,11 @@ export function installJsonHooks(target) {
|
|
|
571
642
|
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
572
643
|
}
|
|
573
644
|
catch {
|
|
574
|
-
return {
|
|
575
|
-
target,
|
|
576
|
-
settingsPath,
|
|
577
|
-
installedSessionEnd: false,
|
|
578
|
-
installedSessionStart: false,
|
|
579
|
-
installedUserPromptSubmit: false,
|
|
580
|
-
installedPreCompact: false,
|
|
581
|
-
installedCompactResume: false,
|
|
582
|
-
installedPostCompact: false,
|
|
583
|
-
installedCaptureError: false,
|
|
584
|
-
migratedPinnedInjectRecent: false,
|
|
585
|
-
migratedFromStop: false,
|
|
586
|
-
migratedLegacySessionEnd: false,
|
|
587
|
-
migratedSplitSessionEnd: false,
|
|
588
|
-
};
|
|
645
|
+
return { ...nothingInstalled(target, settingsPath), invalidJson: true };
|
|
589
646
|
}
|
|
590
647
|
}
|
|
648
|
+
if (target === 'codex')
|
|
649
|
+
return installCodexHooks(settingsPath, settings);
|
|
591
650
|
if (!settings.hooks)
|
|
592
651
|
settings.hooks = {};
|
|
593
652
|
// SAFETY: settings.hooks is either freshly initialised to {} on the line above, or an
|
|
@@ -777,8 +836,41 @@ export function installJsonHooks(target) {
|
|
|
777
836
|
migratedFromStop,
|
|
778
837
|
migratedLegacySessionEnd,
|
|
779
838
|
migratedSplitSessionEnd,
|
|
839
|
+
invalidJson: false,
|
|
780
840
|
};
|
|
781
841
|
}
|
|
842
|
+
/** The exact command hippo writes for each Codex event; uninstall removes only these handlers. */
|
|
843
|
+
const CODEX_HOOK_COMMANDS = [
|
|
844
|
+
['UserPromptSubmit', HIPPO_PINNED_INJECT_COMMAND],
|
|
845
|
+
['SessionStart', HIPPO_COMPACT_RESUME_MARKER],
|
|
846
|
+
];
|
|
847
|
+
/** A group loses only hippo's handlers and goes only once empty, so a user's hook beside or like hippo's stays. */
|
|
848
|
+
function uninstallCodexHooks(hooks) {
|
|
849
|
+
let changed = false;
|
|
850
|
+
for (const [event, command] of CODEX_HOOK_COMMANDS) {
|
|
851
|
+
const groups = hooks[event];
|
|
852
|
+
if (!Array.isArray(groups))
|
|
853
|
+
continue;
|
|
854
|
+
let removed = false;
|
|
855
|
+
const kept = groups.flatMap((group) => {
|
|
856
|
+
if (!isJsonObject(group) || !Array.isArray(group.hooks))
|
|
857
|
+
return [group];
|
|
858
|
+
const handlers = group.hooks.filter((h) => !(isJsonObject(h) && h.command === command));
|
|
859
|
+
if (handlers.length === group.hooks.length)
|
|
860
|
+
return [group];
|
|
861
|
+
removed = true;
|
|
862
|
+
return handlers.length > 0 ? [{ ...group, hooks: handlers }] : [];
|
|
863
|
+
});
|
|
864
|
+
if (!removed)
|
|
865
|
+
continue;
|
|
866
|
+
changed = true;
|
|
867
|
+
if (kept.length > 0)
|
|
868
|
+
hooks[event] = kept;
|
|
869
|
+
else
|
|
870
|
+
delete hooks[event];
|
|
871
|
+
}
|
|
872
|
+
return changed;
|
|
873
|
+
}
|
|
782
874
|
export function uninstallJsonHooks(target) {
|
|
783
875
|
const { settings: settingsPath } = resolveJsonHookPaths(target);
|
|
784
876
|
if (!fs.existsSync(settingsPath))
|
|
@@ -790,11 +882,19 @@ export function uninstallJsonHooks(target) {
|
|
|
790
882
|
catch {
|
|
791
883
|
return false;
|
|
792
884
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
if (!
|
|
885
|
+
if (!isJsonObject(settings) || !isJsonObject(settings.hooks))
|
|
886
|
+
return false;
|
|
887
|
+
const changed = target === 'codex' ? uninstallCodexHooks(settings.hooks) : uninstallClaudeCodeHooks(settings.hooks);
|
|
888
|
+
if (!changed)
|
|
797
889
|
return false;
|
|
890
|
+
if (Object.keys(settings.hooks).length === 0)
|
|
891
|
+
delete settings.hooks;
|
|
892
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
function uninstallClaudeCodeHooks(settingsHooks) {
|
|
896
|
+
// SAFETY: each event key below is re-validated with Array.isArray before use.
|
|
897
|
+
const hooks = settingsHooks;
|
|
798
898
|
let changed = false;
|
|
799
899
|
const markersByKey = {
|
|
800
900
|
SessionEnd: [HIPPO_SESSION_END_MARKER, HIPPO_SLEEP_MARKER, HIPPO_CAPTURE_MARKER],
|
|
@@ -819,12 +919,7 @@ export function uninstallJsonHooks(target) {
|
|
|
819
919
|
delete hooks[key];
|
|
820
920
|
}
|
|
821
921
|
}
|
|
822
|
-
|
|
823
|
-
return false;
|
|
824
|
-
if (Object.keys(hooks).length === 0)
|
|
825
|
-
delete settings.hooks;
|
|
826
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
827
|
-
return true;
|
|
922
|
+
return changed;
|
|
828
923
|
}
|
|
829
924
|
export function resolveOpencodePluginPath() {
|
|
830
925
|
return path.join(homeDir(), '.config', 'opencode', 'plugins', 'hippo.ts');
|
|
@@ -971,7 +1066,7 @@ export function detectInstalledTools() {
|
|
|
971
1066
|
{ name: 'claude-code', configDir: '~/.claude', detected: exists('.claude'), kind: 'json-hook' },
|
|
972
1067
|
{ name: 'opencode', configDir: '~/.config/opencode', detected: exists('.config', 'opencode'), kind: 'plugin', notes: 'installs a TS plugin at ~/.config/opencode/plugins/hippo.ts' },
|
|
973
1068
|
{ name: 'openclaw', configDir: '~/.openclaw', detected: exists('.openclaw'), kind: 'plugin', notes: 'install via `openclaw plugins install hippo-memory`' },
|
|
974
|
-
{ name: 'codex', configDir: '~/.codex', detected:
|
|
1069
|
+
{ name: 'codex', configDir: '~/.codex', detected: isCodexPresent(home), kind: 'wrapper', notes: 'memory hooks in hooks.json, and wraps the detected codex launcher for session-end consolidation' },
|
|
975
1070
|
{ name: 'cursor', configDir: '~/.cursor', detected: exists('.cursor'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
|
|
976
1071
|
{ name: 'pi', configDir: '~/.pi', detected: exists('.pi'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
|
|
977
1072
|
];
|
package/dist/importers.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as path from 'path';
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { createMemory, Layer } from './memory.js';
|
|
9
9
|
import { initStore, loadAllEntries, writeEntry } from './store.js';
|
|
10
|
-
import {
|
|
10
|
+
import { duplicateKey, storedTextKeys } from './same-text.js';
|
|
11
11
|
import { getGlobalRoot, initGlobal } from './shared.js';
|
|
12
12
|
import { remember, archiveRaw, isPrivateScope } from './api.js';
|
|
13
13
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
@@ -26,7 +26,7 @@ export function importEntries(chunks, source, tags, options) {
|
|
|
26
26
|
if (options.global) {
|
|
27
27
|
initGlobal();
|
|
28
28
|
}
|
|
29
|
-
const
|
|
29
|
+
const keys = storedTextKeys(loadAllEntries(targetRoot, options.global ? undefined : options.tenantId));
|
|
30
30
|
const allTags = [...new Set([...tags, ...(options.extraTags ?? [])])];
|
|
31
31
|
const baseHalfLifeDays = loadConfig(targetRoot).defaultHalfLifeDays;
|
|
32
32
|
let total = 0;
|
|
@@ -53,15 +53,8 @@ export function importEntries(chunks, source, tags, options) {
|
|
|
53
53
|
continue;
|
|
54
54
|
}
|
|
55
55
|
total++;
|
|
56
|
-
// Dedup check:
|
|
57
|
-
|
|
58
|
-
for (const existing_entry of existing) {
|
|
59
|
-
if (textOverlap(chunk, existing_entry.content) > 0.7) {
|
|
60
|
-
isDuplicate = true;
|
|
61
|
-
break;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if (isDuplicate) {
|
|
56
|
+
// Dedup check: skip only when the same text is already stored
|
|
57
|
+
if (keys.has(duplicateKey(chunk))) {
|
|
65
58
|
skipped++;
|
|
66
59
|
continue;
|
|
67
60
|
}
|
|
@@ -112,7 +105,7 @@ export function importEntries(chunks, source, tags, options) {
|
|
|
112
105
|
throw err;
|
|
113
106
|
}
|
|
114
107
|
// Add to existing so subsequent chunks dedup against freshly imported ones
|
|
115
|
-
|
|
108
|
+
keys.add(duplicateKey(chunk));
|
|
116
109
|
}
|
|
117
110
|
entries.push(entry);
|
|
118
111
|
imported++;
|