hippo-memory 1.56.0 → 1.58.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 (129) hide show
  1. package/README.md +11 -0
  2. package/dist/agent-memories/claude-code.js +1 -1
  3. package/dist/agent-memories/gemini.js +1 -1
  4. package/dist/api-errors.d.ts +27 -0
  5. package/dist/api-errors.js +37 -0
  6. package/dist/api.d.ts +21 -14
  7. package/dist/api.js +97 -71
  8. package/dist/audit.d.ts +4 -0
  9. package/dist/audit.js +11 -0
  10. package/dist/autolearn.d.ts +1 -1
  11. package/dist/autolearn.js +7 -5
  12. package/dist/capture-contract.d.ts +47 -0
  13. package/dist/capture-contract.js +49 -0
  14. package/dist/capture-error.js +2 -1
  15. package/dist/capture.d.ts +0 -13
  16. package/dist/capture.js +5 -66
  17. package/dist/card-detail.d.ts +1 -1
  18. package/dist/card-detail.js +1 -1
  19. package/dist/cli/shared.d.ts +137 -0
  20. package/dist/cli/shared.js +834 -0
  21. package/dist/cli/sleep.d.ts +10 -0
  22. package/dist/cli/sleep.js +171 -0
  23. package/dist/cli.d.ts +0 -7
  24. package/dist/cli.js +322 -1827
  25. package/dist/client.js +9 -0
  26. package/dist/codex-patch.js +1 -1
  27. package/dist/compaction-record.d.ts +1 -1
  28. package/dist/compaction-record.js +3 -2
  29. package/dist/config.d.ts +5 -0
  30. package/dist/config.js +17 -0
  31. package/dist/connectors/github/dlq.js +5 -2
  32. package/dist/connectors/github/octokit-client.js +4 -2
  33. package/dist/connectors/github/webhook.d.ts +19 -0
  34. package/dist/connectors/github/webhook.js +313 -0
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/connectors/slack/webhook.d.ts +22 -0
  38. package/dist/connectors/slack/webhook.js +203 -0
  39. package/dist/consolidate.d.ts +10 -0
  40. package/dist/consolidate.js +38 -35
  41. package/dist/context-auto.d.ts +3 -0
  42. package/dist/context-auto.js +34 -0
  43. package/dist/customer-notes.js +16 -14
  44. package/dist/dag.js +3 -2
  45. package/dist/dashboard.js +3 -2
  46. package/dist/db.d.ts +12 -0
  47. package/dist/db.js +62 -1
  48. package/dist/decisions.js +11 -9
  49. package/dist/doctor.js +5 -0
  50. package/dist/embedding-provider.js +3 -3
  51. package/dist/embeddings.d.ts +4 -4
  52. package/dist/embeddings.js +72 -16
  53. package/dist/eval-stats.d.ts +58 -0
  54. package/dist/eval-stats.js +111 -0
  55. package/dist/extract.js +3 -2
  56. package/dist/goals.d.ts +49 -25
  57. package/dist/goals.js +39 -22
  58. package/dist/graph-extract.js +1 -1
  59. package/dist/graph-recall.d.ts +1 -1
  60. package/dist/graph-recall.js +1 -1
  61. package/dist/graph.js +1 -1
  62. package/dist/hooks.d.ts +1 -3
  63. package/dist/hooks.js +2 -4
  64. package/dist/http-retry.d.ts +21 -0
  65. package/dist/http-retry.js +50 -0
  66. package/dist/http-util.d.ts +39 -0
  67. package/dist/http-util.js +56 -0
  68. package/dist/importers.d.ts +2 -0
  69. package/dist/importers.js +16 -5
  70. package/dist/incidents.js +13 -11
  71. package/dist/index.d.ts +5 -2
  72. package/dist/index.js +5 -2
  73. package/dist/judgment.js +10 -17
  74. package/dist/log.d.ts +25 -0
  75. package/dist/log.js +48 -0
  76. package/dist/mcp/server.js +224 -308
  77. package/dist/mcp/tool-args.d.ts +21 -0
  78. package/dist/mcp/tool-args.js +80 -0
  79. package/dist/memory.d.ts +19 -0
  80. package/dist/memory.js +41 -2
  81. package/dist/overlap-index.d.ts +7 -0
  82. package/dist/overlap-index.js +38 -0
  83. package/dist/pilot-arm.d.ts +9 -0
  84. package/dist/pilot-arm.js +47 -0
  85. package/dist/policies.js +14 -12
  86. package/dist/predictions.js +11 -9
  87. package/dist/processes.js +16 -14
  88. package/dist/project-briefs.js +19 -16
  89. package/dist/project-identity.d.ts +1 -1
  90. package/dist/project-identity.js +25 -1
  91. package/dist/prompt-recall.js +1 -1
  92. package/dist/raw-archive.js +7 -6
  93. package/dist/recall-history.d.ts +5 -0
  94. package/dist/recall-history.js +9 -0
  95. package/dist/recall-pipeline.d.ts +101 -0
  96. package/dist/recall-pipeline.js +313 -0
  97. package/dist/recall-scope.d.ts +24 -1
  98. package/dist/recall-scope.js +29 -2
  99. package/dist/refine-llm.js +3 -2
  100. package/dist/reject-flow.js +6 -9
  101. package/dist/rejection.d.ts +2 -1
  102. package/dist/rejection.js +2 -1
  103. package/dist/search.d.ts +0 -20
  104. package/dist/search.js +16 -51
  105. package/dist/secret-detect.d.ts +13 -1
  106. package/dist/secret-detect.js +33 -1
  107. package/dist/server.d.ts +3 -1
  108. package/dist/server.js +1854 -2566
  109. package/dist/session-digest.js +2 -1
  110. package/dist/shared.js +7 -6
  111. package/dist/skills.js +17 -15
  112. package/dist/store-cards.d.ts +53 -0
  113. package/dist/store-cards.js +512 -0
  114. package/dist/store.d.ts +2 -89
  115. package/dist/store.js +10 -566
  116. package/dist/tenant.d.ts +22 -0
  117. package/dist/tenant.js +26 -0
  118. package/dist/token-ledger.d.ts +4 -2
  119. package/dist/token-ledger.js +2 -2
  120. package/dist/tokenize.d.ts +2 -0
  121. package/dist/tokenize.js +8 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  125. package/extensions/openclaw-plugin/package.json +1 -1
  126. package/openclaw.plugin.json +1 -1
  127. package/package.json +1 -1
  128. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  129. package/dist/connectors/slack/ratelimit.js +0 -18
