hippo-memory 1.57.0 → 1.59.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 (109) hide show
  1. package/README.md +24 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.js +1 -1
  4. package/dist/agent-memories/gemini.js +1 -1
  5. package/dist/agent-memories/legacy.js +4 -1
  6. package/dist/api-errors.d.ts +27 -0
  7. package/dist/api-errors.js +37 -0
  8. package/dist/api.d.ts +5 -5
  9. package/dist/api.js +40 -47
  10. package/dist/audit.d.ts +5 -1
  11. package/dist/audit.js +13 -0
  12. package/dist/autolearn.d.ts +1 -1
  13. package/dist/autolearn.js +7 -5
  14. package/dist/capture-contract.d.ts +47 -0
  15. package/dist/capture-contract.js +49 -0
  16. package/dist/capture-error.js +2 -1
  17. package/dist/capture.d.ts +0 -13
  18. package/dist/capture.js +5 -66
  19. package/dist/cli/output.d.ts +3 -0
  20. package/dist/cli/output.js +7 -0
  21. package/dist/cli/projects.d.ts +4 -0
  22. package/dist/cli/projects.js +90 -0
  23. package/dist/cli/shared.js +23 -13
  24. package/dist/cli/sleep.js +5 -3
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +486 -397
  27. package/dist/client.js +9 -0
  28. package/dist/codex-patch.js +1 -1
  29. package/dist/compaction-record.d.ts +1 -1
  30. package/dist/compaction-record.js +3 -2
  31. package/dist/config.d.ts +5 -0
  32. package/dist/config.js +17 -0
  33. package/dist/connectors/github/dlq.js +5 -2
  34. package/dist/connectors/github/octokit-client.js +4 -2
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/consolidate.d.ts +10 -0
  38. package/dist/consolidate.js +48 -35
  39. package/dist/customer-notes.js +14 -13
  40. package/dist/dag.js +7 -4
  41. package/dist/dashboard.js +1 -1
  42. package/dist/db.d.ts +12 -0
  43. package/dist/db.js +62 -1
  44. package/dist/decisions.js +9 -8
  45. package/dist/dedupe.js +1 -1
  46. package/dist/doctor.js +28 -0
  47. package/dist/dormant.d.ts +2 -2
  48. package/dist/embedding-provider.js +3 -3
  49. package/dist/embeddings.d.ts +4 -4
  50. package/dist/embeddings.js +72 -16
  51. package/dist/extract.js +19 -18
  52. package/dist/http-retry.d.ts +21 -0
  53. package/dist/http-retry.js +50 -0
  54. package/dist/http-util.d.ts +8 -0
  55. package/dist/http-util.js +10 -0
  56. package/dist/importers.d.ts +2 -0
  57. package/dist/importers.js +16 -5
  58. package/dist/incidents.js +11 -10
  59. package/dist/judgment.js +10 -17
  60. package/dist/log.d.ts +25 -0
  61. package/dist/log.js +48 -0
  62. package/dist/mcp/server.js +52 -24
  63. package/dist/mcp/tool-args.d.ts +21 -0
  64. package/dist/mcp/tool-args.js +80 -0
  65. package/dist/memory.js +3 -2
  66. package/dist/overlap-index.d.ts +7 -0
  67. package/dist/overlap-index.js +38 -0
  68. package/dist/pilot-arm.d.ts +9 -0
  69. package/dist/pilot-arm.js +47 -0
  70. package/dist/policies.js +12 -11
  71. package/dist/predictions.js +9 -8
  72. package/dist/processes.js +14 -13
  73. package/dist/project-briefs.js +16 -15
  74. package/dist/project-identity.d.ts +1 -1
  75. package/dist/project-identity.js +25 -1
  76. package/dist/project-merge.d.ts +52 -0
  77. package/dist/project-merge.js +168 -0
  78. package/dist/raw-archive.js +7 -6
  79. package/dist/recall-scope.d.ts +5 -4
  80. package/dist/recall-scope.js +7 -5
  81. package/dist/refine-llm.js +3 -2
  82. package/dist/reject-flow.js +6 -9
  83. package/dist/rejection.d.ts +2 -1
  84. package/dist/rejection.js +2 -1
  85. package/dist/rerankers/clef.d.ts +29 -0
  86. package/dist/rerankers/clef.js +182 -0
  87. package/dist/rerankers/index.js +3 -0
  88. package/dist/rerankers/jev.d.ts +11 -0
  89. package/dist/rerankers/jev.js +10 -5
  90. package/dist/rerankers/types.d.ts +16 -0
  91. package/dist/search.js +14 -2
  92. package/dist/secret-detect.d.ts +13 -1
  93. package/dist/secret-detect.js +33 -1
  94. package/dist/server.d.ts +9 -2
  95. package/dist/server.js +188 -411
  96. package/dist/session-digest.js +2 -1
  97. package/dist/shared.js +7 -6
  98. package/dist/skills.js +15 -14
  99. package/dist/store.js +10 -10
  100. package/dist/token-ledger.d.ts +4 -2
  101. package/dist/token-ledger.js +2 -2
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  105. package/extensions/openclaw-plugin/package.json +1 -1
  106. package/openclaw.plugin.json +1 -1
  107. package/package.json +5 -2
  108. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  109. package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/client.js CHANGED
@@ -13,6 +13,8 @@
13
13
  * Network errors (ECONNREFUSED on a stale pidfile, etc.) propagate as the
