hippo-memory 1.9.3 → 1.10.1

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 CHANGED
@@ -85,6 +85,11 @@ hippo recall "data pipeline issues" --budget 2000
85
85
 
86
86
  ---
87
87
 
88
+ ### What's new in v1.10.0
89
+
90
+ - **Server and lifecycle hardening.** Closes the `TODOS.md` "server / lifecycle hardening" cluster (deferred follow-ups from the v0.37 server-mode work, the v0.40 security pass, and the A3 envelope review), six items in all. `detectServer` is now async and confirms a recorded server is genuinely this hippo process by matching a `/health` `started_at` before the CLI routes to it (H1). The pidfile carries a `schema` version (L3). `hippo serve` refuses to start when a live peer already serves the hippoRoot (H3). The 413 over-cap-body path closes the socket instead of draining the rest (M3). A `HIPPO_REQUIRE_SERVER` env knob turns a missing server into a loud error instead of a silent direct-mode fallback that discards `HIPPO_API_KEY` (H2). And `hippo forget --archive --reason` gives raw, append-only memories a real removal path via `archiveRaw` instead of a misleading "not found" (A3).
91
+ - **Reviewed via the dev-framework chain.** self-review, an independent code review, a cross-model codex (gpt-5.5) pass, and a security pass. Four findings were fixed before ship: the `forget --archive` server-routing bypass, an unbounded `/health` response parse, a timeout that wrongly unlinked a busy server's pidfile, and pidfile-url validation against off-box redirection. Full suite green: 216 files, 1557 tests.
92
+
88
93
  ### What's new in v1.9.3
89
94
 
90
95
  - **Reranker review-tail patch.** Closes the three follow-ups raised on PR #25: `src/rerankers/llm.ts` now wires `AbortController` + `setTimeout` around the fetch (default 30 s, overridable via `HIPPO_LLM_RERANKER_TIMEOUT_MS`) so recall never hangs on a wedged endpoint; `src/rerankers/cross-encoder.ts` emits a single `console.warn` on first identity-fallback per process so silent fallback no longer masquerades as a working reranker; the orphan `RerankSignals` type (sole consumer retracted in v1.9.1) is removed at both the re-export and the definition.
package/dist/cli.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * hippo session <log|show|latest|resume|complete>
15
15
  * hippo handoff <create|latest|show>
16
16
  * hippo current <show>
17
- * hippo forget <id>
17
+ * hippo forget <id> [--archive --reason "<why>"]
18
18
  * hippo inspect <id>
19
19
  * hippo embed [--status]
20
20
  * hippo watch "<command>"
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * hippo session <log|show|latest|resume|complete>
15
15
  * hippo handoff <create|latest|show>
16
16
  * hippo current <show>
17
- * hippo forget <id>
17
+ * hippo forget <id> [--archive --reason "<why>"]
18
18
  * hippo inspect <id>
19
19
  * hippo embed [--status]
20
20
  * hippo watch "<command>"
@@ -57,7 +57,7 @@ import { buildProvenanceCoverage } from './provenance-coverage.js';
57
57
  import { buildCorrectionLatency } from './correction-latency.js';
58
58
  import * as api from './api.js';
59
59
  import * as client from './client.js';
60
- import { detectServer, removePidfile } from './server-detect.js';
60
+ import { detectServer, removePidfileIfOwned } from './server-detect.js';
61
61
  import { resolveTenantId } from './tenant.js';
62
62
  import { runEval, bootstrapCorpus, compareSummaries } from './eval.js';
63
63
  import { runFeatureEval, formatResult, resultToBaseline, detectRegressions } from './eval-suite.js';
@@ -117,6 +117,19 @@ function requireInit(hippoRoot) {
117
117
  process.exit(1);
118
118
  }
119
119
  }
120
+ /**
121
+ * H2: when HIPPO_REQUIRE_SERVER is set, the CLI must not silently fall back to
122
+ * direct DB mode — a missing server then masks a real misconfiguration (the
123
+ * configured HIPPO_API_KEY is also silently discarded on fallback). Throws a
124
+ * clear, actionable error in that case; a no-op when the knob is unset, so
125
+ * default behaviour is unchanged.
126
+ */
127
+ function failIfServerRequired(reason) {
128
+ if (process.env['HIPPO_REQUIRE_SERVER']) {
129
+ throw new Error(`hippo: HIPPO_REQUIRE_SERVER is set but ${reason}. ` +
130
+ `Start \`hippo serve\`, or unset HIPPO_REQUIRE_SERVER to allow direct-mode fallback.`);
131
+ }
132
+ }
120
133
  /**
121
134
  * Run an HTTP-routed command if a `hippo serve` instance is detected for
122
135
  * `hippoRoot`. Returns:
@@ -124,14 +137,20 @@ function requireInit(hippoRoot) {
124
137
  * was already surfaced to stdout/stderr by `httpFn`),
125
138
  * - false if no server was detected, or if the detected pidfile turned out
126
139
  * to be stale (connection refused). On stale, the pidfile is removed
127
- * and the caller should fall back to the direct path.
140
+ * if it still names that dead server (a newer one may have replaced
141
+ * it) and the caller should fall back to the direct path.
128
142
  *
129
143
  * Per the A1 plan footgun #1: stale pidfiles must self-heal, not crash.
144
+ * H2: when HIPPO_REQUIRE_SERVER is set, both fallback paths throw instead of
145
+ * returning false, so a missing server fails loudly rather than silently
146
+ * degrading to direct mode.
130
147
  */
