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.
- package/README.md +11 -0
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +21 -14
- package/dist/api.js +97 -71
- package/dist/audit.d.ts +4 -0
- package/dist/audit.js +11 -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/card-detail.d.ts +1 -1
- package/dist/card-detail.js +1 -1
- package/dist/cli/shared.d.ts +137 -0
- package/dist/cli/shared.js +834 -0
- package/dist/cli/sleep.d.ts +10 -0
- package/dist/cli/sleep.js +171 -0
- package/dist/cli.d.ts +0 -7
- package/dist/cli.js +322 -1827
- 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/github/webhook.d.ts +19 -0
- package/dist/connectors/github/webhook.js +313 -0
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/connectors/slack/webhook.d.ts +22 -0
- package/dist/connectors/slack/webhook.js +203 -0
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +38 -35
- package/dist/context-auto.d.ts +3 -0
- package/dist/context-auto.js +34 -0
- package/dist/customer-notes.js +16 -14
- package/dist/dag.js +3 -2
- package/dist/dashboard.js +3 -2
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +11 -9
- package/dist/doctor.js +5 -0
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/eval-stats.d.ts +58 -0
- package/dist/eval-stats.js +111 -0
- package/dist/extract.js +3 -2
- package/dist/goals.d.ts +49 -25
- package/dist/goals.js +39 -22
- package/dist/graph-extract.js +1 -1
- package/dist/graph-recall.d.ts +1 -1
- package/dist/graph-recall.js +1 -1
- package/dist/graph.js +1 -1
- package/dist/hooks.d.ts +1 -3
- package/dist/hooks.js +2 -4
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +39 -0
- package/dist/http-util.js +56 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +13 -11
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +224 -308
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.d.ts +19 -0
- package/dist/memory.js +41 -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 +14 -12
- package/dist/predictions.js +11 -9
- package/dist/processes.js +16 -14
- package/dist/project-briefs.js +19 -16
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/prompt-recall.js +1 -1
- package/dist/raw-archive.js +7 -6
- package/dist/recall-history.d.ts +5 -0
- package/dist/recall-history.js +9 -0
- package/dist/recall-pipeline.d.ts +101 -0
- package/dist/recall-pipeline.js +313 -0
- package/dist/recall-scope.d.ts +24 -1
- package/dist/recall-scope.js +29 -2
- 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/search.d.ts +0 -20
- package/dist/search.js +16 -51
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1854 -2566
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +17 -15
- package/dist/store-cards.d.ts +53 -0
- package/dist/store-cards.js +512 -0
- package/dist/store.d.ts +2 -89
- package/dist/store.js +10 -566
- package/dist/tenant.d.ts +22 -0
- package/dist/tenant.js +26 -0
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/tokenize.d.ts +2 -0
- package/dist/tokenize.js +8 -0
- 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 +1 -1
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- package/dist/connectors/slack/ratelimit.js +0 -18
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { verifySlackSignature } from './signature.js';
|
|
2
|
+
import { isSlackEventEnvelope, isSlackMessageEvent } from './types.js';
|
|
3
|
+
import { ingestMessage } from './ingest.js';
|
|
4
|
+
import { handleMessageDeleted } from './deletion.js';
|
|
5
|
+
import { writeToDlq } from './dlq.js';
|
|
6
|
+
import { resolveTenantForTeam } from './tenant-routing.js';
|
|
7
|
+
import { resolveTenantId } from '../../tenant.js';
|
|
8
|
+
import { openHippoDb, closeHippoDb } from '../../db.js';
|
|
9
|
+
import { adminActor } from '../../api.js';
|
|
10
|
+
import { HttpError, JSON_HEADERS, isHeaderString, isJsonObjectRecord, readBody, sendJson, } from '../../http-util.js';
|
|
11
|
+
/**
|
|
12
|
+
* Slack Events API webhook. Auth is signature-based (HMAC over the raw
|
|
13
|
+
* body with SLACK_SIGNING_SECRET); Bearer is NOT required, which is why
|
|
14
|
+
* this route is in PUBLIC_ROUTES. The route is responsible for:
|
|
15
|
+
* 1. Echoing the one-time url_verification challenge.
|
|
16
|
+
* 2. Verifying the HMAC on every other inbound payload.
|
|
17
|
+
* 3. Resolving body.team_id → tenantId via slack_workspaces, falling
|
|
18
|
+
* back to HIPPO_TENANT then 'default'.
|
|
19
|
+
* 4. Dispatching event_callback envelopes to ingestMessage /
|
|
20
|
+
* handleMessageDeleted.
|
|
21
|
+
* 5. Parking malformed or unhandled payloads in slack_dlq and STILL
|
|
22
|
+
* ACKing 200 - Slack retries forever otherwise.
|
|
23
|
+
*
|
|
24
|
+
* When SLACK_SIGNING_SECRET is unset we return 404, not
|
|
25
|
+
* 503, so an external probe cannot distinguish "route gated off by config"
|
|
26
|
+
* from "route does not exist on this build".
|
|
27
|
+
*
|
|
28
|
+
* Bearer auth is skipped on purpose: server.ts checks isPublicRoute before calling this.
|
|
29
|
+
*/
|
|
30
|
+
export async function handleSlackEventsWebhook({ req, res, opts }) {
|
|
31
|
+
const rawBody = await readBody(req);
|
|
32
|
+
const secret = process.env.SLACK_SIGNING_SECRET;
|
|
33
|
+
if (!secret) {
|
|
34
|
+
res.writeHead(404, JSON_HEADERS);
|
|
35
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const previousSecret = process.env.SLACK_SIGNING_SECRET_PREVIOUS;
|
|
39
|
+
const sig = req.headers['x-slack-signature'];
|
|
40
|
+
const tsHdr = req.headers['x-slack-request-timestamp'];
|
|
41
|
+
const sigStr = isHeaderString(sig) ? sig : null;
|
|
42
|
+
const tsStr = isHeaderString(tsHdr) ? tsHdr : null;
|
|
43
|
+
if (sigStr === null ||
|
|
44
|
+
tsStr === null ||
|
|
45
|
+
!verifySlackSignature({
|
|
46
|
+
rawBody,
|
|
47
|
+
timestamp: tsStr,
|
|
48
|
+
signature: sigStr,
|
|
49
|
+
signingSecret: secret,
|
|
50
|
+
previousSecret,
|
|
51
|
+
})) {
|
|
52
|
+
throw new HttpError(401, 'invalid Slack signature');
|
|
53
|
+
}
|
|
54
|
+
// Cheap regex extracts team_id from a (possibly malformed) raw body so the
|
|
55
|
+
// DLQ row carries it for triage even when JSON.parse fails.
|
|
56
|
+
const teamIdFromRaw = (() => {
|
|
57
|
+
const m = rawBody.match(/"team_id"\s*:\s*"([^"]+)"/);
|
|
58
|
+
return m ? m[1] : null;
|
|
59
|
+
})();
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
body = JSON.parse(rawBody);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Attribute the parse failure to the originating workspace via the regex-extracted
|
|
66
|
+
// team_id; a null or unknown team writes tenantId=null, which lands as '__unroutable__'.
|
|
67
|
+
const db = openHippoDb(opts.hippoRoot);
|
|
68
|
+
try {
|
|
69
|
+
const parseFailTenant = teamIdFromRaw !== null ? resolveTenantForTeam(db, teamIdFromRaw) : null;
|
|
70
|
+
writeToDlq(db, {
|
|
71
|
+
tenantId: parseFailTenant, // null → '__unroutable__' sentinel
|
|
72
|
+
teamId: teamIdFromRaw,
|
|
73
|
+
rawPayload: rawBody,
|
|
74
|
+
error: 'invalid JSON',
|
|
75
|
+
bucket: 'parse_error',
|
|
76
|
+
signature: sigStr,
|
|
77
|
+
slackTimestamp: tsStr,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
closeHippoDb(db);
|
|
82
|
+
}
|
|
83
|
+
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (isJsonObjectRecord(body)) {
|
|
87
|
+
const bodyRecord = body;
|
|
88
|
+
if (bodyRecord.type === 'url_verification') {
|
|
89
|
+
sendJson(res, 200, {
|
|
90
|
+
challenge: String(bodyRecord.challenge ?? ''),
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Resolve tenant, failing closed: when slack_workspaces is non-empty
|
|
96
|
+
// and the team_id is unknown, resolveTenantForTeam returns null and we
|
|
97
|
+
// park the envelope in slack_dlq with bucket='unroutable'. Mandatory ACK
|
|
98
|
+
// 200 so Slack stops retrying; do NOT call ingest.
|
|
99
|
+
let resolvedTenant = null;
|
|
100
|
+
if (body !== undefined && isSlackEventEnvelope(body)) {
|
|
101
|
+
const db = openHippoDb(opts.hippoRoot);
|
|
102
|
+
try {
|
|
103
|
+
resolvedTenant = resolveTenantForTeam(db, body.team_id);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
closeHippoDb(db);
|
|
107
|
+
}
|
|
108
|
+
if (resolvedTenant === null) {
|
|
109
|
+
const db2 = openHippoDb(opts.hippoRoot);
|
|
110
|
+
try {
|
|
111
|
+
writeToDlq(db2, {
|
|
112
|
+
tenantId: null, // unroutable - stored as '__unroutable__'
|
|
113
|
+
teamId: body.team_id,
|
|
114
|
+
rawPayload: rawBody,
|
|
115
|
+
error: `unroutable team_id: ${body.team_id}`,
|
|
116
|
+
bucket: 'unroutable',
|
|
117
|
+
signature: sigStr,
|
|
118
|
+
slackTimestamp: tsStr,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
closeHippoDb(db2);
|
|
123
|
+
}
|
|
124
|
+
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// Non-envelope payload: use env tenant for the DLQ row's bookkeeping.
|
|
130
|
+
resolvedTenant = resolveTenantId({});
|
|
131
|
+
}
|
|
132
|
+
const ctx = {
|
|
133
|
+
hippoRoot: opts.hippoRoot,
|
|
134
|
+
tenantId: resolvedTenant,
|
|
135
|
+
actor: adminActor('connector:slack'),
|
|
136
|
+
};
|
|
137
|
+
if (body === undefined || !isSlackEventEnvelope(body)) {
|
|
138
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
139
|
+
try {
|
|
140
|
+
writeToDlq(db, {
|
|
141
|
+
tenantId: ctx.tenantId,
|
|
142
|
+
teamId: teamIdFromRaw,
|
|
143
|
+
rawPayload: rawBody,
|
|
144
|
+
error: 'not an event_callback envelope',
|
|
145
|
+
bucket: 'parse_error',
|
|
146
|
+
signature: sigStr,
|
|
147
|
+
slackTimestamp: tsStr,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
closeHippoDb(db);
|
|
152
|
+
}
|
|
153
|
+
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const inner = body.event;
|
|
157
|
+
if (isSlackMessageEvent(inner)) {
|
|
158
|
+
if (inner.subtype === 'message_deleted' && inner.deleted_ts) {
|
|
159
|
+
const r = handleMessageDeleted(ctx, {
|
|
160
|
+
teamId: body.team_id,
|
|
161
|
+
channelId: inner.channel,
|
|
162
|
+
deletedTs: inner.deleted_ts,
|
|
163
|
+
eventId: body.event_id,
|
|
164
|
+
});
|
|
165
|
+
sendJson(res, 200, { ok: true, status: r.status });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const r = ingestMessage(ctx, {
|
|
169
|
+
teamId: body.team_id,
|
|
170
|
+
// channel privacy isn't on the inner event; use channel_type as a
|
|
171
|
+
// proxy. 'group'|'im'|'mpim' → private. 'channel' → public. Unknown
|
|
172
|
+
// → private (fail closed).
|
|
173
|
+
channel: {
|
|
174
|
+
id: inner.channel,
|
|
175
|
+
is_private: inner.channel_type !== 'channel',
|
|
176
|
+
is_im: inner.channel_type === 'im',
|
|
177
|
+
is_mpim: inner.channel_type === 'mpim',
|
|
178
|
+
},
|
|
179
|
+
message: inner,
|
|
180
|
+
eventId: body.event_id,
|
|
181
|
+
});
|
|
182
|
+
sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
186
|
+
try {
|
|
187
|
+
writeToDlq(db, {
|
|
188
|
+
tenantId: ctx.tenantId,
|
|
189
|
+
teamId: body.team_id,
|
|
190
|
+
rawPayload: rawBody,
|
|
191
|
+
error: `unhandled event type: ${inner.type ?? 'unknown'}`,
|
|
192
|
+
bucket: 'parse_error',
|
|
193
|
+
signature: sigStr,
|
|
194
|
+
slackTimestamp: tsStr,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
closeHippoDb(db);
|
|
199
|
+
}
|
|
200
|
+
sendJson(res, 200, { ok: true, status: 'dlq' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=webhook.js.map
|
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
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* 3. Stats tracking
|
|
8
8
|
*/
|
|
9
9
|
import { evalNow, isRecallBoostAblated } from './ablation.js';
|
|
10
|
-
import { Layer, calculateStrength, canAutoDelete, createMemory } from './memory.js';
|
|
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 {
|
|
12
|
+
import { tokenize } from './tokenize.js';
|
|
13
|
+
import { jaccardMinShared, overlapPartners } from './overlap-index.js';
|
|
13
14
|
import { compareEntryIdentity } from './compare.js';
|
|
14
15
|
import { duplicateKey, mergedText } from './same-text.js';
|
|
15
16
|
import { successorAfterRetirement } from './merged-row.js';
|
|
@@ -25,7 +26,7 @@ import { renderTraceContent } from './trace.js';
|
|
|
25
26
|
import { resolveTenantId } from './tenant.js';
|
|
26
27
|
import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-value.js';
|
|
27
28
|
import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
|
|
28
|
-
import { appendAuditEvent } from './audit.js';
|
|
29
|
+
import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
|
|
29
30
|
import { migrateDefaultHalfLife, LEGACY_TYPED_HALF_LIFE } from './half-life-migration.js';
|
|
30
31
|
import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
|
|
31
32
|
import { isQuarantineScope } from './quarantine.js';
|
|
@@ -418,8 +419,8 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
418
419
|
},
|
|
419
420
|
});
|
|
420
421
|
}
|
|
421
|
-
catch {
|
|
422
|
-
|
|
422
|
+
catch (error) {
|
|
423
|
+
reportAuditWriteFailure('reject_refusal', String(error));
|
|
423
424
|
}
|
|
424
425
|
continue;
|
|
425
426
|
}
|
|
@@ -707,17 +708,14 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
707
708
|
for (const [, tenantCandidates] of mergeCandidatesByTenant) {
|
|
708
709
|
const mergeTenant = tenantCandidates[0].tenantId;
|
|
709
710
|
const mergeScope = derivationScope(tenantCandidates[0].scope);
|
|
711
|
+
const partnersOf = mergePartners(tenantCandidates.map((e) => e.content));
|
|
710
712
|
for (let i = 0; i < tenantCandidates.length; i++) {
|
|
711
713
|
if (used.has(tenantCandidates[i].id) || tenantCandidates[i].content.length > MERGE_MAX_CHARS)
|
|
712
714
|
continue;
|
|
713
715
|
const related = [tenantCandidates[i]];
|
|
714
|
-
for (
|
|
715
|
-
if (used.has(tenantCandidates[j].id))
|
|
716
|
-
continue;
|
|
717
|
-
const overlap = textOverlap(tenantCandidates[i].content, tenantCandidates[j].content);
|
|
718
|
-
if (overlap >= MERGE_OVERLAP_THRESHOLD) {
|
|
716
|
+
for (const j of partnersOf(i)) {
|
|
717
|
+
if (!used.has(tenantCandidates[j].id))
|
|
719
718
|
related.push(tenantCandidates[j]);
|
|
720
|
-
}
|
|
721
719
|
}
|
|
722
720
|
const cluster = [];
|
|
723
721
|
let clusterChars = 0;
|
|
@@ -792,8 +790,8 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
792
790
|
},
|
|
793
791
|
});
|
|
794
792
|
}
|
|
795
|
-
catch {
|
|
796
|
-
|
|
793
|
+
catch (error) {
|
|
794
|
+
reportAuditWriteFailure('reject_refusal', String(error));
|
|
797
795
|
}
|
|
798
796
|
continue;
|
|
799
797
|
}
|
|
@@ -910,8 +908,9 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
910
908
|
: {},
|
|
911
909
|
});
|
|
912
910
|
}
|
|
913
|
-
catch {
|
|
911
|
+
catch (error) {
|
|
914
912
|
auditFailures++;
|
|
913
|
+
reportAuditWriteFailure('mv_rescue', String(error), entry.id);
|
|
915
914
|
}
|
|
916
915
|
}
|
|
917
916
|
if (auditFailures > 0) {
|
|
@@ -964,7 +963,14 @@ function pickStrongestValence(entries) {
|
|
|
964
963
|
}
|
|
965
964
|
return 'neutral';
|
|
966
965
|
}
|
|
967
|
-
|
|
966
|
+
/** Maps i to each j > i, ascending, whose text overlap with i reaches the merge threshold; every text needs at least one token. */
|
|
967
|
+
export function mergePartners(contents) {
|
|
968
|
+
const sets = contents.map((text) => new Set(tokenize(text)));
|
|
969
|
+
const candidatesOf = overlapPartners(sets, jaccardMinShared(MERGE_OVERLAP_THRESHOLD));
|
|
970
|
+
return (i) => candidatesOf(i).filter((j) => jaccardSets(sets[i], sets[j]) >= MERGE_OVERLAP_THRESHOLD);
|
|
971
|
+
}
|
|
972
|
+
/** Pairs of live non-semantic memories that contradict each other, in survivor order. */
|
|
973
|
+
export function detectConflicts(entries, now, decayOpts = {},
|
|
968
974
|
// LC2-E3 (opt-in, default off): ids rescued by this cycle's decay pass.
|
|
969
975
|
// detectConflicts recomputes its own strength>=DECAY_THRESHOLD survivor
|
|
970
976
|
// filter independently of the decay pass above; without this bypass,
|
|
@@ -977,8 +983,10 @@ rescuedIds = new Set()) {
|
|
|
977
983
|
&& !isQuarantineScope(entry.scope ?? null)
|
|
978
984
|
&& (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
|
|
979
985
|
const detected = [];
|
|
986
|
+
const profiles = survivors.map((entry) => conflictProfile(entry.content));
|
|
987
|
+
const partnersOf = overlapPartners(profiles.map((p) => p.distinct), jaccardMinShared(CONFLICT_OVERLAP_THRESHOLD, CONFLICT_MIN_RARE_SHARED));
|
|
980
988
|
for (let i = 0; i < survivors.length; i++) {
|
|
981
|
-
for (
|
|
989
|
+
for (const j of partnersOf(i)) {
|
|
982
990
|
// Traces are variants of each other, not contradictions. Two
|
|
983
991
|
// strategies for the same task can both be valid; conflict detection
|
|
984
992
|
// exists for stated-rule disagreement, not strategy diversity.
|
|
@@ -988,7 +996,7 @@ rescuedIds = new Set()) {
|
|
|
988
996
|
continue;
|
|
989
997
|
if ([survivors[i], survivors[j]].some((e) => e.tags.includes('extracted') || e.tags.includes('session-digest')))
|
|
990
998
|
continue;
|
|
991
|
-
const reasonAndScore = describeConflict(
|
|
999
|
+
const reasonAndScore = describeConflict(profiles[i], profiles[j]);
|
|
992
1000
|
if (!reasonAndScore)
|
|
993
1001
|
continue;
|
|
994
1002
|
detected.push({
|
|
@@ -1001,25 +1009,26 @@ rescuedIds = new Set()) {
|
|
|
1001
1009
|
}
|
|
1002
1010
|
return detected;
|
|
1003
1011
|
}
|
|
1012
|
+
function conflictProfile(text) {
|
|
1013
|
+
const opening = openingWindow(text);
|
|
1014
|
+
// Polarity is measured only in the first POLARITY_WINDOW_WORDS, so a stray
|
|
1015
|
+
// negation deep in a prose memory doesn't flip the intent.
|
|
1016
|
+
// Pad with spaces so space-delimited patterns match words at the start/end.
|
|
1017
|
+
return { distinct: distinctiveTokens(text), polarity: inferConflictPolarity(opening), window: ' ' + opening.toLowerCase() + ' ' };
|
|
1018
|
+
}
|
|
1004
1019
|
function describeConflict(a, b) {
|
|
1005
|
-
const aDistinct = distinctiveTokens(a.content);
|
|
1006
|
-
const bDistinct = distinctiveTokens(b.content);
|
|
1007
1020
|
// Jaccard on stopword-stripped tokens. Defer the threshold check until we
|
|
1008
1021
|
// know whether an explicit polarity pair is present (lower bar for those).
|
|
1009
|
-
const overlapScore = jaccardSets(
|
|
1022
|
+
const overlapScore = jaccardSets(a.distinct, b.distinct);
|
|
1010
1023
|
// Require at least N shared distinctive tokens so two short memories sharing
|
|
1011
1024
|
// only "the project name" don't register.
|
|
1012
1025
|
let shared = 0;
|
|
1013
|
-
for (const t of
|
|
1014
|
-
if (
|
|
1026
|
+
for (const t of a.distinct)
|
|
1027
|
+
if (b.distinct.has(t))
|
|
1015
1028
|
shared++;
|
|
1016
1029
|
if (shared < CONFLICT_MIN_RARE_SHARED)
|
|
1017
1030
|
return null;
|
|
1018
|
-
|
|
1019
|
-
// negation deep in a prose memory doesn't flip the intent.
|
|
1020
|
-
const polarityA = inferConflictPolarity(openingWindow(a.content));
|
|
1021
|
-
const polarityB = inferConflictPolarity(openingWindow(b.content));
|
|
1022
|
-
const conflictType = classifyConflictType(a.content, b.content, polarityA, polarityB);
|
|
1031
|
+
const conflictType = classifyConflictType(a.window, b.window, a.polarity, b.polarity);
|
|
1023
1032
|
if (!conflictType)
|
|
1024
1033
|
return null;
|
|
1025
1034
|
if (overlapScore < CONFLICT_OVERLAP_THRESHOLD)
|
|
@@ -1051,14 +1060,8 @@ function jaccardSets(a, b) {
|
|
|
1051
1060
|
function openingWindow(text) {
|
|
1052
1061
|
return text.split(/\s+/).slice(0, POLARITY_WINDOW_WORDS).join(' ');
|
|
1053
1062
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
// " off " used as English prepositions deep in a long prose memory don't
|
|
1057
|
-
// trigger an enabled/disabled flag. The opening window is where a rule or
|
|
1058
|
-
// declaration is typically stated.
|
|
1059
|
-
// Pad with spaces so space-delimited patterns match words at the start/end.
|
|
1060
|
-
const a = ' ' + openingWindow(aText).toLowerCase() + ' ';
|
|
1061
|
-
const b = ' ' + openingWindow(bText).toLowerCase() + ' ';
|
|
1063
|
+
// Takes opening windows only, so " on " and " off " as prepositions deep in long prose don't read as enabled/disabled.
|
|
1064
|
+
function classifyConflictType(a, b, aPolarity, bPolarity) {
|
|
1062
1065
|
// Tightened tokens: require whole-word boundaries so " on " alone doesn't
|
|
1063
1066
|
// match "on/off". Pair only `enabled` ↔ `disabled` and explicit on/off in
|
|
1064
1067
|
// imperative context.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
const SKIPPED_PATH_TERMS = ['src', 'dist', 'test', 'tests', 'node_modules', 'index'];
|
|
3
|
+
/** A context query from the cwd's git state ('' means `getContext` lists by strength); kept out of the api layer, which never shells out. */
|
|
4
|
+
export function autoDetectContext() {
|
|
5
|
+
try {
|
|
6
|
+
const diff = execSync('git diff --name-only HEAD 2>&1', {
|
|
7
|
+
encoding: 'utf8',
|
|
8
|
+
timeout: 3000,
|
|
9
|
+
windowsHide: true,
|
|
10
|
+
}).trim();
|
|
11
|
+
if (diff) {
|
|
12
|
+
const terms = diff
|
|
13
|
+
.split('\n')
|
|
14
|
+
.flatMap((f) => f.replace(/[/\\.]/g, ' ').split(/\s+/))
|
|
15
|
+
.filter((t) => t.length > 2 && !SKIPPED_PATH_TERMS.includes(t))
|
|
16
|
+
.slice(0, 10);
|
|
17
|
+
if (terms.length > 0)
|
|
18
|
+
return terms.join(' ');
|
|
19
|
+
}
|
|
20
|
+
const branch = execSync('git branch --show-current 2>&1', {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
timeout: 3000,
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
}).trim();
|
|
25
|
+
if (branch && branch !== 'main' && branch !== 'master') {
|
|
26
|
+
return branch.replace(/[-_/]/g, ' ');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Not a git repo or git missing: no query, so the caller lists by strength.
|
|
31
|
+
}
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=context-auto.js.map
|
package/dist/customer-notes.js
CHANGED
|
@@ -19,8 +19,10 @@
|
|
|
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
|
-
import { writeEntry
|
|
24
|
+
import { writeEntry } from './store.js';
|
|
25
|
+
import { assertTenantId } from './tenant.js';
|
|
24
26
|
import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
|
|
25
27
|
import { createMemory, Layer } from './memory.js';
|
|
26
28
|
import { appendAuditEvent } from './audit.js';
|
|
@@ -46,21 +48,21 @@ export const MAX_CHANGE_SUMMARY_LEN = 4096;
|
|
|
46
48
|
function validateNoteFields(customer, note, changeSummary) {
|
|
47
49
|
const normalizedCustomer = (customer ?? '').trim();
|
|
48
50
|
if (normalizedCustomer.length === 0)
|
|
49
|
-
throw new
|
|
51
|
+
throw new BadRequestError('saveCustomerNote: customer is required');
|
|
50
52
|
if (/[\r\n]/.test(normalizedCustomer)) {
|
|
51
|
-
throw new
|
|
53
|
+
throw new BadRequestError('saveCustomerNote: customer must be a single line (no newlines)');
|
|
52
54
|
}
|
|
53
55
|
if (normalizedCustomer.length > MAX_CUSTOMER_LEN) {
|
|
54
|
-
throw new
|
|
56
|
+
throw new BadRequestError(`saveCustomerNote: customer exceeds the ${MAX_CUSTOMER_LEN}-char cap`);
|
|
55
57
|
}
|
|
56
58
|
if (!note || note.trim().length === 0) {
|
|
57
|
-
throw new
|
|
59
|
+
throw new BadRequestError('saveCustomerNote: note is required');
|
|
58
60
|
}
|
|
59
61
|
if (note.length > MAX_NOTE_LEN) {
|
|
60
|
-
throw new
|
|
62
|
+
throw new BadRequestError(`saveCustomerNote: note exceeds the ${MAX_NOTE_LEN}-char cap`);
|
|
61
63
|
}
|
|
62
64
|
if (changeSummary !== undefined && changeSummary.length > MAX_CHANGE_SUMMARY_LEN) {
|
|
63
|
-
throw new
|
|
65
|
+
throw new BadRequestError(`saveCustomerNote: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
|
|
64
66
|
}
|
|
65
67
|
return { customer: normalizedCustomer };
|
|
66
68
|
}
|
|
@@ -136,10 +138,10 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
136
138
|
// shape for the matching row, or undefined when no note/tenant pair matches.
|
|
137
139
|
const pred = db.prepare(`SELECT status, version FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesNoteId, tenantId);
|
|
138
140
|
if (!pred) {
|
|
139
|
-
throw new
|
|
141
|
+
throw new NotFoundError(`saveCustomerNote: note ${opts.supersedesNoteId} to supersede not found for tenant ${tenantId}`);
|
|
140
142
|
}
|
|
141
143
|
if (pred.status !== 'active') {
|
|
142
|
-
throw new
|
|
144
|
+
throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} is not active (status='${pred.status}'); only active notes can be superseded.`);
|
|
143
145
|
}
|
|
144
146
|
version = pred.version + 1;
|
|
145
147
|
}
|
|
@@ -157,7 +159,7 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
157
159
|
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
158
160
|
`).run(noteId, now, opts.supersedesNoteId, tenantId, noteId);
|
|
159
161
|
if (sup.changes === 0) {
|
|
160
|
-
throw new
|
|
162
|
+
throw new ConflictError(`saveCustomerNote: note ${opts.supersedesNoteId} could not be superseded (no longer active or self-reference).`);
|
|
161
163
|
}
|
|
162
164
|
appendAuditEvent(db, {
|
|
163
165
|
tenantId,
|
|
@@ -220,9 +222,9 @@ export function closeCustomerNote(hippoRoot, tenantId, id, actor = 'cli') {
|
|
|
220
222
|
// shape, or undefined when the id/tenant pair doesn't exist.
|
|
221
223
|
const existing = db.prepare(`SELECT status FROM customer_notes WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
|
|
222
224
|
if (!existing) {
|
|
223
|
-
throw new
|
|
225
|
+
throw new NotFoundError(`closeCustomerNote: note ${id} not found for tenant ${tenantId}`);
|
|
224
226
|
}
|
|
225
|
-
throw new
|
|
227
|
+
throw new ConflictError(`closeCustomerNote: note ${id} is not active (status='${existing.status}'); only active notes can be closed.`);
|
|
226
228
|
}
|
|
227
229
|
// SAFETY: SELECT ${NOTE_COLS} projects exactly the CustomerNoteRow columns;
|
|
228
230
|
// .get() returns that row for the just-updated id, or undefined only in an
|
|
@@ -230,7 +232,7 @@ export function closeCustomerNote(hippoRoot, tenantId, id, actor = 'cli') {
|
|
|
230
232
|
const row = db.prepare(`SELECT ${NOTE_COLS} FROM customer_notes WHERE id = ? AND tenant_id = ?`)
|
|
231
233
|
.get(id, tenantId);
|
|
232
234
|
if (!row)
|
|
233
|
-
throw new
|
|
235
|
+
throw new NotFoundError(`closeCustomerNote: note ${id} not found after UPDATE`);
|
|
234
236
|
appendAuditEvent(db, {
|
|
235
237
|
tenantId,
|
|
236
238
|
actor,
|
|
@@ -283,7 +285,7 @@ export function loadCustomerNotes(hippoRoot, tenantId, opts = {}) {
|
|
|
283
285
|
assertTenantId('loadCustomerNotes', tenantId);
|
|
284
286
|
const limit = opts.limit ?? 100;
|
|
285
287
|
if (opts.status && !VALID_NOTE_STATES.has(opts.status)) {
|
|
286
|
-
throw new
|
|
288
|
+
throw new BadRequestError(`loadCustomerNotes: status must be one of ${Array.from(VALID_NOTE_STATES).join('|')}; got ${opts.status}`);
|
|
287
289
|
}
|
|
288
290
|
const db = openHippoDb(hippoRoot);
|
|
289
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)}`);
|
package/dist/dashboard.js
CHANGED
|
@@ -9,7 +9,8 @@ import * as os from 'os';
|
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import { extractPathTags } from './path-context.js';
|
|
12
|
-
import { loadAllEntries,
|
|
12
|
+
import { loadAllEntries, listMemoryConflicts, readEntry, writeEntry } from './store.js';
|
|
13
|
+
import { listCards } from './store-cards.js';
|
|
13
14
|
import { calculateStrength, confidenceFacets } from './memory.js';
|
|
14
15
|
import { loadConfig } from './config.js';
|
|
15
16
|
import { listPeers } from './shared.js';
|
|
@@ -28,7 +29,7 @@ function buildDashboardData(hippoRoot) {
|
|
|
28
29
|
// render resolved conflicts as faded historical context, not just open
|
|
29
30
|
// conflicts. The open_conflicts stat below still counts only 'open' rows
|
|
30
31
|
// to preserve the existing badge meaning.
|
|
31
|
-
const conflicts = listMemoryConflicts(hippoRoot, '*');
|
|
32
|
+
const conflicts = listMemoryConflicts(hippoRoot, '*', tenantId);
|
|
32
33
|
// D4 v1.12.10: tenant-scope peer discovery in the dashboard (matches the
|
|
33
34
|
// tenantId already used for loadAllEntries on line 72).
|
|
34
35
|
const peers = listPeers(undefined, tenantId);
|
package/dist/db.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface DatabaseSyncLike {
|
|
|
10
10
|
exec(sql: string): void;
|
|
11
11
|
prepare(sql: string): StatementSyncLike;
|
|
12
12
|
close(): void;
|
|
13
|
+
readonly isOpen?: boolean;
|
|
14
|
+
readonly isTransaction?: boolean;
|
|
13
15
|
}
|
|
14
16
|
export declare function getHippoDbPath(hippoRoot: string): string;
|
|
15
17
|
export declare function getCurrentSchemaVersion(): number;
|
|
@@ -17,6 +19,16 @@ export declare function getCurrentSchemaVersion(): number;
|
|
|
17
19
|
export declare class IncompatibleBinaryError extends Error {
|
|
18
20
|
}
|
|
19
21
|
export declare function isSqliteBusy(error: unknown): boolean;
|
|
22
|
+
export declare function execWithBusyRetry(db: DatabaseSyncLike, sql: string, timeoutMs?: number): void;
|
|
23
|
+
/** Lock wait for hook commands: under the 5 s prompt-hook budget even after a few skipped writes, and far above a normal write's hold. */
|
|
24
|
+
export declare const HOOK_DB_WAIT_MS = 1000;
|
|
25
|
+
/** A busy store made a command skip work: warn once per process (the holder is usually `hippo sleep`). */
|
|
26
|
+
export declare function noteStoreBusy(skipped: string): void;
|
|
27
|
+
/** Runs `fn` with one handle per store: openHippoDb reuses it and closeHippoDb leaves it open until `fn` settles or the process exits.
|
|
28
|
+
* `busyWaitMs` is the lock wait of every open inside `fn` that does not pass its own. */
|
|
29
|
+
export declare function withSharedStoreHandles<T>(fn: () => T | Promise<T>, opts?: {
|
|
30
|
+
busyWaitMs?: number;
|
|
31
|
+
}): Promise<T>;
|
|
20
32
|
/** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
|
|
21
33
|
export declare function openHippoDb(hippoRoot: string, opts?: {
|
|
22
34
|
busyWaitMs?: number;
|