@@ -6,6 +6,29 @@
6
6
  * these for its own call sites AND re-exports them for back-compat
7
7
  * (`api.isPrivateScope`, test imports of `passesScopeFilterForRecall`).
8
8
  */
9
+ import { ForbiddenError } from './api-errors.js';
10
+ /**
11
+ * Literal scopes excluded from recall by default-deny when the
12
+ * caller passes no `scope`. The SQL clause in `loadSearchRows` and the JS
13
+ * helper `passesScopeFilterForRecall` (src/api.ts) both read from this
14
+ * constant. Adding a deny scope is a one-place change.
15
+ *
16
+ * Regex-based denies (e.g. `<source>:private:*`) stay in
17
+ * `passesScopeFilterForRecall` as a separate JS step — they don't translate
18
+ * cleanly to SQL.
19
+ *
20
+ * Invariant: never empty. An empty array would silently allow quarantine
21
+ * scopes through both paths (SQL clause omitted, JS check vacuous). The
22
+ * module-load assertion below pins this loudly.
23
+ */
24
+ export declare const RECALL_DEFAULT_DENY_SCOPES: readonly ["unknown:legacy"];
25
+ /**
26
+ * @internal Runtime guard against a future maintainer blanking a
27
+ * load-bearing literal array. Extracted from the inline guard so the throw
28
+ * path is directly testable. `as const` arrays widen via `readonly T[]` at
29
+ * the call site so the empty case is reachable at runtime.
30
+ */
31
+ export declare function assertNonEmpty<T>(arr: readonly T[], name: string): void;
9
32
  /**
10
33
  * v1.2.1: source-agnostic private-scope detector. A scope string is treated
11
34
  * as private when it has the shape `<lowercase-source>:private:<rest>`.
@@ -61,7 +84,7 @@ export declare function passesCliRecallScopeFilter(scope: string | null, request
61
84
  * Thrown when a caller requests a scope its role may not read. The HTTP layer
62
85
  * maps it to 403.
63
86
  */