131
148
  async function runViaServerIfAvailable(hippoRoot, httpFn) {
132
- const info = detectServer(hippoRoot);
133
- if (!info)
149
+ const info = await detectServer(hippoRoot);
150
+ if (!info) {
151
+ failIfServerRequired('no running server was detected for this hippoRoot');
134
152
  return false;
153
+ }
135
154
  const apiKey = process.env['HIPPO_API_KEY'];
136
155
  try {
137
156
  await httpFn(info, apiKey);
@@ -139,8 +158,11 @@ async function runViaServerIfAvailable(hippoRoot, httpFn) {
139
158
  }
140
159
  catch (err) {
141
160
  if (client.isConnectionRefused(err)) {
161
+ failIfServerRequired('the server pidfile was stale (connection refused)');
142
162
  console.error('hippo: stale server pidfile detected, falling back to direct mode');
143
- removePidfile(hippoRoot);
163
+ // Clear the pidfile only if it still names the dead server we just
164
+ // probed — a newer server may have rewritten it (removePidfileIfOwned).
165
+ removePidfileIfOwned(hippoRoot, { pid: info.pid, startedAt: info.started_at });
144
166
  return false;
145
167
  }
146
168
  throw err;
@@ -2363,20 +2385,49 @@ function cmdOutcome(hippoRoot, flags) {
2363
2385
  }
2364
2386
  console.log(`Applied ${good ? 'positive' : 'negative'} outcome to ${updated} memor${updated === 1 ? 'y' : 'ies'}`);
2365
2387
  }
2366
- function cmdForget(hippoRoot, id) {
2388
+ function cmdForget(hippoRoot, id, flags) {
2367
2389
  requireInit(hippoRoot);
2368
2390
  const ctx = {
2369
2391
  hippoRoot,
2370
2392
  tenantId: resolveTenantId({}),
2371
2393
  actor: 'cli',
2372
2394
  };
2395
+ // A3: raw memories (Slack / GitHub connector ingestion) are append-only — a
2396
+ // BEFORE-DELETE trigger aborts any delete. archiveRaw is the sanctioned
2397
+ // removal path; it records ctx.actor as the archiver for provenance.
2398
+ if (flags['archive'] === true) {
2399
+ const reason = typeof flags['reason'] === 'string' ? flags['reason'] : null;
2400
+ if (!reason) {
2401
+ console.error('hippo forget --archive requires --reason "<why>" (recorded on the archive).');
2402
+ process.exit(1);
2403
+ }
2404
+ try {
2405
+ api.archiveRaw(ctx, id, reason);
2406
+ updateStats(hippoRoot, { forgotten: 1 });
2407
+ console.log(`Archived ${id}`);
2408
+ }
2409
+ catch (err) {
2410
+ console.error(`Could not archive ${id}: ${err instanceof Error ? err.message : String(err)}`);
2411
+ process.exit(1);
2412
+ }
2413
+ return;
2414
+ }
2373
2415
  try {
2374
2416
  api.forget(ctx, id);
2375
2417
  updateStats(hippoRoot, { forgotten: 1 });
2376
2418
  console.log(`Forgot ${id}`);
2377
2419
  }
2378
- catch {
2379
- console.error(`Memory not found: ${id}`);
2420
+ catch (err) {
2421
+ const msg = err instanceof Error ? err.message : String(err);
2422
+ if (/append-only/i.test(msg)) {
2423
+ // The delete was refused by the append-only trigger — this is a raw
2424
+ // memory, not a missing one. Point the user at the archive path.
2425
+ console.error(`Cannot forget ${id}: it is a raw, append-only memory. ` +
2426
+ `Archive it instead: hippo forget ${id} --archive --reason "<why>"`);
2427
+ }
2428
+ else {
2429
+ console.error(`Memory not found: ${id}`);
2430
+ }
2380
2431
  process.exit(1);
2381
2432
  }
2382
2433
  }
@@ -4841,6 +4892,8 @@ Commands:
4841
4892
  current show Active task + recent session events (default)
4842
4893
  --json Output as JSON
4843
4894
  forget <id> Force remove a memory
4895
+ --archive Archive a raw (append-only) memory instead of deleting
4896
+ --reason "<why>" Reason recorded on the archive (required with --archive)
4844
4897
  inspect <id> Show full memory detail
4845
4898
  embed Embed all memories for semantic search
4846
4899
  --status Show embedding coverage
@@ -5283,19 +5336,24 @@ async function main() {
5283
5336
  console.error('Please provide a memory ID.');
5284
5337
  process.exit(1);
5285
5338
  }
5286
- const routed = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
5287
- try {
5288
- await client.forget(info.url, apiKey, id);
5289
- console.log(`Forgot ${id}`);
5290
- }
5291
- catch (err) {
5292
- console.error(err.message);
5293
- process.exit(1);
5294
- }
5295
- });
5296
- if (routed)
5297
- break;
5298
- cmdForget(hippoRoot, id);
5339
+ // A3: --archive is a direct-DB operation (raw archival via archiveRaw);
5340
+ // the HTTP forget route does not carry it, so archive requests always
5341
+ // take the direct path rather than silently no-op'ing over a server.
5342
+ if (flags['archive'] !== true) {
5343
+ const routed = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
5344
+ try {
5345
+ await client.forget(info.url, apiKey, id);
5346
+ console.log(`Forgot ${id}`);
5347
+ }
5348
+ catch (err) {
5349
+ console.error(err.message);
5350
+ process.exit(1);
5351
+ }
5352
+ });
5353
+ if (routed)
5354
+ break;
5355
+ }
5356
+ cmdForget(hippoRoot, id, flags);
5299
5357
  break;
5300
5358
  }
5301
5359
  case 'inspect': {