14
14
  * native fetch failure so the caller can detect them and self-heal.
15
15
  */
16
+ /** A write the local server has not answered in this long is stuck; the caller treats it as delivery-unknown. */
17
+ const SERVER_TIMEOUT_MS = 30_000;
16
18
  function buildHeaders(apiKey, withBody) {
17
19
  const headers = {};
18
20
  if (withBody)
@@ -58,6 +60,7 @@ export async function remember(serverUrl, apiKey, opts) {
58
60
  const res = await fetch(`${serverUrl}/v1/memories`, {
59
61
  method: 'POST',
60
62
  headers: buildHeaders(apiKey, true),
63
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
61
64
  body: JSON.stringify(opts),
62
65
  });
63
66
  if (!res.ok)
@@ -69,6 +72,7 @@ export async function forget(serverUrl, apiKey, id) {
69
72
  const res = await fetch(`${serverUrl}/v1/memories/${encodeURIComponent(id)}`, {
70
73
  method: 'DELETE',
71
74
  headers: buildHeaders(apiKey, false),
75
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
72
76
  });
73
77
  if (!res.ok)
74
78
  await throwForStatus(res);
@@ -79,6 +83,7 @@ export async function promote(serverUrl, apiKey, id) {
79
83
  const res = await fetch(`${serverUrl}/v1/memories/${encodeURIComponent(id)}/promote`, {
80
84
  method: 'POST',
81
85
  headers: buildHeaders(apiKey, false),
86
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
82
87
  });
83
88
  if (!res.ok)
84
89
  await throwForStatus(res);
@@ -89,6 +94,7 @@ export async function archiveRaw(serverUrl, apiKey, id, reason) {
89
94
  const res = await fetch(`${serverUrl}/v1/memories/${encodeURIComponent(id)}/archive`, {
90
95
  method: 'POST',
91
96
  headers: buildHeaders(apiKey, true),
97
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
92
98
  body: JSON.stringify({ reason }),
93
99
  });
94
100
  if (!res.ok)
@@ -123,6 +129,9 @@ export function classifyTransportFailure(err) {
123
129
  }
124
130
  if (message.includes('socket hang up') || message.includes('fetch failed'))
125
131
  return 'delivery-unknown';
132
+ // The request was sent and the server went quiet, so it may have committed.
133
+ if (err.name === 'TimeoutError')
134
+ return 'delivery-unknown';
126
135
  return 'none';
127
136
  }
128
137
  //# sourceMappingURL=client.js.map
@@ -1,7 +1,7 @@
1
1
  // Files named by a Codex apply_patch call, read the way openai/codex codex-rs/apply-patch reads them
2
2
  // (parser.rs for the body, invocation.rs for the shell forms it runs as apply_patch).
3
3
  import * as path from 'path';
4
- import { isStringValue } from './capture.js';
4
+ import { isStringValue } from './capture-contract.js';
5
5
  /** Codex prints this line only when a patch applied (apply-patch lib.rs). */
6
6
  export const PATCH_SUCCESS_LINE = 'Success. Updated the following files:';
7
7
  const BEGIN_PATCH = '*** Begin Patch';
@@ -2,7 +2,7 @@ import { type DatabaseSyncLike } from './db.js';
2
2
  /** PostCompact has 10 s in all (PreCompact 30 s), so a locked store must be given up on early. */
3
3
  export declare const COMPACTION_DB_WAIT_MS = 2000;
4
4
  /** Tested verbatim: Claude Code hands PreCompact stdout to the summariser as instructions. */
5
- export declare const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo'. Write '- none' if nothing new remains.";
5
+ export declare const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo', and anything this session already saved with `hippo remember`. Write '- none' if nothing new remains.";
6
6
  /** Long enough that a live hook has finished with its own record. */
7
7
  export declare const REPLAY_AFTER_MS: number;
8
8
  /** Claude Code deletes transcripts after 30 days, so an older gap can never be filled. */
@@ -1,7 +1,8 @@
1
1
  // One record per Claude Code compaction, and what turns its summary into kept memories.
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
- import { errorMessage, isObjectLike, isStringValue, readTranscriptTail, truncateCodePointSafe } from './capture.js';
4
+ import { errorMessage, readTranscriptTail, truncateCodePointSafe } from './capture.js';
5
+ import { isObjectLike, isStringValue } from './capture-contract.js';
5
6
  import { compactSummaryBody, parseCompactionItems, selectItemRows } from './compaction-items.js';
6
7
  import { loadConfig } from './config.js';
7
8
  import { closeHippoDb, isSqliteBusy, openHippoDb } from './db.js';
@@ -14,7 +15,7 @@ import { resolveTenantId } from './tenant.js';
14
15
  /** PostCompact has 10 s in all (PreCompact 30 s), so a locked store must be given up on early. */
15
16
  export const COMPACTION_DB_WAIT_MS = 2000;
16
17
  /** Tested verbatim: Claude Code hands PreCompact stdout to the summariser as instructions. */
17
- export const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo'. Write '- none' if nothing new remains.";
18
+ export const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo', and anything this session already saved with `hippo remember`. Write '- none' if nothing new remains.";
18
19
  const SUMMARY_MAX_CHARS = 256 * 1024;
19
20
  /** Long enough that a live hook has finished with its own record. */
20
21
  export const REPLAY_AFTER_MS = 10 * 60_000;
package/dist/config.d.ts CHANGED
@@ -145,6 +145,11 @@ export interface HippoConfig {
145
145
  deliveryLedger: {
146
146
  enabled: boolean;
147
147
  };
148
+ /** Pilot holdout (src/pilot-arm.ts): share of sessions, in basis points 0-10000, that get no memories pushed.
149
+ * Default 0 = off. Read from the store the token ledger writes to. */
150
+ pilot: {
151
+ holdoutRateBp: number;
152
+ };
148
153
  }
149
154
  export declare function loadConfig(hippoRoot: string): HippoConfig;
150
155
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -88,6 +88,9 @@ const DEFAULT_CONFIG = {
88
88
  deliveryLedger: {
89
89
  enabled: false,
90
90
  },
91
+ pilot: {
92
+ holdoutRateBp: 0,
93
+ },
91
94
  };
92
95
  function isMemoryValueConfig(value) {
93
96
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -115,6 +118,19 @@ function deliveryLedgerEnabled(value) {
115
118
  `(got ${JSON.stringify(value)}) - using false.`);
116
119
  return false;
117
120
  }
121
+ // Only an integer 0..10000 counts; anything else warns and turns the pilot off.
122
+ function pilotHoldoutRate(value) {
123
+ if (value === undefined)
124
+ return 0;
125
+ const rate = value?.holdoutRateBp;
126
+ if (rate === undefined && value !== null && value.constructor === Object)
127
+ return 0;
128
+ if (Number.isInteger(rate) && rate >= 0 && rate <= 10000)
129
+ return rate;
130
+ console.error(`Warning: config.json's "pilot" must be an object like {"holdoutRateBp": 2000}, an integer from 0 to 10000 ` +
131
+ `(got ${JSON.stringify(value)}) - using 0 (pilot off).`);
132
+ return 0;
133
+ }
118
134
  function agentMemoryTools(value) {
119
135
  if (value === undefined || value === null)
120
136
  return null;
@@ -226,6 +242,7 @@ export function loadConfig(hippoRoot) {
226
242
  },
227
243
  agentMemories: { tools: agentMemoryTools(raw.agentMemories?.tools) },
228
244
  deliveryLedger: { enabled: deliveryLedgerEnabled(raw.deliveryLedger) },
245
+ pilot: { holdoutRateBp: pilotHoldoutRate(raw.pilot) },
229
246
  };
230
247
  }
231
248
  catch (err) {
@@ -1,13 +1,16 @@
1
1
  import { openHippoDb, closeHippoDb } from '../../db.js';
2
2
  import { verifyGitHubSignature } from './signature.js';
3
3
  import { isGitHubWebhookEnvelope } from './types.js';
4
+ import { redactPayload, DLQ_REDACTED_NOTE } from '../../secret-detect.js';
4
5
  export function writeToDlq(db, opts) {
6
+ const rawPayload = redactPayload(opts.rawPayload);
7
+ const redacted = rawPayload !== opts.rawPayload;
5
8
  const result = db
6
9
  .prepare(`INSERT INTO github_dlq
7
10
  (tenant_id, raw_payload, error, event_name, delivery_id, signature,
8
11
  installation_id, repo_full_name, received_at, bucket)
9
12
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
10
- .run(opts.tenantId ?? '__unroutable__', opts.rawPayload, opts.error, opts.eventName ?? null, opts.deliveryId ?? null, opts.signature ?? null, opts.installationId ?? null, opts.repoFullName ?? null, new Date().toISOString(), opts.bucket ?? 'parse_error');
13
+ .run(opts.tenantId ?? '__unroutable__', rawPayload, redacted ? `${opts.error}; ${DLQ_REDACTED_NOTE}` : opts.error, opts.eventName ?? null, opts.deliveryId ?? null, redacted ? null : opts.signature ?? null, opts.installationId ?? null, opts.repoFullName ?? null, new Date().toISOString(), opts.bucket ?? 'parse_error');
11
14
  return Number(result.lastInsertRowid);
12
15
  }
13
16
  const SELECT_COLUMNS = `id, tenant_id, raw_payload, error, event_name, delivery_id,
@@ -107,7 +110,7 @@ export async function replayDlqEntry(ctx, id, opts = {}) {
107
110
  status: 'sig_missing',
108
111
  memoryId: null,
109
112
  retryCount: row.retryCount,
110
- reason: 'legacy row without signature; pass --force to replay',
113
+ reason: 'row has no signature (legacy, or redacted before storing); pass --force to replay',
111
114
  };
112
115
  }
113
116
  const sigOk = verifyGitHubSignature({
@@ -10,6 +10,7 @@
10
10
  * operator signal, so this code path is now load-bearing.
11
11
  */
12
12
  import { parseRateLimit } from './ratelimit.js';
13
+ import { fetchWithRetry } from '../../http-retry.js';
13
14
  export class GitHubFetchError extends Error {
14
15
  status;
15
16
  bodyExcerpt;
@@ -42,14 +43,15 @@ function headersToRecord(h) {
42
43
  });
43
44
  return Object.fromEntries(entries);
44
45
  }
46
+ const GITHUB_TIMEOUT_MS = 30_000;
45
47
  export const realGitHubFetcher = async ({ url, token }) => {
46
- const res = await fetch(url, {
48
+ const res = await fetchWithRetry(url, {
47
49
  headers: {
48
50
  Authorization: `Bearer ${token}`,
49
51
  Accept: 'application/vnd.github+json',
50
52
  'X-GitHub-Api-Version': '2022-11-28',
51
53
  },
52
- });
54
+ }, { timeoutMs: GITHUB_TIMEOUT_MS });
53
55
  const headers = headersToRecord(res.headers);
54
56
  const rateLimit = parseRateLimit(headers, res.status);
55
57
  // Codex P1 #4: don't silently turn 401/403/404/500 into empty pages.
@@ -5,12 +5,16 @@ import { resolveTenantForTeam } from './tenant-routing.js';
5
5
  import { verifySlackSignature } from './signature.js';
6
6
  import { isSlackEventEnvelope, isSlackMessageEvent } from './types.js';
7
7
  import { handleMessageDeleted } from './deletion.js';
8
+ import { redactPayload, DLQ_REDACTED_NOTE } from '../../secret-detect.js';
8
9
  export function writeToDlq(db, opts) {
10
+ const rawPayload = redactPayload(opts.rawPayload);
11
+ // A redacted body can never match its signature, so the row keeps none and replay needs --force.
12
+ const redacted = rawPayload !== opts.rawPayload;
9
13
  const result = db
10
14
  .prepare(`INSERT INTO slack_dlq
11
15
  (tenant_id, team_id, raw_payload, error, received_at, bucket, signature, slack_timestamp)
12
16
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
13
- .run(opts.tenantId ?? '__unroutable__', opts.teamId ?? null, opts.rawPayload, opts.error, new Date().toISOString(), opts.bucket ?? 'parse_error', opts.signature ?? null, opts.slackTimestamp ?? null);
17
+ .run(opts.tenantId ?? '__unroutable__', opts.teamId ?? null, rawPayload, redacted ? `${opts.error}; ${DLQ_REDACTED_NOTE}` : opts.error, new Date().toISOString(), opts.bucket ?? 'parse_error', redacted ? null : opts.signature ?? null, opts.slackTimestamp ?? null);
14
18
  return Number(result.lastInsertRowid);
15
19
  }
16
20
  export function listDlq(db, opts) {
@@ -95,7 +99,7 @@ export function replayDlqEntry(ctx, id, opts = {}) {
95
99
  status: 'sig_missing',
96
100
  memoryId: null,
97
101
  retryCount: row.retryCount,
98
- reason: 'legacy row without signature/timestamp; pass --force to replay',
102
+ reason: 'row has no signature/timestamp (legacy, or redacted before storing); pass --force to replay',
99
103
  };
100
104
  }
101
105
  if (opts.signingSecret) {
@@ -1,7 +1,8 @@
1
- import { fetchWithRetry } from './ratelimit.js';
1
+ import { fetchWithRetry, isRetryableStatus } from '../../http-retry.js';
2
2
  function isJsonString(value) {
3
3
  return typeof value === 'string';
4
4
  }
5
+ const SLACK_TIMEOUT_MS = 30_000;
5
6
  export function slackHistoryFetcher(token, fetchImpl) {
6
7
  return async ({ channelId, cursor, oldest }) => {
7
8
  const url = new URL('https://slack.com/api/conversations.history');
@@ -11,11 +12,12 @@ export function slackHistoryFetcher(token, fetchImpl) {
11
12
  url.searchParams.set('cursor', cursor);
12
13
  if (oldest)
13
14
  url.searchParams.set('oldest', oldest);
14
- const r = await fetchWithRetry({
15
- url: url.toString(),
16
- init: { method: 'GET', headers: { authorization: `Bearer ${token}` } },
17
- fetchImpl,
15
+ const r = await fetchWithRetry(url, { method: 'GET', headers: { authorization: `Bearer ${token}` } }, {
16
+ timeoutMs: SLACK_TIMEOUT_MS,
17
+ fetchFn: fetchImpl,
18
18
  });
19
+ if (isRetryableStatus(r.status))
20
+ throw new Error(`slack: still rate-limited or unavailable (HTTP ${r.status})`);
19
21
  // SAFETY: body is the Slack `conversations.history` response; per the
20
22
  // documented shape it's `{ ok, error?, messages?, response_metadata? }`.
21
23
  const body = (await r.json());
@@ -6,6 +6,7 @@
6
6
  * 2. Merge pass - find episodic entries with high text overlap, create semantic summaries
7
7
  * 3. Stats tracking
8
8
  */
9
+ import { MemoryEntry, type DecayOptions } from './memory.js';
9
10
  export interface ConsolidationResult {
10
11
  decayed: number;
11
12
  removed: number;
@@ -45,4 +46,13 @@ export declare function consolidate(hippoRoot: string, options?: {
45
46
  now?: Date;
46
47
  fetcher?: typeof fetch;
47
48
  }): Promise<ConsolidationResult>;
49
+ /** Maps i to each j > i, ascending, whose text overlap with i reaches the merge threshold; every text needs at least one token. */
50
+ export declare function mergePartners(contents: readonly string[]): (i: number) => number[];
51
+ /** Pairs of live non-semantic memories that contradict each other, in survivor order. */
52
+ export declare function detectConflicts(entries: MemoryEntry[], now: Date, decayOpts?: DecayOptions, rescuedIds?: Set<string>): Array<{
53
+ memory_a_id: string;
54
+ memory_b_id: string;
55
+ reason: string;
56
+ score: number;
57
+ }>;
48
58
  //# sourceMappingURL=consolidate.d.ts.map
@@ -9,8 +9,8 @@
9
9
  import { evalNow, isRecallBoostAblated } from './ablation.js';
10
10
  import { Layer, calculateStrength, canAutoDelete, createMemory, markRetrieved } from './memory.js';
11
11
  import { loadAllEntries, batchWriteAndDelete, appendConsolidationRun, replaceDetectedConflicts, loadSessionDecayContext, incrementSleepCount, findPromotableSessions, traceExistsForSession, listSessionEvents, memoriesBackingObjects, } from './store.js';
12
- import { textOverlap } from './search.js';
13
12
  import { tokenize } from './tokenize.js';
13
+ import { jaccardMinShared, overlapPartners } from './overlap-index.js';
14
14
  import { compareEntryIdentity } from './compare.js';
15
15
  import { duplicateKey, mergedText } from './same-text.js';
16
16
  import { successorAfterRetirement } from './merged-row.js';
@@ -26,7 +26,7 @@ import { renderTraceContent } from './trace.js';
26
26
  import { resolveTenantId } from './tenant.js';
27
27
  import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-value.js';
28
28
  import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
29
- import { appendAuditEvent } from './audit.js';
29
+ import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
30
30
  import { migrateDefaultHalfLife, LEGACY_TYPED_HALF_LIFE } from './half-life-migration.js';
31
31
  import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
32
32
  import { isQuarantineScope } from './quarantine.js';
@@ -419,8 +419,8 @@ export async function consolidate(hippoRoot, options = {}) {
419
419
  },
420
420
  });
421
421
  }
422
- catch {
423
- // Best-effort — mirrors store.ts's audit() semantics.
422
+ catch (error) {
423
+ reportAuditWriteFailure('reject_refusal', String(error));
424
424
  }
425
425
  continue;
426
426
  }
@@ -689,7 +689,7 @@ export async function consolidate(hippoRoot, options = {}) {
689
689
  // before this fix — byte-identical behavior there.
690
690
  const mergeCandidatesByTenant = new Map();
691
691
  for (const entry of mergeCandidates) {
692
- const key = derivationPartitionKey(entry.tenantId, entry.scope);
692
+ const key = derivationPartitionKey(entry.tenantId, entry.scope, entry.origin_project);
693
693
  const bucket = mergeCandidatesByTenant.get(key);
694
694
  if (bucket)
695
695
  bucket.push(entry);
@@ -708,17 +708,15 @@ export async function consolidate(hippoRoot, options = {}) {
708
708
  for (const [, tenantCandidates] of mergeCandidatesByTenant) {
709
709
  const mergeTenant = tenantCandidates[0].tenantId;
710
710
  const mergeScope = derivationScope(tenantCandidates[0].scope);
711
+ const mergeOrigin = tenantCandidates[0].origin_project;
712
+ const partnersOf = mergePartners(tenantCandidates.map((e) => e.content));
711
713
  for (let i = 0; i < tenantCandidates.length; i++) {
712
714
  if (used.has(tenantCandidates[i].id) || tenantCandidates[i].content.length > MERGE_MAX_CHARS)
713
715
  continue;
714
716
  const related = [tenantCandidates[i]];
715
- for (let j = i + 1; j < tenantCandidates.length; j++) {
716
- if (used.has(tenantCandidates[j].id))
717
- continue;
718
- const overlap = textOverlap(tenantCandidates[i].content, tenantCandidates[j].content);
719
- if (overlap >= MERGE_OVERLAP_THRESHOLD) {
717
+ for (const j of partnersOf(i)) {
718
+ if (!used.has(tenantCandidates[j].id))
720
719
  related.push(tenantCandidates[j]);
721
- }
722
720
  }
723
721
  const cluster = [];
724
722
  let clusterChars = 0;
@@ -755,6 +753,7 @@ export async function consolidate(hippoRoot, options = {}) {
755
753
  scope: mergeScope,
756
754
  baseHalfLifeDays: config.defaultHalfLifeDays,
757
755
  }),
756
+ origin_project: mergeOrigin,
758
757
  parents: cluster.map((e) => e.id),
759
758
  };
760
759
  }
@@ -793,8 +792,8 @@ export async function consolidate(hippoRoot, options = {}) {
793
792
  },
794
793
  });
795
794
  }
796
- catch {
797
- // Best-effort — mirrors store.ts's audit() semantics.
795
+ catch (error) {
796
+ reportAuditWriteFailure('reject_refusal', String(error));
798
797
  }
799
798
  continue;
800
799
  }
@@ -911,8 +910,9 @@ export async function consolidate(hippoRoot, options = {}) {
911
910
  : {},
912
911
  });
913
912
  }
914
- catch {
913
+ catch (error) {
915
914
  auditFailures++;
915
+ reportAuditWriteFailure('mv_rescue', String(error), entry.id);
916
916
  }
917
917
  }
918
918
  if (auditFailures > 0) {
@@ -965,7 +965,14 @@ function pickStrongestValence(entries) {
965
965
  }
966
966
  return 'neutral';
967
967
  }
968
- function detectConflicts(entries, now, decayOpts = {},
968
+ /** Maps i to each j > i, ascending, whose text overlap with i reaches the merge threshold; every text needs at least one token. */
969
+ export function mergePartners(contents) {
970
+ const sets = contents.map((text) => new Set(tokenize(text)));
971
+ const candidatesOf = overlapPartners(sets, jaccardMinShared(MERGE_OVERLAP_THRESHOLD));
972
+ return (i) => candidatesOf(i).filter((j) => jaccardSets(sets[i], sets[j]) >= MERGE_OVERLAP_THRESHOLD);
973
+ }
974
+ /** Pairs of live non-semantic memories that contradict each other, in survivor order. */
975
+ export function detectConflicts(entries, now, decayOpts = {},
969
976
  // LC2-E3 (opt-in, default off): ids rescued by this cycle's decay pass.
970
977
  // detectConflicts recomputes its own strength>=DECAY_THRESHOLD survivor
971
978
  // filter independently of the decay pass above; without this bypass,
@@ -978,8 +985,10 @@ rescuedIds = new Set()) {
978
985
  && !isQuarantineScope(entry.scope ?? null)
979
986
  && (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
980
987
  const detected = [];
988
+ const profiles = survivors.map((entry) => conflictProfile(entry.content));
989
+ const partnersOf = overlapPartners(profiles.map((p) => p.distinct), jaccardMinShared(CONFLICT_OVERLAP_THRESHOLD, CONFLICT_MIN_RARE_SHARED));
981
990
  for (let i = 0; i < survivors.length; i++) {
982
- for (let j = i + 1; j < survivors.length; j++) {
991
+ for (const j of partnersOf(i)) {
983
992
  // Traces are variants of each other, not contradictions. Two
984
993
  // strategies for the same task can both be valid; conflict detection
985
994
  // exists for stated-rule disagreement, not strategy diversity.
@@ -987,9 +996,11 @@ rescuedIds = new Set()) {
987
996
  continue;
988
997
  if (survivors[i].superseded_by || survivors[j].superseded_by)
989
998
  continue;
999
+ if (!recalledTogether(survivors[i], survivors[j]))
1000
+ continue;
990
1001
  if ([survivors[i], survivors[j]].some((e) => e.tags.includes('extracted') || e.tags.includes('session-digest')))
991
1002
  continue;
992
- const reasonAndScore = describeConflict(survivors[i], survivors[j]);
1003
+ const reasonAndScore = describeConflict(profiles[i], profiles[j]);
993
1004
  if (!reasonAndScore)
994
1005
  continue;
995
1006
  detected.push({
@@ -1002,25 +1013,33 @@ rescuedIds = new Set()) {
1002
1013
  }
1003
1014
  return detected;
1004
1015
  }
1016
+ /** One project's ambient context can show both: same tenant, and the same project or a user-global row beside a project's. */
1017
+ function recalledTogether(a, b) {
1018
+ if (a.tenantId !== b.tenantId)
1019
+ return false;
1020
+ const [x, y] = [a.origin_project ?? null, b.origin_project ?? null];
1021
+ return x === y || (x === '' && y !== null) || (y === '' && x !== null);
1022
+ }
1023
+ function conflictProfile(text) {
1024
+ const opening = openingWindow(text);
1025
+ // Polarity is measured only in the first POLARITY_WINDOW_WORDS, so a stray
1026
+ // negation deep in a prose memory doesn't flip the intent.
1027
+ // Pad with spaces so space-delimited patterns match words at the start/end.
1028
+ return { distinct: distinctiveTokens(text), polarity: inferConflictPolarity(opening), window: ' ' + opening.toLowerCase() + ' ' };
1029
+ }
1005
1030
  function describeConflict(a, b) {
1006
- const aDistinct = distinctiveTokens(a.content);
1007
- const bDistinct = distinctiveTokens(b.content);
1008
1031
  // Jaccard on stopword-stripped tokens. Defer the threshold check until we
1009
1032
  // know whether an explicit polarity pair is present (lower bar for those).
1010
- const overlapScore = jaccardSets(aDistinct, bDistinct);
1033
+ const overlapScore = jaccardSets(a.distinct, b.distinct);
1011
1034
  // Require at least N shared distinctive tokens so two short memories sharing
1012
1035
  // only "the project name" don't register.
1013
1036
  let shared = 0;
1014
- for (const t of aDistinct)
1015
- if (bDistinct.has(t))
1037
+ for (const t of a.distinct)
1038
+ if (b.distinct.has(t))
1016
1039
  shared++;
1017
1040
  if (shared < CONFLICT_MIN_RARE_SHARED)
1018
1041
  return null;
1019
- // Polarity is measured only in the first POLARITY_WINDOW_WORDS, so a stray
1020
- // negation deep in a prose memory doesn't flip the intent.
1021
- const polarityA = inferConflictPolarity(openingWindow(a.content));
1022
- const polarityB = inferConflictPolarity(openingWindow(b.content));
1023
- const conflictType = classifyConflictType(a.content, b.content, polarityA, polarityB);
1042
+ const conflictType = classifyConflictType(a.window, b.window, a.polarity, b.polarity);
1024
1043
  if (!conflictType)
1025
1044
  return null;
1026
1045
  if (overlapScore < CONFLICT_OVERLAP_THRESHOLD)
@@ -1052,14 +1071,8 @@ function jaccardSets(a, b) {
1052
1071
  function openingWindow(text) {
1053
1072
  return text.split(/\s+/).slice(0, POLARITY_WINDOW_WORDS).join(' ');
1054
1073
  }
1055
- function classifyConflictType(aText, bText, aPolarity, bPolarity) {
1056
- // Classifier scans only the opening window of each memory so " on " and
1057
- // " off " used as English prepositions deep in a long prose memory don't
1058
- // trigger an enabled/disabled flag. The opening window is where a rule or
1059
- // declaration is typically stated.
1060
- // Pad with spaces so space-delimited patterns match words at the start/end.
1061
- const a = ' ' + openingWindow(aText).toLowerCase() + ' ';
1062
- const b = ' ' + openingWindow(bText).toLowerCase() + ' ';
1074
+ // Takes opening windows only, so " on " and " off " as prepositions deep in long prose don't read as enabled/disabled.
1075
+ function classifyConflictType(a, b, aPolarity, bPolarity) {
1063
1076
  // Tightened tokens: require whole-word boundaries so " on " alone doesn't
1064
1077
  // match "on/off". Pair only `enabled` ↔ `disabled` and explicit on/off in
1065
1078
  // imperative context.
@@ -19,6 +19,7 @@
19
19
  *
20
20
  * Lifecycle: active -> superseded (a corrected version) or active -> closed (retired).
21
21
  */
22
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
22
23
  import { openHippoDb, closeHippoDb } from './db.js';
23
24
  import { writeEntry } from './store.js';
24
25
  import { assertTenantId } from './tenant.js';
@@ -47,21 +48,21 @@ export const MAX_CHANGE_SUMMARY_LEN = 4096;
47
48
  function validateNoteFields(customer, note, changeSummary) {
48
49
  const normalizedCustomer = (customer ?? '').trim();
49
50
  if (normalizedCustomer.length === 0)
50
- throw new Error('saveCustomerNote: customer is required');
51
+ throw new BadRequestError('saveCustomerNote: customer is required');
51
52
  if (/[\r\n]/.test(normalizedCustomer)) {
52
- throw new Error('saveCustomerNote: customer must be a single line (no newlines)');
53
+ throw new BadRequestError('saveCustomerNote: customer must be a single line (no newlines)');
53
54
  }
54
55
  if (normalizedCustomer.length > MAX_CUSTOMER_LEN) {
55
- throw new Error(`saveCustomerNote: customer exceeds the ${MAX_CUSTOMER_LEN}-char cap`);
56
+ throw new BadRequestError(`saveCustomerNote: customer exceeds the ${MAX_CUSTOMER_LEN}-char cap`);
56
57
  }
57
58
  if (!note || note.trim().length === 0) {
58
- throw new Error('saveCustomerNote: note is required');
59
+ throw new BadRequestError('saveCustomerNote: note is required');
59
60
  }
60
61
  if (note.length > MAX_NOTE_LEN) {
61
- throw new Error(`saveCustomerNote: note exceeds the ${MAX_NOTE_LEN}-char cap`);
62
+ throw new BadRequestError(`saveCustomerNote: note exceeds the ${MAX_NOTE_LEN}-char cap`);
62
63
  }
63
64
  if (changeSummary !== undefined && changeSummary.length > MAX_CHANGE_SUMMARY_LEN) {
64
- throw new Error(`saveCustomerNote: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
65
+ throw new BadRequestError(`saveCustomerNote: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
65
66
  }
66
67
  return { customer: normalizedCustomer };
67
68
  }
@@ -137,10 +138,10 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
137
138
  // shape for the matching row, or undefined when no note/tenant pair matches.
138
139
  const pred = db.prepare(`SELECT status, version FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesNoteId, tenantId);
139
140
  if (!pred) {
140
- throw new Error(`saveCustomerNote: note ${opts.supersedesNoteId} to supersede not found for tenant ${tenantId}`);
141
+ throw new NotFoundError(`saveCustomerNote: note ${opts.supersedesNoteId} to supersede not found for tenant ${tenantId}`);
141
142
  }
142
143
  if (pred.status !== 'active') {
143
- throw new Error(`saveCustomerNote: note ${opts.supersedesNoteId} is not active (status='${pred.status}'); only active notes can be superseded.`);
144
+ throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} is not active (status='${pred.status}'); only active notes can be superseded.`);
144
145
  }
145
146
  version = pred.version + 1;
146
147
  }
@@ -158,7 +159,7 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
158
159
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
159
160
  `).run(noteId, now, opts.supersedesNoteId, tenantId, noteId);
160
161
  if (sup.changes === 0) {
161
- throw new Error(`saveCustomerNote: note ${opts.supersedesNoteId} could not be superseded (no longer active or self-reference).`);
162
+ throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} could not be superseded (no longer active or self-reference).`);
162
163
  }
163
164
  appendAuditEvent(db, {
164
165
  tenantId,
@@ -221,9 +222,9 @@ export function closeCustomerNote(hippoRoot, tenantId, id, actor = 'cli') {
221
222
  // shape, or undefined when the id/tenant pair doesn't exist.
222
223
  const existing = db.prepare(`SELECT status FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
223
224
  if (!existing) {
224
- throw new Error(`closeCustomerNote: note ${id} not found for tenant ${tenantId}`);
225
+ throw new NotFoundError(`closeCustomerNote: note ${id} not found for tenant ${tenantId}`);
225
226
  }
226
- throw new Error(`closeCustomerNote: note ${id} is not active (status='${existing.status}'); only active notes can be closed.`);
227
+ throw new ConflictError(`closeCustomerNote: note ${id} is not active (status='${existing.status}'); only active notes can be closed.`);
227
228
  }
228
229
  // SAFETY: SELECT ${NOTE_COLS} projects exactly the CustomerNoteRow columns;
229
230
  // .get() returns that row for the just-updated id, or undefined only in an
@@ -231,7 +232,7 @@ export function closeCustomerNote(hippoRoot, tenantId, id, actor = 'cli') {
231
232
  const row = db.prepare(`SELECT ${NOTE_COLS} FROM customer_notes WHERE id = ? AND tenant_id = ?`)
232
233
  .get(id, tenantId);
233
234
  if (!row)
234
- throw new Error(`closeCustomerNote: note ${id} not found after UPDATE`);
235
+ throw new NotFoundError(`closeCustomerNote: note ${id} not found after UPDATE`);
235
236
  appendAuditEvent(db, {
236
237
  tenantId,
237
238
  actor,
@@ -284,7 +285,7 @@ export function loadCustomerNotes(hippoRoot, tenantId, opts = {}) {
284
285
  assertTenantId('loadCustomerNotes', tenantId);
285
286
  const limit = opts.limit ?? 100;
286
287
  if (opts.status && !VALID_NOTE_STATES.has(opts.status)) {
287
- throw new Error(`loadCustomerNotes: status must be one of ${Array.from(VALID_NOTE_STATES).join('|')}; got ${opts.status}`);
288
+ throw new BadRequestError(`loadCustomerNotes: status must be one of ${Array.from(VALID_NOTE_STATES).join('|')}; got ${opts.status}`);
288
289
  }
289
290
  const db = openHippoDb(hippoRoot);
290
291
  try {
package/dist/dag.js CHANGED
@@ -2,6 +2,7 @@ import { createMemory, Layer } from './memory.js';
2
2
  import { writeEntry, loadAllDirtySummaries, loadChildrenOfSummary, applyRebuildResult, clearSummaryDirtyAfterBuild, } from './store.js';
3
3
  import { RejectedValueError } from './rejection.js';
4
4
  import { redactSecrets } from './secret-detect.js';
5
+ import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
5
6
  import { derivationScope, derivationPartitionKey } from './recall-scope.js';
6
7
  import { loadConfig } from './config.js';
7
8
  import { neverAutoShareTags } from './shared.js';
@@ -52,7 +53,7 @@ export async function generateDagSummary(label, factContents, opts) {
52
53
  .replace('{facts}', factsBlock);
53
54
  let res;
54
55
  try {
55
- res = await fetchFn('https://api.anthropic.com/v1/messages', {
56
+ res = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
56
57
  method: 'POST',
57
58
  headers: {
58
59
  'content-type': 'application/json',
@@ -64,7 +65,7 @@ export async function generateDagSummary(label, factContents, opts) {
64
65
  max_tokens: 400,
65
66
  messages: [{ role: 'user', content: prompt }],
66
67
  }),
67
- });
68
+ }, { timeoutMs: llmTimeoutMs(), fetchFn });
68
69
  }
69
70
  catch (err) {
70
71
  opts.onError?.(`request failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -97,7 +98,7 @@ export async function buildDag(hippoRoot, facts, opts) {
97
98
  // behavior there.
98
99
  const unparentedByTenant = new Map();
99
100
  for (const fact of unparented) {
100
- const key = derivationPartitionKey(fact.tenantId, fact.scope);
101
+ const key = derivationPartitionKey(fact.tenantId, fact.scope, fact.origin_project);
101
102
  const bucket = unparentedByTenant.get(key);
102
103
  if (bucket)
103
104
  bucket.push(fact);
@@ -128,6 +129,7 @@ export async function buildDag(hippoRoot, facts, opts) {
128
129
  scope: factScope,
129
130
  baseHalfLifeDays,
130
131
  });
132
+ summaryEntry.origin_project = tenantFacts[0].origin_project;
131
133
  // Schema v25: cache descendant_count + earliest/latest_at on the summary
132
134
  // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
133
135
  // can reason about scope without walking the children.
@@ -309,7 +311,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
309
311
  const byTenant = new Map();
310
312
  for (const l2 of unparented) {
311
313
  const tid = l2.tenantId ?? 'default';
312
- const key = derivationPartitionKey(tid, l2.scope);
314
+ const key = derivationPartitionKey(tid, l2.scope, l2.origin_project);
313
315
  const list = byTenant.get(key) ?? [];
314
316
  list.push(l2);
315
317
  byTenant.set(key, list);
@@ -337,6 +339,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
337
339
  scope,
338
340
  baseHalfLifeDays,
339
341
  });
342
+ profileEntry.origin_project = tenantL2s[0].origin_project;
340
343
  profileEntry.descendant_count = cluster.members.length;
341
344
  profileEntry.earliest_at = memberCreatedAts[0];
342
345
  profileEntry.latest_at = memberCreatedAts[memberCreatedAts.length - 1];
package/dist/dashboard.js CHANGED
@@ -29,7 +29,7 @@ function buildDashboardData(hippoRoot) {
29
29
  // render resolved conflicts as faded historical context, not just open
30
30
  // conflicts. The open_conflicts stat below still counts only 'open' rows
31
31
  // to preserve the existing badge meaning.
32
- const conflicts = listMemoryConflicts(hippoRoot, '*');
32
+ const conflicts = listMemoryConflicts(hippoRoot, '*', tenantId);
33
33
  // D4 v1.12.10: tenant-scope peer discovery in the dashboard (matches the
34
34
  // tenantId already used for loadAllEntries on line 72).
35
35
  const peers = listPeers(undefined, tenantId);