hippo-memory 1.55.0 → 1.57.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.
Files changed (77) hide show
  1. package/README.md +11 -0
  2. package/dist/api.d.ts +19 -9
  3. package/dist/api.js +112 -35
  4. package/dist/card-detail.d.ts +1 -1
  5. package/dist/card-detail.js +1 -1
  6. package/dist/cli/shared.d.ts +137 -0
  7. package/dist/cli/shared.js +830 -0
  8. package/dist/cli/sleep.d.ts +10 -0
  9. package/dist/cli/sleep.js +171 -0
  10. package/dist/cli.d.ts +0 -7
  11. package/dist/cli.js +313 -1806
  12. package/dist/config.d.ts +5 -0
  13. package/dist/config.js +21 -0
  14. package/dist/connectors/github/webhook.d.ts +19 -0
  15. package/dist/connectors/github/webhook.js +313 -0
  16. package/dist/connectors/slack/webhook.d.ts +22 -0
  17. package/dist/connectors/slack/webhook.js +203 -0
  18. package/dist/consolidate.js +3 -2
  19. package/dist/context-auto.d.ts +3 -0
  20. package/dist/context-auto.js +34 -0
  21. package/dist/customer-notes.js +2 -1
  22. package/dist/dashboard.js +2 -1
  23. package/dist/db.js +67 -1
  24. package/dist/decisions.js +2 -1
  25. package/dist/delivery-recorder.d.ts +127 -0
  26. package/dist/delivery-recorder.js +218 -0
  27. package/dist/eval-stats.d.ts +58 -0
  28. package/dist/eval-stats.js +111 -0
  29. package/dist/goals.d.ts +49 -25
  30. package/dist/goals.js +39 -22
  31. package/dist/graph-extract.js +1 -1
  32. package/dist/graph-recall.d.ts +1 -1
  33. package/dist/graph-recall.js +1 -1
  34. package/dist/graph.js +1 -1
  35. package/dist/hooks.d.ts +1 -3
  36. package/dist/hooks.js +2 -4
  37. package/dist/http-util.d.ts +31 -0
  38. package/dist/http-util.js +46 -0
  39. package/dist/incidents.js +2 -1
  40. package/dist/index.d.ts +5 -2
  41. package/dist/index.js +5 -2
  42. package/dist/mcp/server.js +173 -285
  43. package/dist/memory.d.ts +19 -0
  44. package/dist/memory.js +38 -0
  45. package/dist/policies.js +2 -1
  46. package/dist/predictions.js +2 -1
  47. package/dist/processes.js +2 -1
  48. package/dist/project-briefs.js +3 -1
  49. package/dist/prompt-recall.js +1 -1
  50. package/dist/recall-history.d.ts +5 -0
  51. package/dist/recall-history.js +9 -0
  52. package/dist/recall-pipeline.d.ts +101 -0
  53. package/dist/recall-pipeline.js +313 -0
  54. package/dist/recall-scope.d.ts +22 -0
  55. package/dist/recall-scope.js +27 -1
  56. package/dist/recall-trace.d.ts +69 -0
  57. package/dist/recall-trace.js +136 -0
  58. package/dist/search.d.ts +0 -20
  59. package/dist/search.js +2 -49
  60. package/dist/server.js +1901 -2384
  61. package/dist/skills.js +2 -1
  62. package/dist/store-cards.d.ts +53 -0
  63. package/dist/store-cards.js +512 -0
  64. package/dist/store.d.ts +2 -89
  65. package/dist/store.js +6 -562
  66. package/dist/tenant.d.ts +22 -0
  67. package/dist/tenant.js +26 -0
  68. package/dist/token-ledger.d.ts +2 -0
  69. package/dist/token-ledger.js +5 -0
  70. package/dist/tokenize.d.ts +2 -0
  71. package/dist/tokenize.js +8 -0
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  75. package/extensions/openclaw-plugin/package.json +1 -1
  76. package/openclaw.plugin.json +1 -1
  77. package/package.json +2 -1