64
- export declare class ScopeForbiddenError extends Error {
87
+ export declare class ScopeForbiddenError extends ForbiddenError {
65
88
  readonly scope: string;
66
89
  constructor(scope: string);
67
90
  }
@@ -6,7 +6,34 @@
6
6
  * these for its own call sites AND re-exports them for back-compat
7
7
  * (`api.isPrivateScope`, test imports of `passesScopeFilterForRecall`).
8
8
  */
9
- import { RECALL_DEFAULT_DENY_SCOPES } from './store.js';
9
+ import { ForbiddenError } from './api-errors.js';
10
+ /**
11
+ * Literal scopes excluded from recall by default-deny when the
12
+ * caller passes no `scope`. The SQL clause in `loadSearchRows` and the JS
13
+ * helper `passesScopeFilterForRecall` (src/api.ts) both read from this
14
+ * constant. Adding a deny scope is a one-place change.
15
+ *
16
+ * Regex-based denies (e.g. `<source>:private:*`) stay in
17
+ * `passesScopeFilterForRecall` as a separate JS step — they don't translate
18
+ * cleanly to SQL.
19
+ *
20
+ * Invariant: never empty. An empty array would silently allow quarantine
21
+ * scopes through both paths (SQL clause omitted, JS check vacuous). The
22
+ * module-load assertion below pins this loudly.
23
+ */
24
+ export const RECALL_DEFAULT_DENY_SCOPES = ['unknown:legacy'];
25
+ /**
26
+ * @internal Runtime guard against a future maintainer blanking a
27
+ * load-bearing literal array. Extracted from the inline guard so the throw
28
+ * path is directly testable. `as const` arrays widen via `readonly T[]` at
29
+ * the call site so the empty case is reachable at runtime.
30
+ */
31
+ export function assertNonEmpty(arr, name) {
32
+ if (arr.length === 0) {
33
+ throw new Error(`${name} cannot be empty — would silently allow quarantine scopes`);
34
+ }
35
+ }
36
+ assertNonEmpty(RECALL_DEFAULT_DENY_SCOPES, 'RECALL_DEFAULT_DENY_SCOPES');
10
37
  /**
11
38
  * v1.2.1: source-agnostic private-scope detector. A scope string is treated
12
39
  * as private when it has the shape `<lowercase-source>:private:<rest>`.
@@ -79,7 +106,7 @@ export function passesCliRecallScopeFilter(scope, requested) {
79
106
  * Thrown when a caller requests a scope its role may not read. The HTTP layer
80
107
  * maps it to 403.
81
108
  */
82
- export class ScopeForbiddenError extends Error {
109
+ export class ScopeForbiddenError extends ForbiddenError {
83
110
  scope;
84
111
  constructor(scope) {
85
112
  super(`scope ${scope} requires admin role`);
@@ -15,6 +15,7 @@
15
15
  import { Layer } from './memory.js';
16
16
  import { loadAllEntries, readEntry, writeEntry } from './store.js';
17
17
  import { redactSecrets } from './secret-detect.js';
18
+ import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
18
19
  const REFINED_TAG = 'llm-refined';
19
20
  const CONSOLIDATED_MARKERS = [
20
21
  '[Consolidated from',
@@ -48,7 +49,7 @@ Source memories (up to 8 shown):
48
49
  ${sourceBlock}`;
49
50
  let res;
50
51
  try {
51
- res = await fetchFn('https://api.anthropic.com/v1/messages', {
52
+ res = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
52
53
  method: 'POST',
53
54
  headers: {
54
55
  'content-type': 'application/json',
@@ -60,7 +61,7 @@ ${sourceBlock}`;
60
61
  max_tokens: 800,
61
62
  messages: [{ role: 'user', content: prompt }],
62
63
  }),
63
- });
64
+ }, { timeoutMs: llmTimeoutMs(), fetchFn });
64
65
  }
