hippo-memory 1.57.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 +5 -5
- package/dist/api.js +40 -47
- 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/cli/shared.js +10 -6
- package/dist/cli.js +100 -39
- 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 +36 -34
- package/dist/customer-notes.js +14 -13
- package/dist/dag.js +3 -2
- 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/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/extract.js +3 -2
- 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/raw-archive.js +7 -6
- package/dist/recall-scope.d.ts +2 -1
- package/dist/recall-scope.js +2 -1
- 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.js +14 -2
- 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 +180 -409
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +15 -14
- package/dist/store.js +4 -4
- 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 +1 -1
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- package/dist/connectors/slack/ratelimit.js +0 -18
|
@@ -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
|
}
|
|
@@ -708,17 +708,14 @@ 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 partnersOf = mergePartners(tenantCandidates.map((e) => e.content));
|
|
711
712
|
for (let i = 0; i < tenantCandidates.length; i++) {
|
|
712
713
|
if (used.has(tenantCandidates[i].id) || tenantCandidates[i].content.length > MERGE_MAX_CHARS)
|
|
713
714
|
continue;
|
|
714
715
|
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) {
|
|
716
|
+
for (const j of partnersOf(i)) {
|
|
717
|
+
if (!used.has(tenantCandidates[j].id))
|
|
720
718
|
related.push(tenantCandidates[j]);
|
|
721
|
-
}
|
|
722
719
|
}
|
|
723
720
|
const cluster = [];
|
|
724
721
|
let clusterChars = 0;
|
|
@@ -793,8 +790,8 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
793
790
|
},
|
|
794
791
|
});
|
|
795
792
|
}
|
|
796
|
-
catch {
|
|
797
|
-
|
|
793
|
+
catch (error) {
|
|
794
|
+
reportAuditWriteFailure('reject_refusal', String(error));
|
|
798
795
|
}
|
|
799
796
|
continue;
|
|
800
797
|
}
|
|
@@ -911,8 +908,9 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
911
908
|
: {},
|
|
912
909
|
});
|
|
913
910
|
}
|
|
914
|
-
catch {
|
|
911
|
+
catch (error) {
|
|
915
912
|
auditFailures++;
|
|
913
|
+
reportAuditWriteFailure('mv_rescue', String(error), entry.id);
|
|
916
914
|
}
|
|
917
915
|
}
|
|
918
916
|
if (auditFailures > 0) {
|
|
@@ -965,7 +963,14 @@ function pickStrongestValence(entries) {
|
|
|
965
963
|
}
|
|
966
964
|
return 'neutral';
|
|
967
965
|
}
|
|
968
|
-
|
|
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 = {},
|
|
969
974
|
// LC2-E3 (opt-in, default off): ids rescued by this cycle's decay pass.
|
|
970
975
|
// detectConflicts recomputes its own strength>=DECAY_THRESHOLD survivor
|
|
971
976
|
// filter independently of the decay pass above; without this bypass,
|
|
@@ -978,8 +983,10 @@ rescuedIds = new Set()) {
|
|
|
978
983
|
&& !isQuarantineScope(entry.scope ?? null)
|
|
979
984
|
&& (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
|
|
980
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));
|
|
981
988
|
for (let i = 0; i < survivors.length; i++) {
|
|
982
|
-
for (
|
|
989
|
+
for (const j of partnersOf(i)) {
|
|
983
990
|
// Traces are variants of each other, not contradictions. Two
|
|
984
991
|
// strategies for the same task can both be valid; conflict detection
|
|
985
992
|
// exists for stated-rule disagreement, not strategy diversity.
|
|
@@ -989,7 +996,7 @@ rescuedIds = new Set()) {
|
|
|
989
996
|
continue;
|
|
990
997
|
if ([survivors[i], survivors[j]].some((e) => e.tags.includes('extracted') || e.tags.includes('session-digest')))
|
|
991
998
|
continue;
|
|
992
|
-
const reasonAndScore = describeConflict(
|
|
999
|
+
const reasonAndScore = describeConflict(profiles[i], profiles[j]);
|
|
993
1000
|
if (!reasonAndScore)
|
|
994
1001
|
continue;
|
|
995
1002
|
detected.push({
|
|
@@ -1002,25 +1009,26 @@ rescuedIds = new Set()) {
|
|
|
1002
1009
|
}
|
|
1003
1010
|
return detected;
|
|
1004
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
|
+
}
|
|
1005
1019
|
function describeConflict(a, b) {
|
|
1006
|
-
const aDistinct = distinctiveTokens(a.content);
|
|
1007
|
-
const bDistinct = distinctiveTokens(b.content);
|
|
1008
1020
|
// Jaccard on stopword-stripped tokens. Defer the threshold check until we
|
|
1009
1021
|
// know whether an explicit polarity pair is present (lower bar for those).
|
|
1010
|
-
const overlapScore = jaccardSets(
|
|
1022
|
+
const overlapScore = jaccardSets(a.distinct, b.distinct);
|
|
1011
1023
|
// Require at least N shared distinctive tokens so two short memories sharing
|
|
1012
1024
|
// only "the project name" don't register.
|
|
1013
1025
|
let shared = 0;
|
|
1014
|
-
for (const t of
|
|
1015
|
-
if (
|
|
1026
|
+
for (const t of a.distinct)
|
|
1027
|
+
if (b.distinct.has(t))
|
|
1016
1028
|
shared++;
|
|
1017
1029
|
if (shared < CONFLICT_MIN_RARE_SHARED)
|
|
1018
1030
|
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);
|
|
1031
|
+
const conflictType = classifyConflictType(a.window, b.window, a.polarity, b.polarity);
|
|
1024
1032
|
if (!conflictType)
|
|
1025
1033
|
return null;
|
|
1026
1034
|
if (overlapScore < CONFLICT_OVERLAP_THRESHOLD)
|
|
@@ -1052,14 +1060,8 @@ function jaccardSets(a, b) {
|
|
|
1052
1060
|
function openingWindow(text) {
|
|
1053
1061
|
return text.split(/\s+/).slice(0, POLARITY_WINDOW_WORDS).join(' ');
|
|
1054
1062
|
}
|
|
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() + ' ';
|
|
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) {
|
|
1063
1065
|
// Tightened tokens: require whole-word boundaries so " on " alone doesn't
|
|
1064
1066
|
// match "on/off". Pair only `enabled` ↔ `disabled` and explicit on/off in
|
|
1065
1067
|
// 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)}`);
|
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);
|
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;
|
package/dist/db.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createPhysicsTable } from './physics-state.js';
|
|
|
6
6
|
import { cleanupArchivedMirrors } from './raw-archive-mirror-cleanup.js';
|
|
7
7
|
import { PACKAGE_VERSION, compareSemver } from './version.js';
|
|
8
8
|
import { deriveOriginProject, originFromSource, isGlobalStoreRoot } from './project-identity.js';
|
|
9
|
+
import { log } from './log.js';
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
// SAFETY: node:sqlite's DatabaseSync constructor genuinely has this shape at
|
|
11
12
|
// runtime (Node's built-in synchronous SQLite module); there are no bundled
|
|
@@ -2607,7 +2608,7 @@ export function isSqliteBusy(error) {
|
|
|
2607
2608
|
// busy_timeout covers neither of this file's two contended statements: SQLite
|
|
2608
2609
|
// skips the busy handler for `PRAGMA journal_mode` and for a write that upgrades
|
|
2609
2610
|
// a deferred read snapshot. Both need an explicit wait instead.
|
|
2610
|
-
function execWithBusyRetry(db, sql, timeoutMs = 30000) {
|
|
2611
|
+
export function execWithBusyRetry(db, sql, timeoutMs = 30000) {
|
|
2611
2612
|
const deadline = Date.now() + timeoutMs;
|
|
2612
2613
|
const idle = new Int32Array(new SharedArrayBuffer(4));
|
|
2613
2614
|
for (;;) {
|
|
@@ -2622,8 +2623,66 @@ function execWithBusyRetry(db, sql, timeoutMs = 30000) {
|
|
|
2622
2623
|
}
|
|
2623
2624
|
}
|
|
2624
2625
|
}
|
|
2626
|
+
// Hook commands run on every prompt, so inside withSharedStoreHandles each store pays its pragmas, migration check and mirror cleanup once.
|
|
2627
|
+
const sharedHandles = new Map();
|
|
2628
|
+
const sharedSet = new WeakSet();
|
|
2629
|
+
let shareDepth = 0;
|
|
2630
|
+
let shareBusyWaitMs;
|
|
2631
|
+
/** 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. */
|
|
2632
|
+
export const HOOK_DB_WAIT_MS = 1000;
|
|
2633
|
+
/** A busy store made a command skip work: warn once per process (the holder is usually `hippo sleep`). */
|
|
2634
|
+
export function noteStoreBusy(skipped) {
|
|
2635
|
+
log.once('store-busy', 'warn', `store busy (another hippo process holds the write lock); ${skipped}`);
|
|
2636
|
+
// A lock held past one full wait belongs to a long transaction, so the hook's later writes skip at once.
|
|
2637
|
+
for (const db of sharedHandles.values()) {
|
|
2638
|
+
if (db.isOpen !== false)
|
|
2639
|
+
db.exec('PRAGMA busy_timeout = 0');
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
function closeSharedStoreHandles() {
|
|
2643
|
+
for (const db of sharedHandles.values()) {
|
|
2644
|
+
sharedSet.delete(db);
|
|
2645
|
+
if (db.isOpen !== false)
|
|
2646
|
+
db.close();
|
|
2647
|
+
}
|
|
2648
|
+
sharedHandles.clear();
|
|
2649
|
+
}
|
|
2650
|
+
/** Runs `fn` with one handle per store: openHippoDb reuses it and closeHippoDb leaves it open until `fn` settles or the process exits.
|
|
2651
|
+
* `busyWaitMs` is the lock wait of every open inside `fn` that does not pass its own. */
|
|
2652
|
+
export async function withSharedStoreHandles(fn, opts) {
|
|
2653
|
+
if (shareDepth++ === 0) {
|
|
2654
|
+
process.once('exit', closeSharedStoreHandles);
|
|
2655
|
+
shareBusyWaitMs = opts?.busyWaitMs;
|
|
2656
|
+
}
|
|
2657
|
+
try {
|
|
2658
|
+
return await fn();
|
|
2659
|
+
}
|
|
2660
|
+
finally {
|
|
2661
|
+
if (--shareDepth === 0) {
|
|
2662
|
+
process.off('exit', closeSharedStoreHandles);
|
|
2663
|
+
closeSharedStoreHandles();
|
|
2664
|
+
shareBusyWaitMs = undefined;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2625
2668
|
/** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
|
|
2626
2669
|
export function openHippoDb(hippoRoot, opts) {
|
|
2670
|
+
if (shareDepth === 0)
|
|
2671
|
+
return openOwnHippoDb(hippoRoot, opts);
|
|
2672
|
+
const busyWaitMs = opts?.busyWaitMs ?? shareBusyWaitMs;
|
|
2673
|
+
const key = `${path.resolve(getHippoDbPath(hippoRoot))}\0${busyWaitMs ?? ''}`;
|
|
2674
|
+
const shared = sharedHandles.get(key);
|
|
2675
|
+
if (shared?.isOpen && !shared.isTransaction)
|
|
2676
|
+
return shared;
|
|
2677
|
+
const db = openOwnHippoDb(hippoRoot, { busyWaitMs });
|
|
2678
|
+
// An open nested inside a transaction gets its own connection, as it did before sharing.
|
|
2679
|
+
if (!shared?.isOpen) {
|
|
2680
|
+
sharedHandles.set(key, db);
|
|
2681
|
+
sharedSet.add(db);
|
|
2682
|
+
}
|
|
2683
|
+
return db;
|
|
2684
|
+
}
|
|
2685
|
+
function openOwnHippoDb(hippoRoot, opts) {
|
|
2627
2686
|
fs.mkdirSync(hippoRoot, { recursive: true });
|
|
2628
2687
|
const db = new DatabaseSync(getHippoDbPath(hippoRoot));
|
|
2629
2688
|
const busyWaitMs = opts?.busyWaitMs;
|
|
@@ -2937,6 +2996,8 @@ function backfillFtsIndex(db) {
|
|
|
2937
2996
|
`);
|
|
2938
2997
|
}
|
|
2939
2998
|
export function closeHippoDb(db) {
|
|
2999
|
+
if (sharedSet.has(db))
|
|
3000
|
+
return;
|
|
2940
3001
|
db.close();
|
|
2941
3002
|
}
|
|
2942
3003
|
export function getMeta(db, key, fallback = '') {
|
package/dist/decisions.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* 'write_entry' (store.ts:1196) via the afterWrite hook, so a failure in any
|
|
24
24
|
* step rolls all of them back. Pattern matches savePrediction (predictions.ts).
|
|
25
25
|
*/
|
|
26
|
+
import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
|
|
26
27
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
27
28
|
import { writeEntry } from './store.js';
|
|
28
29
|
import { assertTenantId } from './tenant.js';
|
|
@@ -73,7 +74,7 @@ const DECISION_COLS = `
|
|
|
73
74
|
export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
74
75
|
assertTenantId('saveDecision', tenantId);
|
|
75
76
|
if (!opts.decisionText)
|
|
76
|
-
throw new
|
|
77
|
+
throw new BadRequestError('saveDecision: decisionText is required');
|
|
77
78
|
const now = new Date().toISOString();
|
|
78
79
|
const content = opts.context
|
|
79
80
|
? `${opts.decisionText}\n\nContext: ${opts.context}`
|
|
@@ -103,10 +104,10 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
103
104
|
// SAFETY: row shape matches the single `status` column named in the SELECT below.
|
|
104
105
|
const pred = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(opts.supersedesDecisionId, tenantId);
|
|
105
106
|
if (!pred) {
|
|
106
|
-
throw new
|
|
107
|
+
throw new NotFoundError(`saveDecision: decision ${opts.supersedesDecisionId} to supersede not found for tenant ${tenantId}`);
|
|
107
108
|
}
|
|
108
109
|
if (pred.status !== 'active') {
|
|
109
|
-
throw new
|
|
110
|
+
throw new ConflictError(`saveDecision: decision ${opts.supersedesDecisionId} is not active (status='${pred.status}'); only active decisions can be superseded.`);
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
const result = db.prepare(`
|
|
@@ -127,7 +128,7 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
127
128
|
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
128
129
|
`).run(decisionId, now, opts.supersedesDecisionId, tenantId, decisionId);
|
|
129
130
|
if (sup.changes === 0) {
|
|
130
|
-
throw new
|
|
131
|
+
throw new BadRequestError(`saveDecision: decision ${opts.supersedesDecisionId} could not be superseded (no longer active or self-reference).`);
|
|
131
132
|
}
|
|
132
133
|
appendAuditEvent(db, {
|
|
133
134
|
tenantId,
|
|
@@ -191,15 +192,15 @@ export function closeDecision(hippoRoot, tenantId, id, actor = 'cli') {
|
|
|
191
192
|
// SAFETY: row shape matches the single `status` column named in the SELECT above.
|
|
192
193
|
const existing = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
|
|
193
194
|
if (!existing) {
|
|
194
|
-
throw new
|
|
195
|
+
throw new NotFoundError(`closeDecision: decision ${id} not found for tenant ${tenantId}`);
|
|
195
196
|
}
|
|
196
|
-
throw new
|
|
197
|
+
throw new ConflictError(`closeDecision: decision ${id} is not active (status='${existing.status}'); only active decisions can be closed.`);
|
|
197
198
|
}
|
|
198
199
|
// SAFETY: row's shape matches the columns named in DECISION_COLS above.
|
|
199
200
|
const row = db.prepare(`SELECT ${DECISION_COLS} FROM decisions WHERE id = ? AND tenant_id = ?`)
|
|
200
201
|
.get(id, tenantId);
|
|
201
202
|
if (!row)
|
|
202
|
-
throw new
|
|
203
|
+
throw new NotFoundError(`closeDecision: decision ${id} not found after UPDATE`);
|
|
203
204
|
appendAuditEvent(db, {
|
|
204
205
|
tenantId,
|
|
205
206
|
actor,
|
|
@@ -256,7 +257,7 @@ export function loadDecisions(hippoRoot, tenantId, opts = {}) {
|
|
|
256
257
|
let rows;
|
|
257
258
|
if (opts.status) {
|
|
258
259
|
if (!VALID_DECISION_STATES.has(opts.status)) {
|
|
259
|
-
throw new
|
|
260
|
+
throw new BadRequestError(`loadDecisions: status must be one of ${Array.from(VALID_DECISION_STATES).join('|')}; got ${opts.status}`);
|
|
260
261
|
}
|
|
261
262
|
// SAFETY: rows' shape matches the columns named in DECISION_COLS above.
|
|
262
263
|
rows = db.prepare(`
|
package/dist/doctor.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as path from 'node:path';
|
|
|
10
10
|
import { findHippoStoreDir } from './project-identity.js';
|
|
11
11
|
import { getGlobalRoot } from './shared.js';
|
|
12
12
|
import { isInitialized } from './store.js';
|
|
13
|
+
import { loadConfig } from './config.js';
|
|
13
14
|
import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
|
|
14
15
|
import { REPLAY_AFTER_MS, TRANSCRIPT_FILL_WINDOW_MS } from './compaction-record.js';
|
|
15
16
|
import { isEmbeddingAvailable } from './embeddings.js';
|
|
@@ -208,6 +209,10 @@ export function runDoctor(opts) {
|
|
|
208
209
|
closeHippoDb(db);
|
|
209
210
|
}
|
|
210
211
|
}
|
|
212
|
+
const holdoutRateBp = store === null ? 0 : loadConfig(store).pilot.holdoutRateBp;
|
|
213
|
+
if (holdoutRateBp > 0) {
|
|
214
|
+
checks.push({ id: 'pilot', status: 'info', detail: `pilot holdout on: about ${holdoutRateBp / 100}% of sessions get no memories pushed by hippo (pilot.holdoutRateBp=${holdoutRateBp})` });
|
|
215
|
+
}
|
|
211
216
|
const claudeDir = path.join(home, '.claude');
|
|
212
217
|
if (fs.existsSync(claudeDir)) {
|
|
213
218
|
const settings = readJson(path.join(claudeDir, 'settings.json'));
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { getEmbedding, isEmbeddingAvailable, resolveEmbeddingModel, DEFAULT_EMBEDDING_MODEL, } from './embeddings.js';
|
|
34
34
|
import { loadConfig } from './config.js';
|
|
35
35
|
import { redactSecrets } from './secret-detect.js';
|
|
36
|
+
import { fetchWithRetry } from './http-retry.js';
|
|
36
37
|
export const API_PROVIDER_KINDS = ['openai', 'voyage', 'cohere'];
|
|
37
38
|
function isApiProviderKind(x) {
|
|
38
39
|
return x === 'openai' || x === 'voyage' || x === 'cohere';
|
|
@@ -184,15 +185,14 @@ class ApiEmbeddingProvider {
|
|
|
184
185
|
const url = `${this.baseUrl.replace(/\/$/, '')}/${spec.path}`;
|
|
185
186
|
let resp;
|
|
186
187
|
try {
|
|
187
|
-
resp = await
|
|
188
|
+
resp = await fetchWithRetry(url, {
|
|
188
189
|
method: 'POST',
|
|
189
190
|
headers: {
|
|
190
191
|
'content-type': 'application/json',
|
|
191
192
|
authorization: `Bearer ${key}`,
|
|
192
193
|
},
|
|
193
194
|
body: JSON.stringify(spec.buildBody(this.model, chunk.map(redactSecrets), role)),
|
|
194
|
-
|
|
195
|
-
});
|
|
195
|
+
}, { timeoutMs: REQUEST_TIMEOUT_MS });
|
|
196
196
|
}
|
|
197
197
|
catch (err) {
|
|
198
198
|
const msg = err instanceof Error ? err.message : String(err);
|
package/dist/embeddings.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Falls back silently if the library is not installed.
|
|
5
5
|
*/
|
|
6
6
|
import { MemoryEntry } from './memory.js';
|
|
7
|
+
import { type EmbeddingProvider } from './embedding-provider.js';
|
|
7
8
|
export declare const DEFAULT_EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
8
9
|
export declare const EMBEDDING_MODEL_META_KEY = "embedding_model";
|
|
9
10
|
/**
|
|
@@ -114,8 +115,7 @@ export declare function getEmbedding(text: string, model?: string, role?: Embedd
|
|
|
114
115
|
*/
|
|
115
116
|
export declare function cosineSimilarity(a: number[], b: number[]): number;
|
|
116
117
|
/**
|
|
117
|
-
* Load the cached embedding index
|
|
118
|
-
* Returns an empty object if the file doesn't exist or is corrupt.
|
|
118
|
+
* Load the cached embedding index; `{}` when the file is missing. A corrupt file is moved aside and rebuilt on the next embed; any other read error throws, so nothing saves over an index it could not read.
|
|
119
119
|
*/
|
|
120
120
|
export declare function loadEmbeddingIndex(hippoRoot: string): Record<string, number[]>;
|
|
121
121
|
/**
|
|
@@ -129,7 +129,7 @@ export declare function embedMemory(hippoRoot: string, entry: MemoryEntry, model
|
|
|
129
129
|
/**
|
|
130
130
|
* Embed all entries in hippoRoot that don't already have cached vectors.
|
|
131
131
|
* Prunes orphaned embeddings for memories that no longer exist.
|
|
132
|
-
* Returns the count of newly embedded entries.
|
|
132
|
+
* Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
|
|
133
133
|
*/
|
|
134
|
-
export declare function embedAll(hippoRoot: string, model?: string): Promise<number>;
|
|
134
|
+
export declare function embedAll(hippoRoot: string, model?: string, provider?: EmbeddingProvider): Promise<number>;
|
|
135
135
|
//# sourceMappingURL=embeddings.d.ts.map
|