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.
- package/README.md +24 -1
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/agent-memories/legacy.js +4 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +5 -5
- package/dist/api.js +40 -47
- package/dist/audit.d.ts +5 -1
- package/dist/audit.js +13 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/cli/output.d.ts +3 -0
- package/dist/cli/output.js +7 -0
- package/dist/cli/projects.d.ts +4 -0
- package/dist/cli/projects.js +90 -0
- package/dist/cli/shared.js +23 -13
- package/dist/cli/sleep.js +5 -3
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +486 -397
- package/dist/client.js +9 -0
- package/dist/codex-patch.js +1 -1
- package/dist/compaction-record.d.ts +1 -1
- package/dist/compaction-record.js +3 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/github/dlq.js +5 -2
- package/dist/connectors/github/octokit-client.js +4 -2
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +48 -35
- package/dist/customer-notes.js +14 -13
- package/dist/dag.js +7 -4
- package/dist/dashboard.js +1 -1
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +9 -8
- package/dist/dedupe.js +1 -1
- package/dist/doctor.js +28 -0
- package/dist/dormant.d.ts +2 -2
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/extract.js +19 -18
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +8 -0
- package/dist/http-util.js +10 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +11 -10
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +52 -24
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.js +3 -2
- package/dist/overlap-index.d.ts +7 -0
- package/dist/overlap-index.js +38 -0
- package/dist/pilot-arm.d.ts +9 -0
- package/dist/pilot-arm.js +47 -0
- package/dist/policies.js +12 -11
- package/dist/predictions.js +9 -8
- package/dist/processes.js +14 -13
- package/dist/project-briefs.js +16 -15
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/project-merge.d.ts +52 -0
- package/dist/project-merge.js +168 -0
- package/dist/raw-archive.js +7 -6
- package/dist/recall-scope.d.ts +5 -4
- package/dist/recall-scope.js +7 -5
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/rerankers/clef.d.ts +29 -0
- package/dist/rerankers/clef.js +182 -0
- package/dist/rerankers/index.js +3 -0
- package/dist/rerankers/jev.d.ts +11 -0
- package/dist/rerankers/jev.js +10 -5
- package/dist/rerankers/types.d.ts +16 -0
- package/dist/search.js +14 -2
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +9 -2
- package/dist/server.js +188 -411
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +15 -14
- package/dist/store.js +10 -10
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- 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
|
package/dist/codex-patch.js
CHANGED
|
@@ -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'
|
|
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,
|
|
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'
|
|
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__',
|
|
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
|
|
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
|
|
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,
|
|
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: '
|
|
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 '
|
|
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
|
-
|
|
16
|
-
|
|
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());
|
package/dist/consolidate.d.ts
CHANGED
|
@@ -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
|
package/dist/consolidate.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
|
1015
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
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.
|
package/dist/customer-notes.js
CHANGED
|
@@ -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
|
|
51
|
+
throw new BadRequestError('saveCustomerNote: customer is required');
|
|
51
52
|
if (/[\r\n]/.test(normalizedCustomer)) {
|
|
52
|
-
throw new
|
|
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
|
|
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
|
|
59
|
+
throw new BadRequestError('saveCustomerNote: note is required');
|
|
59
60
|
}
|
|
60
61
|
if (note.length > MAX_NOTE_LEN) {
|
|
61
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
225
|
+
throw new NotFoundError(`closeCustomerNote: note ${id} not found for tenant ${tenantId}`);
|
|
225
226
|
}
|
|
226
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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);
|