65
66
  catch {
66
67
  return null;
@@ -12,7 +12,7 @@
12
12
  * cli.ts and api.ts, so it introduces no cycle.
13
13
  */
14
14
  import { closeHippoDb } from './db.js';
15
- import { appendAuditEvent } from './audit.js';
15
+ import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
16
16
  import { archiveRawMemory } from './raw-archive.js';
17
17
  import { deleteDormantRow, listDormantSnapshots, purgeDormantByDigest, replaceDormantEntry } from './dormant.js';
18
18
  import { openStore, deleteEntryCore, purgeMirrorBestEffort, selectAllEntries, stampOriginProject, writeEntryDbOnly, writeEntryMirrors, } from './store.js';
@@ -144,12 +144,9 @@ export function rejectValue(opts) {
144
144
  metadata: { digest, removedIds, count: removedIds.length },
145
145
  });
146
146
  }
147
- catch {
148
- // Best-effort — mirrors store.ts's private audit() semantics. This
149
- // runs INSIDE the still-open transaction (COMMIT is the next
150
- // statement): a swallowed audit failure lets the tombstone +
151
- // removals commit without the trail row, rather than rolling the
152
- // whole reject back over bookkeeping.
147
+ catch (error) {
148
+ // Inside the open transaction: the reject commits without its trail row rather than rolling back over bookkeeping.
149
+ reportAuditWriteFailure('reject_value', String(error), opts.memoryId);
153
150
  }
154
151
  db.exec('COMMIT');
155
152
  }
@@ -217,8 +214,8 @@ export function unrejectValue(hippoRoot, tenantId, digestOrPrefix, actor) {
217
214
  metadata: { digest: target.digest, reason: target.reason },
218
215
  });
219
216
  }
220
- catch {
221
- // Best-effort, same as reject's audit call above.
217
+ catch (error) {
218
+ reportAuditWriteFailure('unreject_value', String(error), target.sourceMemoryId);
222
219
  }
223
220
  return { status: 'ok', digest: target.digest, reason: target.reason };
224
221
  }
@@ -12,6 +12,7 @@
12
12
  * be a cycle.
13
13
  */
14
14
  import type { DatabaseSyncLike } from './db.js';
15
+ import { BadRequestError } from './api-errors.js';
15
16
  /**
16
17
  * Normalize content for rejection-digest comparisons: Unicode NFC →
17
18
  * lowercase → collapse whitespace runs to a single space → trim. No
@@ -33,7 +34,7 @@ export declare function rejectionDigest(content: string): string;
33
34
  * blocks (writeEntry, api.supersede) to write a post-rollback
34
35
  * `reject_refusal` audit row via `auditRejectionRefusal` (plan §3).
35
36
  */
