hippo-memory 1.9.3 → 1.10.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 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>"
@@ -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:
@@ -127,11 +140,16 @@ function requireInit(hippoRoot) {
127
140
  * and the caller should fall back to the direct path.
128
141
  *
129
142
  * Per the A1 plan footgun #1: stale pidfiles must self-heal, not crash.
143
+ * H2: when HIPPO_REQUIRE_SERVER is set, both fallback paths throw instead of
144
+ * returning false, so a missing server fails loudly rather than silently
145
+ * degrading to direct mode.
130
146
  */
131
147
  async function runViaServerIfAvailable(hippoRoot, httpFn) {
132
- const info = detectServer(hippoRoot);
133
- if (!info)
148
+ const info = await detectServer(hippoRoot);
149
+ if (!info) {
150
+ failIfServerRequired('no running server was detected for this hippoRoot');
134
151
  return false;
152
+ }
135
153
  const apiKey = process.env['HIPPO_API_KEY'];
136
154
  try {
137
155
  await httpFn(info, apiKey);
@@ -139,6 +157,7 @@ async function runViaServerIfAvailable(hippoRoot, httpFn) {
139
157
  }
140
158
  catch (err) {
141
159
  if (client.isConnectionRefused(err)) {
160
+ failIfServerRequired('the server pidfile was stale (connection refused)');
142
161
  console.error('hippo: stale server pidfile detected, falling back to direct mode');
143
162
  removePidfile(hippoRoot);
144
163
  return false;
@@ -2363,20 +2382,49 @@ function cmdOutcome(hippoRoot, flags) {
2363
2382
  }
2364
2383
  console.log(`Applied ${good ? 'positive' : 'negative'} outcome to ${updated} memor${updated === 1 ? 'y' : 'ies'}`);
2365
2384
  }
2366
- function cmdForget(hippoRoot, id) {
2385
+ function cmdForget(hippoRoot, id, flags) {
2367
2386
  requireInit(hippoRoot);
2368
2387
  const ctx = {
2369
2388
  hippoRoot,
2370
2389
  tenantId: resolveTenantId({}),
2371
2390
  actor: 'cli',
2372
2391
  };
2392
+ // A3: raw memories (Slack / GitHub connector ingestion) are append-only — a
2393
+ // BEFORE-DELETE trigger aborts any delete. archiveRaw is the sanctioned
2394
+ // removal path; it records ctx.actor as the archiver for provenance.
2395
+ if (flags['archive'] === true) {
2396
+ const reason = typeof flags['reason'] === 'string' ? flags['reason'] : null;
2397
+ if (!reason) {
2398
+ console.error('hippo forget --archive requires --reason "<why>" (recorded on the archive).');
2399
+ process.exit(1);
2400
+ }
2401
+ try {
2402
+ api.archiveRaw(ctx, id, reason);
2403
+ updateStats(hippoRoot, { forgotten: 1 });
2404
+ console.log(`Archived ${id}`);
2405
+ }
2406
+ catch (err) {
2407
+ console.error(`Could not archive ${id}: ${err instanceof Error ? err.message : String(err)}`);
2408
+ process.exit(1);
2409
+ }
2410
+ return;
2411
+ }
2373
2412
  try {
2374
2413
  api.forget(ctx, id);
2375
2414
  updateStats(hippoRoot, { forgotten: 1 });
2376
2415
  console.log(`Forgot ${id}`);
2377
2416
  }
2378
- catch {
2379
- console.error(`Memory not found: ${id}`);
2417
+ catch (err) {
2418
+ const msg = err instanceof Error ? err.message : String(err);
2419
+ if (/append-only/i.test(msg)) {
2420
+ // The delete was refused by the append-only trigger — this is a raw
2421
+ // memory, not a missing one. Point the user at the archive path.
2422
+ console.error(`Cannot forget ${id}: it is a raw, append-only memory. ` +
2423
+ `Archive it instead: hippo forget ${id} --archive --reason "<why>"`);
2424
+ }
2425
+ else {
2426
+ console.error(`Memory not found: ${id}`);
2427
+ }
2380
2428
  process.exit(1);
2381
2429
  }
2382
2430
  }
@@ -4841,6 +4889,8 @@ Commands:
4841
4889
  current show Active task + recent session events (default)
4842
4890
  --json Output as JSON
4843
4891
  forget <id> Force remove a memory
4892
+ --archive Archive a raw (append-only) memory instead of deleting
4893
+ --reason "<why>" Reason recorded on the archive (required with --archive)
4844
4894
  inspect <id> Show full memory detail
4845
4895
  embed Embed all memories for semantic search
4846
4896
  --status Show embedding coverage
@@ -5283,19 +5333,24 @@ async function main() {
5283
5333
  console.error('Please provide a memory ID.');
5284
5334
  process.exit(1);
5285
5335
  }
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);
5336
+ // A3: --archive is a direct-DB operation (raw archival via archiveRaw);
5337
+ // the HTTP forget route does not carry it, so archive requests always
5338
+ // take the direct path rather than silently no-op'ing over a server.
5339
+ if (flags['archive'] !== true) {
5340
+ const routed = await runViaServerIfAvailable(hippoRoot, async (info, apiKey) => {
5341
+ try {
5342
+ await client.forget(info.url, apiKey, id);
5343
+ console.log(`Forgot ${id}`);
5344
+ }
5345
+ catch (err) {
5346
+ console.error(err.message);
5347
+ process.exit(1);
5348
+ }
5349
+ });
5350
+ if (routed)
5351
+ break;
5352
+ }
5353
+ cmdForget(hippoRoot, id, flags);
5299
5354
  break;
5300
5355
  }
5301
5356
  case 'inspect': {