@@ -0,0 +1,830 @@
1
+ // Helpers two or more CLI verbs use, split from cli.ts so a verb can move to its own file without importing cli.ts.
2
+ // This module must never import cli.ts.
3
+ import * as path from 'path';
4
+ import * as fs from 'fs';
5
+ import { execFileSync, execSync } from 'child_process';
6
+ import { installJsonHooks, CODEX_TRUST_LINE } from '../hooks.js';
7
+ import { confidenceLabel, computeSchemaFit, createMemory, Layer } from '../memory.js';
8
+ import { isInitialized, loadAllEntries, writeEntry, updateStats } from '../store.js';
9
+ import { RejectedValueError } from '../rejection.js';
10
+ import { explainMatch } from '../search.js';
11
+ import { embedMemory } from '../embeddings.js';
12
+ import { loadConfig } from '../config.js';
13
+ import { openHippoDb, closeHippoDb } from '../db.js';
14
+ import { hookPayloadSessionId, isSubagentPayload, recordTokenUse } from '../token-ledger.js';
15
+ import { isGitRepo, fetchGitLog, extractLessons, partitionLessons } from '../autolearn.js';
16
+ import { storedTextKeys, duplicateKey } from '../same-text.js';
17
+ import { importAtSessionEnd, currentMachine } from '../agent-memories/sync.js';
18
+ import { summaryLine } from '../agent-memories/report.js';
19
+ import { detectChurnStale, extractInvalidationTarget, invalidateMatching } from '../invalidation.js';
20
+ import { resolveProjectIdentity } from '../project-identity.js';
21
+ import { extractPathTags } from '../path-context.js';
22
+ import { getGlobalRoot, initGlobal } from '../shared.js';
23
+ import { DAILY_TASK_NAME, buildDailyRunnerCommand, buildSchtasksCreateArgs, buildWindowsTaskRun } from '../scheduler.js';
24
+ import { sanitizeLogMessage } from '../capture.js';
25
+ import { appendAuditEvent } from '../audit.js';
26
+ import { createHash } from 'node:crypto';
27
+ import * as client from '../client.js';
28
+ import { detectServer, removePidfileIfOwned } from '../server-detect.js';
29
+ import { resolveTenantId } from '../tenant.js';
30
+ import { snapshotText, sessionTrailText, handoffText } from '../context-render.js';
31
+ export function parseLimitFlag(value) {
32
+ if (!value)
33
+ return Infinity;
34
+ const parsed = parseInt(String(value), 10);
35
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
36
+ }
37
+ export function parseCountFlag(value) {
38
+ if (!value || value === true || Array.isArray(value))
39
+ return 0;
40
+ const parsed = parseInt(String(value), 10);
41
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : 0;
42
+ }
43
+ export function parseBudgetFlag(value, fallback) {
44
+ if (value === undefined)
45
+ return fallback;
46
+ // A value-less flag and a junk value are different typos; the --hops guard already splits them.
47
+ if (typeof value !== 'string') {
48
+ console.error('--budget requires an integer value (e.g. --budget 1500).');
49
+ process.exit(1);
50
+ }
51
+ // Number(), like the --hops guard: parseInt('12abc') is 12, silently accepting what this message rejects.
52
+ const parsed = Number(value);
53
+ if (!Number.isInteger(parsed) || parsed < 0) {
54
+ console.error(`Invalid --budget: "${value}". Must be a non-negative integer.`);
55
+ process.exit(1);
56
+ }
57
+ return parsed;
58
+ }
59
+ /**
60
+ * Emit an audit event against `hippoRoot`'s db. Opens its own short-lived
61
+ * connection so callers don't have to thread a db handle. Swallows all errors
62
+ * — audit must never crash a CLI command.
63
+ */
64
+ export function emitCliAudit(hippoRoot, op, targetId, metadata) {
65
+ try {
66
+ const db = openHippoDb(hippoRoot);
67
+ try {
68
+ appendAuditEvent(db, {
69
+ tenantId: resolveTenantId({}),
70
+ actor: 'cli',
71
+ op,
72
+ targetId,
73
+ metadata,
74
+ });
75
+ }
76
+ finally {
77
+ closeHippoDb(db);
78
+ }
79
+ }
80
+ catch {
81
+ // Audit is best-effort; surface failures only via missing rows.
82
+ }
83
+ }
84
+ export function requireInit(hippoRoot) {
85
+ if (!isInitialized(hippoRoot)) {
86
+ console.error(`No hippo store at ${hippoRoot} (searched ${process.cwd()} and its parents up to your home directory). Run \`hippo init\` first.`);
87
+ process.exit(1);
88
+ }
89
+ }
90
+ /** Runs detectChurnStale against every store this repo's memories can live in. */
91
+ export function runChurnStaleForRepo(hippoRoot, dryRun) {
92
+ const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8', windowsHide: true }).trim();
93
+ const projectName = resolveProjectIdentity(process.cwd()).name;
94
+ const globalRoot = getGlobalRoot();
95
+ const roots = globalRoot !== hippoRoot && isInitialized(globalRoot) ? [hippoRoot, globalRoot] : [hippoRoot];
96
+ const tenantId = resolveTenantId({});
97
+ return roots.map((root) => {
98
+ // One store failing must not abort sleep's later phases or skip the other store.
99
+ try {
100
+ return { root, result: detectChurnStale(root, repoRoot, { tenantId, projectName, dryRun }) };
101
+ }
102
+ catch (err) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ return { root, result: { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned: [], dryRun, preview: [], error: message } };
105
+ }
106
+ });
107
+ }
108
+ /**
109
+ * When HIPPO_REQUIRE_SERVER is set, the CLI must not silently fall back to
110
+ * direct DB mode — a missing server then masks a real misconfiguration (the
111
+ * configured HIPPO_API_KEY is also silently discarded on fallback). Throws a
112
+ * clear error then. It guards only the routed writes (remember, forget, archive,
113
+ * promote); every other command opens the store directly, knob or not.
114
+ */
115
+ function failIfServerRequired(reason) {
116
+ if (process.env['HIPPO_REQUIRE_SERVER']) {
117
+ throw new Error(`hippo: HIPPO_REQUIRE_SERVER is set but ${reason}. ` +
118
+ `Start \`hippo serve\`, or unset HIPPO_REQUIRE_SERVER to allow direct-mode fallback.`);
119
+ }
120
+ }
121
+ /**
122
+ * Run an HTTP-routed command if a `hippo serve` instance is detected for
123
+ * `hippoRoot`. Returns:
124
+ * - true if the HTTP path ran (success OR a structured server error that
125
+ * was already surfaced to stdout/stderr by `httpFn`),
126
+ * - false if no server was detected, or if the detected pidfile turned out
127
+ * to be stale (connection refused). On stale, the pidfile is removed
128
+ * if it still names that dead server (a newer one may have replaced
129
+ * it) and the caller should fall back to the direct path.
130
+ *
131
+ * Stale pidfiles must self-heal, not crash.
132
+ * When HIPPO_REQUIRE_SERVER is set, both fallback paths throw instead of
133
+ * returning false, so a missing server fails loudly rather than silently
134
+ * degrading to direct mode.
135
+ */
136
+ export async function runViaServerIfAvailable(hippoRoot, httpFn) {
137
+ const info = await detectServer(hippoRoot);
138
+ if (!info) {
139
+ failIfServerRequired('no running server was detected for this hippoRoot');
140
+ return false;
141
+ }
142
+ const apiKey = process.env['HIPPO_API_KEY'];
143
+ try {
144
+ await httpFn(info, apiKey);
145
+ return true;
146
+ }
147
+ catch (err) {
148
+ const failure = client.classifyTransportFailure(err);
149
+ if (failure === 'never-sent') {
150
+ failIfServerRequired('the server pidfile was stale (connection refused)');
151
+ console.error('hippo: stale server pidfile detected, falling back to direct mode');
152
+ // Clear the pidfile only if it still names the dead server we just
153
+ // probed — a newer server may have rewritten it (removePidfileIfOwned).
154
+ removePidfileIfOwned(hippoRoot, { pid: info.pid, startedAt: info.started_at });
155
+ return false;
156
+ }
157
+ if (failure === 'delivery-unknown') {
158
+ // Every caller of this helper is a non-idempotent write, so replaying on
159
+ // the direct path would store a row the server may already have committed.
160
+ // Leave the pidfile alone: the next command's connect-phase failure heals it.
161
+ console.error(`hippo: the connection to ${info.url} dropped mid-request, so the write may already have been applied. Not retrying locally. Check with \`hippo recall\` before running this again.`);
162
+ process.exit(1);
163
+ }
164
+ throw err;
165
+ }
166
+ }
167
+ export function fmt(n, digits = 2) {
168
+ return n.toFixed(digits);
169
+ }
170
+ // What `hippo recall` prints for one result; the budget prices this same text.
171
+ export function recallEntryText(r, query, showWhy, isGlobal) {
172
+ const e = r.entry;
173
+ const label = confidenceLabel(e);
174
+ const confLabel = label.warn ? `[${label.text}] ⚠️` : `[${label.text}]`;
175
+ const bars = Math.round(e.strength * 10);
176
+ const graphMark = r.graphVia ? ` [graph: ${r.graphVia.hops}hop ${r.graphVia.relType}]` : '';
177
+ const lines = [
178
+ `--- ${e.id} [${e.layer}] ${confLabel}${isGlobal ? ' [global]' : ''}${e.superseded_by ? ' [superseded]' : ''}${graphMark} score=${fmt(r.score, 3)} strength=${fmt(e.strength)}`,
179
+ ` [${'█'.repeat(bars)}${'░'.repeat(10 - bars)}] tags: ${e.tags.join(', ') || 'none'} | retrieved: ${e.retrieval_count}x`,
180
+ ];
181
+ if (showWhy) {
182
+ const explanation = explainMatch(query, r);
183
+ lines.push(` source:${isGlobal ? ' [global]' : ' [local]'} | layer: [${e.layer}] | confidence: [${label.text}]`, ` reason: ${explanation.reason}`);
184
+ const env = explanation.envelope;
185
+ if (env) {
186
+ lines.push(` kind: ${env.kind}`);
187
+ if (env.scope)
188
+ lines.push(` scope: ${env.scope}`);
189
+ if (env.owner)
190
+ lines.push(` owner: ${env.owner}`);
191
+ if (env.artifact_ref)
192
+ lines.push(` artifact_ref: ${env.artifact_ref}`);
193
+ if (env.session_id)
194
+ lines.push(` session_id: ${env.session_id}`);
195
+ lines.push(` confidence: ${env.confidence}`);
196
+ }
197
+ // The recall trace, e.g. "ranking: base 0.420 -> interference x0.30 -> 0.126 -> goal-boost x1.50 -> 0.189".
198
+ if (r.rerankTrace && r.rerankTrace.length > 0) {
199
+ const parts = [`base ${fmt(r.rerankTrace[0].scoreBefore, 3)}`];
200
+ for (const step of r.rerankTrace) {
201
+ parts.push(`${step.stage}${step.multiplier !== undefined ? ` x${fmt(step.multiplier, 2)}` : ''}`, fmt(step.scoreAfter, 3));
202
+ }
203
+ lines.push(` ranking: ${parts.join(' -> ')}`);
204
+ }
205
+ }
206
+ lines.push('', e.content, '');
207
+ return lines.join('\n');
208
+ }
209
+ export function recallHeading(entries, tokens, query) {
210
+ return `Found ${entries} memories (${tokens} tokens) for: "${query}"\n`;
211
+ }
212
+ /** One line when an agent memory import moved anything; its warnings go to stderr. */
213
+ export function printAgentImport(report, indent = ' ') {
214
+ const line = summaryLine(report);
215
+ if (line !== null)
216
+ console.log(`${indent}${line}`);
217
+ for (const warning of report.warnings)
218
+ console.error(`hippo: agent memories: ${warning}`);
219
+ }
220
+ /** The first hippo block in `text` and the agent whose current or shipped text it is; `owner` is undefined for an edited block. */
221
+ export function hippoBlock(text) {
222
+ const at = text.indexOf(HOOK_MARKERS.start);
223
+ const start = at + HOOK_MARKERS.start.length;
224
+ const end = text.indexOf(HOOK_MARKERS.end, start);
225
+ if (at < 0 || end < 0)
226
+ return null;
227
+ // git autocrlf checks these files out with CRLF: match as LF, write back in the file's own ending.
228
+ const raw = text.slice(start, end);
229
+ const inner = raw.replace(/\r\n/g, '\n').trim();
230
+ const owner = Object.keys(HOOKS).find((k) => HOOKS[k].content === inner) ?? SHIPPED_HOOK_HASHES.get(createHash('sha256').update(inner).digest('hex'));
231
+ return { start, end, eol: raw.includes('\r\n') ? '\r\n' : '\n', inner, owner };
232
+ }
233
+ /** Adds hippo's two Codex hooks and says what changed; each install ends on the trust reminder, since Codex skips an untrusted hook. */
234
+ export function installCodexMemoryHooks(indent) {
235
+ const result = installJsonHooks('codex');
236
+ if (result.invalidJson) {
237
+ console.log(`${indent}WARNING: ${result.settingsPath} is not a hooks file hippo can merge into; fix it, then run \`hippo hook install codex\`.`);
238
+ return;
239
+ }
240
+ const added = [
241
+ result.installedUserPromptSubmit ? 'UserPromptSubmit' : '',
242
+ result.installedCompactResume ? 'SessionStart(compact)' : '',
243
+ ].filter(Boolean);
244
+ console.log(added.length > 0
245
+ ? `${indent}Installed hippo's Codex memory hooks (${added.join(', ')}) in ${result.settingsPath}`
246
+ : `${indent}hippo's Codex memory hooks already in ${result.settingsPath}`);
247
+ console.log(`${indent}${CODEX_TRUST_LINE}`);
248
+ }
249
+ /**
250
+ * Set up a machine-level daily runner that sweeps all registered Hippo
251
+ * workspaces.
252
+ * Linux/macOS: writes to user crontab.
253
+ * Windows: creates a scheduled task.
254
+ * Skips if already installed.
255
+ */
256
+ export function setupDailySchedule(globalRoot) {
257
+ const runnerDir = path.resolve(globalRoot);
258
+ // Reject paths with characters that could break shell/crontab quoting
259
+ // (backslash is normal on Windows, only dangerous in Unix shell/crontab)
260
+ const unsafeChars = process.platform === 'win32' ? /["`$%\n\r]/ : /["`$\n\r\\]/;
261
+ if (unsafeChars.test(runnerDir)) {
262
+ console.log(` Skipping schedule: runner path contains unsafe characters.`);
263
+ return;
264
+ }
265
+ const isWindows = process.platform === 'win32';
266
+ const taskName = DAILY_TASK_NAME;
267
+ const cmd = buildDailyRunnerCommand(runnerDir);
268
+ if (isWindows) {
269
+ // Check if task already exists
270
+ try {
271
+ const existing = execSync(`schtasks /query /tn "${taskName}" 2>nul`, { encoding: 'utf-8', windowsHide: true });
272
+ if (existing.includes(taskName)) {
273
+ return; // already scheduled
274
+ }
275
+ }
276
+ catch {
277
+ // Task doesn't exist, create it
278
+ }
279
+ try {
280
+ execFileSync('schtasks', buildSchtasksCreateArgs(taskName, cmd), { stdio: 'pipe', windowsHide: true });
281
+ console.log(` Scheduled machine-level daily runner (6:15am) via Task Scheduler: ${taskName}`);
282
+ }
283
+ catch {
284
+ // No admin rights or schtasks unavailable, fall back to printing instructions
285
+ console.log(` To schedule the machine-level daily runner, run:`);
286
+ console.log(` schtasks /create /tn "${taskName}" /tr "${buildWindowsTaskRun(cmd).replace(/"/g, '\\"')}" /sc daily /st 06:15`);
287
+ }
288
+ }
289
+ else {
290
+ // Unix: check crontab for existing entry
291
+ const marker = `# hippo:${taskName}`;
292
+ try {
293
+ const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8', windowsHide: true });
294
+ if (existing.includes(marker)) {
295
+ return; // already scheduled
296
+ }
297
+ const cronLine = `15 6 * * * ${cmd} ${marker}`;
298
+ const newCrontab = existing.trimEnd() + '\n' + cronLine + '\n';
299
+ execSync('crontab -', { input: newCrontab, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
300
+ console.log(` Scheduled machine-level daily runner (6:15am) via crontab`);
301
+ }
302
+ catch {
303
+ const cronLine = `15 6 * * * ${cmd}`;
304
+ console.log(` To schedule the machine-level daily runner, add to crontab (crontab -e):`);
305
+ console.log(` ${cronLine}`);
306
+ }
307
+ }
308
+ }
309
+ export function parseAsOfFlag(flags) {
310
+ const asOf = typeof flags['as-of'] === 'string' ? flags['as-of'] : undefined;
311
+ if (asOf !== undefined && Number.isNaN(new Date(asOf).getTime())) {
312
+ console.error(`Error: --as-of value "${asOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
313
+ process.exit(1);
314
+ }
315
+ return asOf;
316
+ }
317
+ /** --physics forces physics, --classic forces BM25+cosine, else physics unless the config turns it off. */
318
+ export function engineFlags(flags, config) {
319
+ return {
320
+ usePhysics: Boolean(flags['physics']) || (!flags['classic'] && config.physics.enabled !== false),
321
+ physicsConfig: config.physics,
322
+ mmr: !flags['no-mmr'] && config.mmr.enabled,
323
+ mmrLambda: flags['mmr-lambda'] !== undefined ? parseFloat(String(flags['mmr-lambda'])) : config.mmr.lambda,
324
+ localBump: flags['equal-sources']
325
+ ? 1.0
326
+ : flags['local-bump'] !== undefined ? parseFloat(String(flags['local-bump'])) : config.search.localBump,
327
+ };
328
+ }
329
+ /**
330
+ * Detached worker that counts re-reads, runs sleep, then capture. Invoked via the internal
331
+ * `__session-end-worker` subcommand (not user-facing). Failures in one stage
332
+ * do not block the other.
333
+ */
334
+ // Best-effort git state; a missing git, non-repo cwd, or the timeout all
335
+ // yield null fields rather than throw (autolearn.ts execFileSync shape).
336
+ export function collectHandoffEvidence(cwd, testStatus) {
337
+ let gitRef = null;
338
+ try {
339
+ gitRef = execFileSync('git', ['rev-parse', 'HEAD'], {
340
+ cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
341
+ }).trim() || null;
342
+ }
343
+ catch {
344
+ gitRef = null;
345
+ }
346
+ let dirtyTree = null;
347
+ try {
348
+ const status = execFileSync('git', ['status', '--porcelain'], {
349
+ cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
350
+ });
351
+ dirtyTree = status.trim().length > 0;
352
+ }
353
+ catch {
354
+ dirtyTree = null;
355
+ }
356
+ return { gitRef, dirtyTree, testStatus };
357
+ }
358
+ /** A folder without its own store never sleeps at session end, so its project's agent notes go to the global store here. */
359
+ export function logSessionEndImport(logFile, transcriptPath) {
360
+ try {
361
+ const report = importAtSessionEnd(process.cwd(), transcriptPath, { machine: currentMachine() });
362
+ const line = summaryLine(report);
363
+ if (line !== null)
364
+ appendSessionEndCloseLog(logFile, line);
365
+ for (const warning of report.warnings)
366
+ appendSessionEndCloseLog(logFile, `agent memories: ${warning}`);
367
+ }
368
+ catch (err) {
369
+ appendSessionEndCloseLog(logFile, `agent memory import failed: ${err instanceof Error ? err.message : String(err)}`);
370
+ }
371
+ }
372
+ /**
373
+ * Best-effort log line for the snapshot-close step in
374
+ * `cmdSessionEndWorker`. `cmdSleep`/`cmdCapture` each tee console output to
375
+ * `logFile` only for their own duration (the tee is restored before this
376
+ * runs), so a plain `console.log` here would be silently discarded under
377
+ * the detached worker's `stdio: 'ignore'` — write straight to the file
378
+ * instead, matching capture.ts's `appendPreCompactLog` convention.
379
+ */
380
+ export function appendSessionEndCloseLog(logFile, message, opts = {}) {
381
+ if (!logFile)
382
+ return;
383
+ try {
384
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
385
+ // sanitizeLogMessage: `message` interpolates the payload-controlled
386
+ // session_id — same log-forgery guard appendPreCompactLog applies.
387
+ const write = opts.startFresh ? fs.writeFileSync : fs.appendFileSync;
388
+ write(logFile, `[hippo] ${new Date().toISOString()} ${sanitizeLogMessage(message)}\n`, 'utf8');
389
+ }
390
+ catch {
391
+ // Best-effort only — never let a log-write failure surface as an error.
392
+ }
393
+ }
394
+ export function printActiveTaskSnapshot(snapshot) {
395
+ console.log(snapshotText(snapshot));
396
+ }
397
+ export function printSessionEvents(events) {
398
+ console.log(events.length === 0 ? 'No session events found.' : sessionTrailText(events));
399
+ }
400
+ export function printHandoff(handoff) {
401
+ console.log(handoffText(handoff));
402
+ }
403
+ // parseArgs turns a value-less flag into `true`; refuse rather than silently
404
+ // stringifying it (String(true) === 'true'), mirroring cmdHandoff's guard.
405
+ export function cardStringFlag(flags, key) {
406
+ const v = flags[key];
407
+ if (v === undefined)
408
+ return undefined;
409
+ if (v === true || v === false || Array.isArray(v)) {
410
+ console.error(`--${key} requires a value`);
411
+ process.exit(1);
412
+ }
413
+ return v.trim();
414
+ }
415
+ // Claude Code exports its own session var, not ours; without the fallback agent-run recalls trace with no session.
416
+ export function hostSessionId() {
417
+ return process.env.HIPPO_SESSION_ID?.trim() || process.env.CLAUDE_CODE_SESSION_ID?.trim() || undefined;
418
+ }
419
+ /**
420
+ * Compaction drops the pinned blocks the per-prompt hook injected
421
+ * earlier, so record a `reset` for the payload's session and the next prompt
422
+ * injects again even if nothing changed. `requiredSource` limits it to hook
423
+ * payloads with that `source` (SessionStart fires for other reasons too).
424
+ * Best-effort and silent: a malformed payload records nothing.
425
+ */
426
+ export function resetHookInjection(hippoRoot, stdinText, requiredSource) {
427
+ const sessionId = hookPayloadSessionId(stdinText, requiredSource);
428
+ // A sub-agent's compaction leaves its parent's context, and the blocks in it, as they were.
429
+ if (sessionId === null || isSubagentPayload(stdinText))
430
+ return;
431
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
432
+ tenantId: resolveTenantId({}), sessionId, surface: 'hook', event: 'reset', items: 0, tokens: 0,
433
+ }));
434
+ }
435
+ /**
436
+ * Run `fn` with console.log captured; returns the captured lines joined by
437
+ * newlines (what the same calls would have printed, minus the final newline).
438
+ */
439
+ export function captureConsole(fn) {
440
+ const lines = [];
441
+ const realLog = console.log;
442
+ console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
443
+ try {
444
+ fn();
445
+ }
446
+ finally {
447
+ console.log = realLog;
448
+ }
449
+ return lines.join('\n');
450
+ }
451
+ /**
452
+ * The store a Claude Code hook writes to: the project store when there is
453
+ * one, else an existing global store, else the project path (which the hook
454
+ * then skips, since hooks fire in every directory and must not create one).
455
+ * Pre-compact and compact-resume must agree, or a snapshot saved to one store
456
+ * is looked for in the other.
457
+ */
458
+ export function hookStoreRoot(hippoRoot) {
459
+ if (isInitialized(hippoRoot))
460
+ return hippoRoot;
461
+ const globalRoot = getGlobalRoot();
462
+ return isInitialized(globalRoot) ? globalRoot : hippoRoot;
463
+ }
464
+ /**
465
+ * Run `fn` against the token ledger's store: the local store when it is
466
+ * initialized, else the global one (the per-prompt hook runs in directories
467
+ * without a local store). Best-effort: returns undefined and never throws,
468
+ * because a ledger failure must not break context or recall.
469
+ */
470
+ export function withLedgerDb(hippoRoot, fn) {
471
+ let root = null;
472
+ try {
473
+ if (isInitialized(hippoRoot))
474
+ root = hippoRoot;
475
+ else if (isInitialized(getGlobalRoot()))
476
+ root = getGlobalRoot();
477
+ }
478
+ catch {
479
+ return undefined;
480
+ }
481
+ if (root === null)
482
+ return undefined;
483
+ let db;
484
+ try {
485
+ db = openHippoDb(root);
486
+ return fn(db);
487
+ }
488
+ catch {
489
+ return undefined;
490
+ }
491
+ finally {
492
+ if (db)
493
+ closeHippoDb(db);
494
+ }
495
+ }
496
+ export function learnFromRepo(hippoRoot, repoPath, days, label) {
497
+ const prefix = label ? `[${label}] ` : '';
498
+ if (!isGitRepo(repoPath)) {
499
+ console.log(`${prefix}No git history found (or not a git repository).`);
500
+ return { added: 0, skipped: 0, lowInfo: 0 };
501
+ }
502
+ const gitLog = fetchGitLog(repoPath, days);
503
+ if (!gitLog.trim()) {
504
+ console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
505
+ return { added: 0, skipped: 0, lowInfo: 0 };
506
+ }
507
+ // Same patterns as MCP hippo_learn: config.gitLearnPatterns (whose default
508
+ // equals extractLessons' built-in list) so a custom list applies everywhere.
509
+ const config = loadConfig(hippoRoot);
510
+ const parsedLessons = extractLessons(gitLog, config.gitLearnPatterns);
511
+ if (parsedLessons.length === 0) {
512
+ console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
513
+ return { added: 0, skipped: 0, lowInfo: 0 };
514
+ }
515
+ // The admission gate lives at the write path, not in extractLessons
516
+ // (a published API surface that only parses). Bare subjects like "fixed
517
+ // signals" are dropped here, before they ever become a memory.
518
+ // The gate filters the loop INPUT, so a dropped lesson neither stores nor
519
+ // invalidates. That is deliberate, and it was argued both ways.
520
+ //
521
+ // One review called the lost invalidation serious: a migration subject
522
+ // too thin to store ("replace webpack with vite") would stop weakening
523
+ // stale webpack memories. True. So the loop was widened to walk every
524
+ // parsed lesson with the gate on the write alone.
525
+ //
526
+ // A second review found the cure was worse. STORAGE is what makes invalidation
527
+ // idempotent here: a stored lesson is recognised by its same-text key on
528
+ // the next scan and short-circuits before invalidating again. A lesson that
529
+ // invalidates but is never stored has no such record, so every rescan
530
+ // re-invalidates, and invalidateMatching halves half_life_days each time.
531
+ // Measured: 7 -> 3 -> 1 over two runs. That is compounding data damage.
532
+ //
533
+ // Measured frequency decided it. Across 413 real auto-learn rows in 4
534
+ // stores, 24 are gated and ZERO of those carry an invalidation target; the
535
+ // 45 lessons that do carry targets all pass the gate and are unaffected
536
+ // either way. Both failure modes are empty on real data, so the tie breaks
537
+ // on which one is benign if it ever fires: not invalidating is a missed
538
+ // improvement, re-invalidating forever is damage.
539
+ //
540
+ // Documented limitation, pinned by test: a migration subject too thin to
541
+ // store also does not invalidate. Making invalidateMatching idempotent
542
+ // would allow both, and is backlogged - it is a latent issue for the manual
543
+ // `hippo invalidate` path too, not just this one.
544
+ const { kept: lessons, dropped } = partitionLessons(parsedLessons);
545
+ const lowInfo = dropped.length;
546
+ let added = 0;
547
+ let skipped = 0;
548
+ // Containment: per-lesson refusal must not abort the rest
549
+ // of the git-log scan. No signature change (added/skipped return shape
550
+ // used by cmdLearn + cmdSleepCore callers) — counted locally, folded into
551
+ // the existing summary line.
552
+ let rejected = 0;
553
+ const gitLearnTags = ['error', 'git-learned'];
554
+ const existingForSchema = loadAllEntries(hippoRoot, resolveTenantId({}));
555
+ const keys = storedTextKeys(existingForSchema);
556
+ for (const lesson of lessons) {
557
+ if (keys.has(duplicateKey(lesson))) {
558
+ skipped++;
559
+ continue;
560
+ }
561
+ const target = extractInvalidationTarget(lesson);
562
+ if (target) {
563
+ const invResult = invalidateMatching(hippoRoot, target, resolveTenantId({}));
564
+ if (invResult.invalidated > 0) {
565
+ console.log(`${prefix} Invalidated ${invResult.invalidated} memories referencing "${target.from}"`);
566
+ }
567
+ }
568
+ const schemaFitVal = computeSchemaFit(lesson, gitLearnTags, existingForSchema);
569
+ const entry = createMemory(lesson, {
570
+ layer: Layer.Episodic,
571
+ tags: [...gitLearnTags],
572
+ source: 'git-learn',
573
+ confidence: 'observed',
574
+ schema_fit: schemaFitVal,
575
+ tenantId: resolveTenantId({}),
576
+ baseHalfLifeDays: config.defaultHalfLifeDays,
577
+ });
578
+ // Auto-tag with path context from the repo being learned
579
+ const learnPathTags = extractPathTags(repoPath);
580
+ for (const pt of learnPathTags) {
581
+ if (!entry.tags.includes(pt))
582
+ entry.tags.push(pt);
583
+ }
584
+ try {
585
+ writeEntry(hippoRoot, entry);
586
+ }
587
+ catch (err) {
588
+ if (err instanceof RejectedValueError) {
589
+ rejected++;
590
+ continue;
591
+ }
592
+ throw err;
593
+ }
594
+ updateStats(hippoRoot, { remembered: 1 });
595
+ keys.add(duplicateKey(lesson));
596
+ void embedMemory(hippoRoot, entry);
597
+ added++;
598
+ }
599
+ console.log(`${prefix}${added} new lessons added, ${skipped} duplicates skipped` +
600
+ (rejected > 0 ? `, ${rejected} rejected value(s) skipped` : '') +
601
+ (lowInfo > 0 ? `, ${lowInfo} low-information subject(s) dropped` : '') +
602
+ '.');
603
+ return { added, skipped, lowInfo };
604
+ }
605
+ export const HOOK_MARKERS = {
606
+ start: '<!-- hippo:start -->',
607
+ end: '<!-- hippo:end -->',
608
+ };
609
+ export const HOOKS = {
610
+ 'claude-code': {
611
+ file: 'CLAUDE.md',
612
+ description: 'Claude Code',
613
+ content: `
614
+ ## Project Memory (Hippo)
615
+
616
+ Pinned rules and recent writes auto-inject at every prompt via the installed
617
+ UserPromptSubmit hook; never re-run that part manually. At the START of a
618
+ task (not per prompt), additionally load task-specific context: git-aware
619
+ recall over the full store that per-prompt injection does not cover. Also
620
+ run it if the hook is not installed:
621
+ \`\`\`bash
622
+ hippo context --auto --budget 1500
623
+ \`\`\`
624
+
625
+ When you find out why something failed, record it right then, while you
626
+ work, never as a closing step:
627
+ \`\`\`bash
628
+ hippo remember "<what went wrong and why>" --error
629
+ \`\`\`
630
+
631
+ The installed hooks store failed tool calls and capture the session when it
632
+ ends, so there is nothing to run before you finish.
633
+ `.trim(),
634
+ },
635
+ 'codex': {
636
+ file: 'AGENTS.md',
637
+ description: 'OpenAI Codex',
638
+ content: `
639
+ ## Project Memory (Hippo)
640
+
641
+ At the start of every task, run:
642
+ \`\`\`bash
643
+ hippo context --auto --budget 1500
644
+ \`\`\`
645
+ Read the output before writing any code.
646
+
647
+ On errors or unexpected behaviour, record it right then, while you work,
648
+ never as a closing step:
649
+ \`\`\`bash
650
+ hippo remember "<description of what went wrong>" --error
651
+ \`\`\`
652
+
653
+ When you learn something that should outlive this session (a decision and
654
+ its reason, a user preference, a lesson), record it right then, while you
655
+ work, never as a closing step. Leave out secrets and personal details:
656
+ \`\`\`bash
657
+ hippo remember "<what you learned and why>"
658
+ \`\`\`
659
+
660
+ When Hippo's Codex wrapper is installed, session-end capture runs automatically.
661
+ If the wrapper is not installed, capture a brief summary manually:
662
+ \`\`\`bash
663
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
664
+ \`\`\`
665
+ `.trim(),
666
+ },
667
+ 'cursor': {
668
+ file: 'AGENTS.md',
669
+ description: 'Cursor',
670
+ content: `
671
+ ## Project Memory (Hippo)
672
+
673
+ At the start of every task, run:
674
+ \`\`\`bash
675
+ hippo context --auto --budget 1500
676
+ \`\`\`
677
+ Read the output before writing any code.
678
+
679
+ On errors or unexpected behaviour, record it right then, while you work,
680
+ never as a closing step:
681
+ \`\`\`bash
682
+ hippo remember "<description of what went wrong>" --error
683
+ \`\`\`
684
+
685
+ When you learn something that should outlive this session (a decision and
686
+ its reason, a user preference, a lesson), record it right then, while you
687
+ work, never as a closing step. Leave out secrets and personal details:
688
+ \`\`\`bash
689
+ hippo remember "<what you learned and why>"
690
+ \`\`\`
691
+
692
+ When ending a session, capture a brief summary:
693
+ \`\`\`bash
694
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
695
+ \`\`\`
696
+ `.trim(),
697
+ },
698
+ 'openclaw': {
699
+ file: 'AGENTS.md',
700
+ description: 'OpenClaw',
701
+ content: `
702
+ ## Project Memory (Hippo)
703
+
704
+ At the start of every session, run:
705
+ \`\`\`bash
706
+ hippo context --auto --budget 1500
707
+ \`\`\`
708
+ Read the output before writing any code.
709
+
710
+ On errors or unexpected behaviour, record it right then, while you work,
711
+ never as a closing step:
712
+ \`\`\`bash
713
+ hippo remember "<description of what went wrong>" --error
714
+ \`\`\`
715
+
716
+ When you learn something that should outlive this session (a decision and
717
+ its reason, a user preference, a lesson), record it right then, while you
718
+ work, never as a closing step. Leave out secrets and personal details:
719
+ \`\`\`bash
720
+ hippo remember "<what you learned and why>"
721
+ \`\`\`
722
+
723
+ When ending a session, capture a brief summary:
724
+ \`\`\`bash
725
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
726
+ \`\`\`
727
+ `.trim(),
728
+ },
729
+ 'opencode': {
730
+ file: 'AGENTS.md',
731
+ description: 'OpenCode',
732
+ content: `
733
+ ## Project Memory (Hippo)
734
+
735
+ At the start of every task, run:
736
+ \`\`\`bash
737
+ hippo context --auto --budget 1500
738
+ \`\`\`
739
+ Read the output before writing any code.
740
+
741
+ On errors or unexpected behaviour, record it right then, while you work,
742
+ never as a closing step:
743
+ \`\`\`bash
744
+ hippo remember "<description of what went wrong>" --error
745
+ \`\`\`
746
+
747
+ When you learn something that should outlive this session (a decision and
748
+ its reason, a user preference, a lesson), record it right then, while you
749
+ work, never as a closing step. Leave out secrets and personal details:
750
+ \`\`\`bash
751
+ hippo remember "<what you learned and why>"
752
+ \`\`\`
753
+
754
+ When stuck or repeating yourself, check if this happened before:
755
+ \`\`\`bash
756
+ hippo recall "<what's going wrong>" --budget 2000
757
+ \`\`\`
758
+
759
+ When ending a session, capture a brief summary:
760
+ \`\`\`bash
761
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
762
+ \`\`\`
763
+ `.trim(),
764
+ },
765
+ 'pi': {
766
+ file: 'AGENTS.md',
767
+ description: 'Pi',
768
+ content: `
769
+ ## Project Memory (Hippo)
770
+
771
+ At the start of every session, run:
772
+ \`\`\`bash
773
+ hippo context --auto --budget 1500
774
+ \`\`\`
775
+ Read the output before writing any code.
776
+
777
+ On errors or unexpected behaviour, record it right then, while you work,
778
+ never as a closing step:
779
+ \`\`\`bash
780
+ hippo remember "<description of what went wrong>" --error
781
+ \`\`\`
782
+
783
+ When you learn something that should outlive this session (a decision and
784
+ its reason, a user preference, a lesson), record it right then, while you
785
+ work, never as a closing step. Leave out secrets and personal details:
786
+ \`\`\`bash
787
+ hippo remember "<what you learned and why>"
788
+ \`\`\`
789
+
790
+ When ending a session, capture a brief summary:
791
+ \`\`\`bash
792
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
793
+ \`\`\`
794
+
795
+ For full integration, copy the hippo-memory Pi extension to \`~/.pi/agent/extensions/hippo-memory/\`.
796
+ `.trim(),
797
+ },
798
+ };
799
+ // sha256 of each trimmed block an earlier hippo wrote, so init refreshes only blocks nobody edited. Add the old hash when a block changes.
800
+ const SHIPPED_HOOK_HASHES = new Map([
801
+ ['c04e48f2896a4fee9ae98f8f832e2d26a3910269df3beb5bcd6baee3cd9db68e', 'claude-code'],
802
+ ['e6b12bd8983c032e5ca8e95a97aeff4178a5a05026d10acad5b2e1b25d5656dd', 'claude-code'],
803
+ ['4c64e11d3e5be68fa547c9248d7553feb645a02f7cf13ba02f7275e1854baf44', 'claude-code'],
804
+ ['293bd319bbc86225a0ee027490a3322a0336257f5832f7fade65e4ffb2530654', 'claude-code'],
805
+ ['15abcece9712279fb4721f7a8f0ba117457400278977beb5cf5b5d7ba49f7b1a', 'codex'],
806
+ ['0c81a6b2c21473313001f624b80ea870e661aecbfda9bfe8503febc0d5f34533', 'codex'],
807
+ ['88e45358aba4f17912f113221c991dc758275991335d1daa4aa1974a69c46769', 'codex'],
808
+ ['e61632fe177450a06541c148a9a4f9182530d8df667806927a99792825903298', 'codex'],
809
+ ['a1415ecda9b2f8f317c233738e4a5ac16e6b2cc385a017c0c8ecfbfacbcab6a3', 'cursor'],
810
+ ['a38c428bbdfc14ec50f6f7b9183785170a4eae1ce9cde60257cca6efc7206b3a', 'cursor'],
811
+ ['0ec9f556abfd55e94f9e6fb47ece0fc5acb841977d144b35a2371e03645d8636', 'cursor'],
812
+ ['40524c3bd5a2eb04036567cc761451961d950995768bccd93a9900b0f75eafea', 'openclaw'],
813
+ ['7b3518e8c0feaa7b8b454cde7743f7598ad14cd9979e1680d0954484e2464aae', 'openclaw'],
814
+ ['1137dcf04568caf011e41db77bc55324faee88bc29c3a5fcc98ab687cd952a16', 'openclaw'],
815
+ ['4601c67c31f41cd5b1324cfccdb1afc66872b7fb0bc1e7c5789ecabb1f6bd942', 'opencode'],
816
+ ['90d9e21d8d1ecbe99a0fc7b7f2d9f8af7b5315a6b4b0203df4f7a9bdc0699b98', 'opencode'],
817
+ ['ca4e00284f1397ed2f2fcc53210c27f63b90edf6b37fd66dad5ee58b94ea3eee', 'opencode'],
818
+ ['8b8f5986d7f7ed15f06e68720d8913c3cab23d94366b411935ca2bbaa334553b', 'pi'],
819
+ ['37767b355e18beac726b05b9e2b898dab8c6135fd7b98f3aa52edc734d5dd283', 'pi'],
820
+ ['6e85a5cccb3cfeaa9a080713754936db730f96376f94cc9a9888a746149c7268', 'pi'],
821
+ ]);
822
+ export function resolveAuthRoot(hippoRoot, flags) {
823
+ if (flags['global']) {
824
+ initGlobal();
825
+ return getGlobalRoot();
826
+ }
827
+ requireInit(hippoRoot);
828
+ return hippoRoot;
829
+ }
830
+ //# sourceMappingURL=shared.js.map