36
- export declare class RejectedValueError extends Error {
37
+ export declare class RejectedValueError extends BadRequestError {
37
38
  readonly digest: string;
38
39
  readonly tenantId: string;
39
40
  readonly entryId: string;
package/dist/rejection.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * be a cycle.
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
+ import { BadRequestError } from './api-errors.js';
15
16
  /**
16
17
  * Normalize content for rejection-digest comparisons: Unicode NFC →
17
18
  * lowercase → collapse whitespace runs to a single space → trim. No
@@ -37,7 +38,7 @@ export function rejectionDigest(content) {
37
38
  * blocks (writeEntry, api.supersede) to write a post-rollback
38
39
  * `reject_refusal` audit row via `auditRejectionRefusal` (plan §3).
39
40
  */
40
- export class RejectedValueError extends Error {
41
+ export class RejectedValueError extends BadRequestError {
41
42
  digest;
42
43
  tenantId;
43
44
  entryId;
package/dist/search.d.ts CHANGED
@@ -7,7 +7,6 @@ import { MemoryEntry } from './memory.js';
7
7
  import type { PhysicsConfig } from './physics-config.js';
8
8
  export declare const CHURN_STALE_RANK_MULTIPLIER = 0.5;
9
9
  export declare function churnStaleFactor(entry: MemoryEntry): number;
10
- export declare function tokenize(text: string): string[];
11
10
  /**
12
11
  * Tokenized BM25 corpus. Callers can pre-build this with `buildCorpus` once
13
12
  * and reuse across many `hybridSearch` calls on the same entry set — the
@@ -287,25 +286,6 @@ export declare function search(query: string, entries: MemoryEntry[], options?:
287
286
  includeSuperseded?: boolean;
288
287
  asOf?: string;
289
288
  }): SearchResult[];
290
- /**
291
- * Update retrieval metadata on entries that were returned by a search.
292
- * Returns the mutated copies (caller must persist to disk).
293
- *
294
- * EVAL-ONLY ablation (see ablation.ts): with HIPPO_ABLATE_RECALL_BOOST set,
295
- * this returns the entries UNMUTATED - neutralizing all three strengthening
296
- * sub-effects (clock reset, retrieval_count, half-life increment) at the
297
- * single shared write site. The entries (not an empty array) must be
298
- * returned because callers derive `last_retrieval_ids` from the return
299
- * value, and a later `hippo outcome --good/--bad` targets those ids - an
300
- * empty return would silently co-ablate the outcome channel in the
301
- * strengthen-off arm (codex round-7 P2). PERSISTENCE is gated separately at
302
- * each persisting caller (CLI recall, api context, MCP recall/context,
303
- * consolidation replay): writeEntry on identical rows still refreshes
304
- * updated_at, rewrites mirrors, and marks DAG parents dirty (codex round-6
305
- * P2), so those write loops skip under the flag.
306
- * The default `now` honors HIPPO_FAKE_NOW (simulated-time protocols).
307
- */
308
- export declare function markRetrieved(entries: MemoryEntry[], now?: Date): MemoryEntry[];
309
289
  export interface MatchExplanation {
310
290
  /** Human-readable reason string */
311
291
  reason: string;
package/dist/search.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * Zero external dependencies when embeddings are not available.
4
4
  */
5
5
  import { estimateTokens } from './token-ledger.js';
6
- import { calculateStrength, netWrong, CHURN_STALE_TAG } from './memory.js';
6
+ import { calculateStrength, CHURN_STALE_TAG } from './memory.js';
7
+ import { tokenize } from './tokenize.js';
7
8
  import { isOutcomeFastAblated, isRecallBoostAblated, isRecencyAblated, evalRecencyScaleDays, evalNow } from './ablation.js';
8
9
  import { extractPathTags, pathBoostMultiplier } from './path-context.js';
9
10
  import { detectScope, scopeMatch } from './scope.js';
@@ -16,20 +17,16 @@ import { openHippoDb, closeHippoDb } from './db.js';
16
17
  import { rrfFuse } from './rrf.js';
17
18
  import { graphRankStream, selectGraphSeeds, DEFAULT_GRAPH_SEED_COUNT } from './graph-stream.js';
18
19
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
20
+ import { log } from './log.js';
21
+ import { redactSecretsStrict } from './secret-detect.js';
19
22
  export const CHURN_STALE_RANK_MULTIPLIER = 0.5; // SHORTCUT: untuned; FE3 measures before any default.
23
+ // Search runs on every hook prompt, so one line per reason per process says why vectors went unused without flooding stderr.
24
+ function warnBm25Fallback(reason, detail) {
25
+ log.once(`search.bm25-fallback.${reason}`, 'warn', `hybrid search fell back to BM25 only: ${redactSecretsStrict(detail)}`);
26
+ }
20
27
  export function churnStaleFactor(entry) {
21
28
  return entry.tags.includes(CHURN_STALE_TAG) ? CHURN_STALE_RANK_MULTIPLIER : 1.0;
22
29
  }
23
- // ---------------------------------------------------------------------------
24
- // Tokenizer
25
- // ---------------------------------------------------------------------------
26
- export function tokenize(text) {
27
- return text
28
- .toLowerCase()
29
- .replace(/[^\w\s]/g, ' ')
30
- .split(/\s+/)
31
- .filter((t) => t.length > 1);
32
- }
33
30
  const BM25_K1 = 1.5;
34
31
  const BM25_B = 0.75;
35
32
  export function buildCorpus(texts) {
@@ -276,11 +273,17 @@ export async function hybridSearch(query, entries, options = {}) {
276
273
  embeddingIndex = idx;
277
274
  useEmbeddings = true;
278
275
  }
276
+ else {
277
+ warnBm25Fallback('empty-query-vector', 'the embedding provider returned no vector for the query');
278
+ }
279
279
  }
280
280
  }
281
+ else if (provider.isAvailable()) {
282
+ warnBm25Fallback('reindex', "the embedding index was built by another model or is being rebuilt; run 'hippo embed'");
283
+ }
281
284
  }
282
- catch {
283
- // Fall through to BM25-only
285
+ catch (err) {
286
+ warnBm25Fallback('error', err instanceof Error ? err.message : String(err));
284
287
  }
285
288
  }
286
289
  // Compute cosine similarities for RRF ranking (need all before scoring)
@@ -902,44 +905,6 @@ export function search(query, entries, options = {}) {
902
905
  }
903
906
  return fitBudget(dedupedSync, budget, minResults, options.cost);
904
907
  }
905
- /**
906
- * Update retrieval metadata on entries that were returned by a search.
907
- * Returns the mutated copies (caller must persist to disk).
908
- *
909
- * EVAL-ONLY ablation (see ablation.ts): with HIPPO_ABLATE_RECALL_BOOST set,
910
- * this returns the entries UNMUTATED - neutralizing all three strengthening
911
- * sub-effects (clock reset, retrieval_count, half-life increment) at the
912
- * single shared write site. The entries (not an empty array) must be
913
- * returned because callers derive `last_retrieval_ids` from the return
914
- * value, and a later `hippo outcome --good/--bad` targets those ids - an
915
- * empty return would silently co-ablate the outcome channel in the
916
- * strengthen-off arm (codex round-7 P2). PERSISTENCE is gated separately at
917
- * each persisting caller (CLI recall, api context, MCP recall/context,
918
- * consolidation replay): writeEntry on identical rows still refreshes
919
- * updated_at, rewrites mirrors, and marks DAG parents dirty (codex round-6
920
- * P2), so those write loops skip under the flag.
921
- * The default `now` honors HIPPO_FAKE_NOW (simulated-time protocols).
922
- */
923
- // Confidence is deliberately absent below: it is an epistemic tier, not a
924
- // recency signal, and a stored 'stale' is always a deliberate mark (2026-09-07).
925
- export function markRetrieved(entries, now = evalNow()) {
926
- if (isRecallBoostAblated())
927
- return entries;
928
- return entries.map((e) => {
929
- if (e.superseded_by)
930
- return e;
931
- const wrong = netWrong(e) > 0;
932
- const updated = {
933
- ...e,
934
- retrieval_count: e.retrieval_count + 1,
935
- last_retrieved: wrong ? e.last_retrieved : now.toISOString(),
936
- // +2 days half-life per retrieval (PLAN.md); a wrong memory keeps both, since last_retrieved is the decay anchor
937
- half_life_days: wrong ? e.half_life_days : e.half_life_days + 2,
938
- };
939
- updated.strength = calculateStrength(updated, now);
940
- return updated;
941
- });
942
- }
943
908
  /**
944
909
  * Explain why a search result matched a query.
945
910
  * Computes which query terms overlapped with the document and whether
@@ -12,7 +12,8 @@
12
12
  * a deliberate act.
13
13
  *
14
14
  * This is deliberately a thin slice of the A4 lifecycle-compliance item
15
- * (no write-time scrubbing, no PII detection).
15
+ * (no PII detection). Write-time scrubbing covers only text no person
16
+ * typed into hippo; see `vetSecrets`.
16
17
  *
17
18
  * Leaf module: keep free of imports from store/api/shared so all of them
18
19
  * can import it without cycles.
@@ -42,4 +43,15 @@ export declare function redactSecrets(text: string): string;
42
43
  export declare function maskEmails(text: string): string;
43
44
  /** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer and Basic auth headers and JWTs. */
44
45
  export declare function redactSecretsStrict(text: string): string;
46
+ /** The text a write keeps, plus what to tell its caller about secret material in it. */
47
+ export interface SecretVet {
48
+ content: string;
49
+ warnings: string[];
50
+ }
51
+ /** Write-time veto: `scrub` text no person typed gets capture's strict redaction; typed text is a person's own call, so it is kept and flagged. */
52
+ export declare function vetSecrets(content: string, tags: readonly string[], scrub: boolean): SecretVet;
53
+ /** Appended to a dead-letter row's error when `redactPayload` changed its body. */
54
+ export declare const DLQ_REDACTED_NOTE = "secret-shaped text redacted, signature dropped; replay needs --force";
55
+ /** `vetSecrets` redaction for a raw webhook body: JSON stays valid for replay, and an untouched body stays byte-identical so its signature still verifies. */
56
+ export declare function redactPayload(raw: string): string;
45
57
  //# sourceMappingURL=secret-detect.d.ts.map
@@ -12,7 +12,8 @@
12
12
  * a deliberate act.
13
13
  *
14
14
  * This is deliberately a thin slice of the A4 lifecycle-compliance item
15
- * (no write-time scrubbing, no PII detection).
15
+ * (no PII detection). Write-time scrubbing covers only text no person
16
+ * typed into hippo; see `vetSecrets`.
16
17
  *
17
18
  * Leaf module: keep free of imports from store/api/shared so all of them
18
19
  * can import it without cycles.
@@ -100,6 +101,37 @@ export function maskEmails(text) {
100
101
  export function redactSecretsStrict(text) {
101
102
  return redactText(text, true);
102
103
  }
104
+ /** Write-time veto: `scrub` text no person typed gets capture's strict redaction; typed text is a person's own call, so it is kept and flagged. */
105
+ export function vetSecrets(content, tags, scrub) {
106
+ const kept = scrub ? redactSecretsStrict(content) : content;
107
+ const warnings = [];
108
+ if (kept !== content) {
109
+ const found = detectSecret({ content, tags: [] }).reason;
110
+ warnings.push(`secret-shaped text was redacted before storing${found ? ` (${found})` : ''}`);
111
+ }
112
+ const left = detectSecret({ content: kept, tags: [...tags] });
113
+ if (left.flagged) {
114
+ warnings.push(`content looks like a secret (${left.reason}) and was stored as sent; forget it if it should not be kept`);
115
+ }
116
+ return { content: kept, warnings };
117
+ }
118
+ const JSON_STRING = /"(?:[^"\\]|\\.)*"/g;
119
+ /** Appended to a dead-letter row's error when `redactPayload` changed its body. */
120
+ export const DLQ_REDACTED_NOTE = 'secret-shaped text redacted, signature dropped; replay needs --force';
121
+ /** `vetSecrets` redaction for a raw webhook body: JSON stays valid for replay, and an untouched body stays byte-identical so its signature still verifies. */
122
+ export function redactPayload(raw) {
123
+ try {
124
+ JSON.parse(raw);
125
+ }
126
+ catch {
127
+ return redactSecretsStrict(raw);
128
+ }
129
+ return raw.replace(JSON_STRING, (literal) => {
130
+ const text = JSON.parse(literal);
131
+ const kept = redactSecretsStrict(text);
132
+ return kept === text ? literal : JSON.stringify(kept);
133
+ });
134
+ }
103
135
  function redactText(text, strict) {
104
136
  if (!text)
105
137
  return text;
package/dist/server.d.ts CHANGED
@@ -66,7 +66,9 @@ export declare function clientIpForRateLimit(req: IncomingMessage): string;
66
66
  * webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
67
67
  * secrets and 404 when those secrets are unset. But the loopback
68
68
  * no-auth fallback inside buildContextWithAuth still admits unauthenticated
69
- * requests from a loopback remote address, so binding to a non-loopback host
69
+ * requests from a loopback remote address (unless they carry Forwarded,
70
+ * X-Forwarded-For/-Host/-Proto, X-Real-IP, Cf-Connecting-Ip or True-Client-Ip, which mark a same-host proxy and get
71
+ * a 401 like any keyless remote request), so binding to a non-loopback host
70
72
  * is only safe once that fallback is disabled with HIPPO_REQUIRE_AUTH=1,
71
73
  * which forces every request (loopback or not) through Bearer-token
72
74
  * validation. Without that env var set, a non-loopback bind would expose the