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
package/dist/api.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* in exactly one place.
|
|
8
8
|
*/
|
|
9
9
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
10
|
+
import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
|
|
11
|
+
export { ApiError, BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
|
|
10
12
|
import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, memoriesBackingObjects, } from './store.js';
|
|
11
13
|
import { RejectedValueError } from './rejection.js';
|
|
12
14
|
import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
|
|
@@ -16,15 +18,15 @@ import { detectInstruction } from './instruction-detect.js';
|
|
|
16
18
|
import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
|
|
17
19
|
import { summarizeFailures } from './failure-log.js';
|
|
18
20
|
import { formatHandoffEvidenceLine } from './handoff.js';
|
|
19
|
-
import { createMemory, createSuccessor, applyOutcome, calculateStrength, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
|
|
20
|
-
import { appendAuditEvent, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
21
|
+
import { createMemory, createSuccessor, applyOutcome, calculateStrength, markRetrieved, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
|
|
22
|
+
import { appendAuditEvent, reportAuditWriteFailure, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
21
23
|
import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
|
|
22
24
|
import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
|
|
23
25
|
import { evalNow } from './ablation.js';
|
|
24
26
|
import { archiveRawMemory } from './raw-archive.js';
|
|
25
27
|
import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
|
|
26
28
|
import { applyGoalStackBoost } from './goals.js';
|
|
27
|
-
import {
|
|
29
|
+
import { estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
|
|
28
30
|
import { compareEntryIdentity, compareScoredResults } from './compare.js';
|
|
29
31
|
import { dropHeldCopies, duplicateKey, storedTextKeys } from './same-text.js';
|
|
30
32
|
import { scopeMatch } from './scope.js';
|
|
@@ -32,14 +34,14 @@ import { consolidate } from './consolidate.js';
|
|
|
32
34
|
import { loadConfig } from './config.js';
|
|
33
35
|
import { resolveProjectIdentity, classifyOriginProject, isGlobalStoreRoot } from './project-identity.js';
|
|
34
36
|
import { promptTokens, contentTokens, gatePromptRecall, } from './prompt-recall.js';
|
|
35
|
-
import { detectSecret } from './secret-detect.js';
|
|
37
|
+
import { detectSecret, vetSecrets } from './secret-detect.js';
|
|
36
38
|
import { isSessionDigestRow } from './session-digest.js';
|
|
37
39
|
import { deduplicateStore } from './dedupe.js';
|
|
38
40
|
import { computeAmbientState } from './ambient.js';
|
|
39
41
|
import { loadPendingExtractionTenants, markPendingProcessedUpTo } from './graph.js';
|
|
40
42
|
import { extractGraph } from './graph-extract.js';
|
|
41
43
|
import { computePlanningFallacyOutput, } from './predictions.js';
|
|
42
|
-
import { detectAnchoring, hashQueryText, } from './recall-history.js';
|
|
44
|
+
import { detectAnchoring, hashQueryText, biasHintEnabled, } from './recall-history.js';
|
|
43
45
|
import { detectAvailabilityBias } from './availability.js';
|
|
44
46
|
/**
|
|
45
47
|
* Helper for building process-local (admin-by-default) Actor values. v1.12.0
|
|
@@ -68,7 +70,7 @@ export function adminActor(subject) {
|
|
|
68
70
|
* full-store fallback (codex v1.7.0 diff-pass P1). Validated upfront
|
|
69
71
|
* so the contract holds.
|
|
70
72
|
*/
|
|
71
|
-
export class RecallContractError extends
|
|
73
|
+
export class RecallContractError extends BadRequestError {
|
|
72
74
|
code;
|
|
73
75
|
constructor(code, message) {
|
|
74
76
|
super(message);
|
|
@@ -76,13 +78,6 @@ export class RecallContractError extends Error {
|
|
|
76
78
|
this.code = code;
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
|
-
/** The actor's role or identity does not allow the operation. HTTP maps it to 403. */
|
|
80
|
-
export class ForbiddenError extends Error {
|
|
81
|
-
constructor(message) {
|
|
82
|
-
super(message);
|
|
83
|
-
this.name = 'ForbiddenError';
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
81
|
// v1.25.0: the recall-side scope predicates (PRIVATE_SCOPE_RE, isPrivateScope,
|
|
87
82
|
// passesScopeFilterForRecall) live in recall-scope.ts (leaf) so shared.ts can
|
|
88
83
|
// apply the same default-deny rule to searchBothHybrid's internal loads
|
|
@@ -107,23 +102,22 @@ export { classifyOriginProject } from './project-identity.js';
|
|
|
107
102
|
* injects inside its owning project; flagged rows with no project origin
|
|
108
103
|
* (''/null) never ambient-inject at all. Explicit recall is unaffected -
|
|
109
104
|
* recalling a secret is a deliberate act.
|
|
110
|
-
* - S2 envelope parity: private scopes
|
|
105
|
+
* - S2 envelope parity: private/quarantine scopes never inject unless `exactScope` names one.
|
|
111
106
|
* - S3 origin partition: other-project rows are excluded unless
|
|
112
107
|
* `includeCrossProject`.
|
|
113
108
|
*/
|
|
114
|
-
function ambientAdmitEntry(e, currentProjectName, includeCrossProject) {
|
|
109
|
+
function ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope) {
|
|
115
110
|
if (!ambientSecretAdmit(e, currentProjectName))
|
|
116
111
|
return false;
|
|
117
|
-
if (!passesScopeFilterForRecall(e.scope ?? null,
|
|
112
|
+
if (!passesScopeFilterForRecall(e.scope ?? null, exactScope))
|
|
118
113
|
return false;
|
|
119
114
|
if (includeCrossProject)
|
|
120
115
|
return true;
|
|
121
116
|
return classifyOriginProject(e.origin_project, currentProjectName) !== 'cross-project';
|
|
122
117
|
}
|
|
123
118
|
/**
|
|
124
|
-
* v39 S4: the secret half of the ambient policy on its own, for
|
|
125
|
-
*
|
|
126
|
-
* exact-match). A flagged row is only admitted inside its owning project;
|
|
119
|
+
* v39 S4: the secret half of the ambient policy on its own, for callers
|
|
120
|
+
* that apply their own scope rule. A flagged row is only admitted inside its owning project;
|
|
127
121
|
* flagged rows with no project origin never ambient-inject.
|
|
128
122
|
*/
|
|
129
123
|
export function ambientSecretAdmit(e, currentProjectName) {
|
|
@@ -171,9 +165,10 @@ export function oneCopyPerMemory(local, global, now) {
|
|
|
171
165
|
return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
|
|
172
166
|
}
|
|
173
167
|
export function remember(ctx, opts) {
|
|
174
|
-
const
|
|
168
|
+
const vetted = vetSecrets(opts.content, opts.tags ?? [], opts.untrusted === true);
|
|
169
|
+
const detection = opts.untrusted ? detectInstruction(vetted.content) : { flagged: false, reason: null };
|
|
175
170
|
const requestedScope = opts.scope ?? null;
|
|
176
|
-
const entry = createMemory(
|
|
171
|
+
const entry = createMemory(vetted.content, {
|
|
177
172
|
kind: opts.kind ?? 'distilled',
|
|
178
173
|
scope: detection.flagged ? quarantineScopeFor(requestedScope) : requestedScope,
|
|
179
174
|
owner: opts.owner ?? null,
|
|
@@ -200,6 +195,8 @@ export function remember(ctx, opts) {
|
|
|
200
195
|
const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
|
|
201
196
|
if (detection.flagged)
|
|
202
197
|
result.quarantined = { reason: detection.reason ?? 'unknown' };
|
|
198
|
+
if (vetted.warnings.length > 0)
|
|
199
|
+
result.warnings = vetted.warnings;
|
|
203
200
|
return result;
|
|
204
201
|
}
|
|
205
202
|
/**
|
|
@@ -244,6 +241,8 @@ export function recall(ctx, opts) {
|
|
|
244
241
|
export async function retrieve(ctx, opts) {
|
|
245
242
|
assertScopeRequestAllowed(ctx.actor, opts.scope);
|
|
246
243
|
const windowSize = recallWindowSize(opts);
|
|
244
|
+
if (opts.showRanked)
|
|
245
|
+
return retrieveFromStore(ctx, opts, windowSize, opts.showRanked);
|
|
247
246
|
let candidates = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false);
|
|
248
247
|
if (opts.mode === 'hybrid' || opts.mode === 'physics') {
|
|
249
248
|
const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, scope: opts.scope ?? null };
|
|
@@ -257,6 +256,40 @@ export async function retrieve(ctx, opts) {
|
|
|
257
256
|
strengthenRetrieved(ctx.hippoRoot, result.results.map((r) => r.id), ctx.tenantId);
|
|
258
257
|
return result;
|
|
259
258
|
}
|
|
259
|
+
/** `retrieve` under `showRanked`: physics when `mode` says so, hybrid otherwise, over every admitted row. */
|
|
260
|
+
async function retrieveFromStore(ctx, opts, windowSize, show) {
|
|
261
|
+
const store = loadAllEntries(ctx.hippoRoot, ctx.tenantId);
|
|
262
|
+
const pool = store.filter((e) => passesScopeFilterForRecall(e.scope ?? null, opts.scope));
|
|
263
|
+
// No scope option: the scope boost follows HIPPO_SCOPE and the skill env, as MCP recall always ranked.
|
|
264
|
+
const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot };
|
|
265
|
+
let ranked = opts.mode === 'physics'
|
|
266
|
+
? await physicsSearch(opts.query, pool, { ...searchOpts, physicsConfig: loadConfig(ctx.hippoRoot).physics })
|
|
267
|
+
: await hybridSearch(opts.query, pool, searchOpts);
|
|
268
|
+
if (opts.sessionId && !opts.goalTag) {
|
|
269
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
270
|
+
try {
|
|
271
|
+
ranked = applyGoalStackBoost(db, ranked, { sessionId: opts.sessionId, tenantId: ctx.tenantId, limit: ranked.length });
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
closeHippoDb(db);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const window = ranked.slice(0, windowSize).map((r) => r.entry);
|
|
278
|
+
const result = recallFrom(ctx, { ...opts, suppressRecallTrace: true }, windowSize, window);
|
|
279
|
+
const shown = show({ ranked, pool, droppedByScope: store.length - pool.length }, result);
|
|
280
|
+
strengthenRetrieved(ctx.hippoRoot, shown, ctx.tenantId);
|
|
281
|
+
if (!opts.suppressRecallTrace) {
|
|
282
|
+
const scores = new Map(ranked.map((r) => [r.entry.id, r.score]));
|
|
283
|
+
writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
284
|
+
tenantId: ctx.tenantId,
|
|
285
|
+
sessionId: opts.sessionId ?? null,
|
|
286
|
+
pipeline: 'mcp',
|
|
287
|
+
query: opts.query,
|
|
288
|
+
results: shown.map((id) => ({ memoryId: id, score: scores.get(id) ?? 0 })),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
260
293
|
/** Contract preflight: throws before any store-touching work. */
|
|
261
294
|
function recallWindowSize(opts) {
|
|
262
295
|
// F5 (v1.6.5) preflight — codex P1: original guard fired AFTER
|
|
@@ -564,9 +597,9 @@ function recallFrom(ctx, opts, windowSize, all) {
|
|
|
564
597
|
// handle. v1.11.5 contract lock holds — api.recall does NOT write
|
|
565
598
|
// last_trace_id (tests/api-recall-no-side-effects.test.ts); a trace INSERT
|
|
566
599
|
// is the same observability class as the audit row it sits beside, not
|
|
567
|
-
// retrieval state. F2 fix: suppressed when the caller
|
|
568
|
-
//
|
|
569
|
-
//
|
|
600
|
+
// retrieval state. F2 fix: suppressed when the caller traces its own,
|
|
601
|
+
// different result set (retrieve under showRanked traces the shown list as
|
|
602
|
+
// 'mcp'). Fail-soft internally; never throws.
|
|
570
603
|
if (!opts.suppressRecallTrace) {
|
|
571
604
|
writeRecallTrace(db, {
|
|
572
605
|
tenantId: ctx.tenantId,
|
|
@@ -656,7 +689,7 @@ function recallFrom(ctx, opts, windowSize, all) {
|
|
|
656
689
|
// detect call returns null and api.recall's anchoringHint stays absent.
|
|
657
690
|
let anchoringHint = null;
|
|
658
691
|
let suppressedByInterferenceCount = 0;
|
|
659
|
-
if (
|
|
692
|
+
if (biasHintEnabled('anchoring') && opts.recallHistory) {
|
|
660
693
|
const queryHash = hashQueryText(opts.query);
|
|
661
694
|
const topMemoryId = rankedOut[0]?.id ?? null;
|
|
662
695
|
anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
|
|
@@ -709,7 +742,7 @@ function recallFrom(ctx, opts, windowSize, all) {
|
|
|
709
742
|
// opts.recallHistory gate above so we never double-emit the audit op. Audit
|
|
710
743
|
// emission is pipeline-local, mirroring the J1 block above.
|
|
711
744
|
let availabilityHint = null;
|
|
712
|
-
if (
|
|
745
|
+
if (biasHintEnabled('availability') && !opts.suppressAvailabilityHint) {
|
|
713
746
|
availabilityHint = detectAvailabilityBias({
|
|
714
747
|
topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
|
|
715
748
|
pool: entries.map((e) => ({ id: e.id, created: e.created })),
|
|
@@ -1072,7 +1105,7 @@ export function forget(ctx, id) {
|
|
|
1072
1105
|
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1073
1106
|
.get(id);
|
|
1074
1107
|
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1075
|
-
throw new
|
|
1108
|
+
throw new NotFoundError(`memory not found: ${id}`);
|
|
1076
1109
|
}
|
|
1077
1110
|
}
|
|
1078
1111
|
finally {
|
|
@@ -1080,7 +1113,7 @@ export function forget(ctx, id) {
|
|
|
1080
1113
|
}
|
|
1081
1114
|
const removed = deleteEntry(ctx.hippoRoot, id, { actor: ctx.actor.subject });
|
|
1082
1115
|
if (!removed) {
|
|
1083
|
-
throw new
|
|
1116
|
+
throw new NotFoundError(`memory not found: ${id}`);
|
|
1084
1117
|
}
|
|
1085
1118
|
// Counted here, not in the CLI: both callers of this function (cmdForget and
|
|
1086
1119
|
// the HTTP route) are the two paths of one user command, so neither can miss
|
|
@@ -1115,7 +1148,7 @@ export function reject(ctx, opts) {
|
|
|
1115
1148
|
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1116
1149
|
.get(opts.memoryId);
|
|
1117
1150
|
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1118
|
-
throw new
|
|
1151
|
+
throw new NotFoundError(`memory not found: ${opts.memoryId}`);
|
|
1119
1152
|
}
|
|
1120
1153
|
}
|
|
1121
1154
|
finally {
|
|
@@ -1141,10 +1174,10 @@ export function reject(ctx, opts) {
|
|
|
1141
1174
|
export function unreject(ctx, digestOrPrefix) {
|
|
1142
1175
|
const outcome = unrejectValue(ctx.hippoRoot, ctx.tenantId, digestOrPrefix, ctx.actor.subject);
|
|
1143
1176
|
if (outcome.status === 'not_found') {
|
|
1144
|
-
throw new
|
|
1177
|
+
throw new NotFoundError(`no rejected value matches: ${digestOrPrefix}`);
|
|
1145
1178
|
}
|
|
1146
1179
|
if (outcome.status === 'ambiguous') {
|
|
1147
|
-
throw new
|
|
1180
|
+
throw new BadRequestError(`"${digestOrPrefix}" matches ${outcome.candidates.length} tombstones; use a longer prefix`);
|
|
1148
1181
|
}
|
|
1149
1182
|
return { ok: true, digest: outcome.digest };
|
|
1150
1183
|
}
|
|
@@ -1167,7 +1200,7 @@ export function promote(ctx, id) {
|
|
|
1167
1200
|
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1168
1201
|
.get(id);
|
|
1169
1202
|
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1170
|
-
throw new
|
|
1203
|
+
throw new NotFoundError(`memory not found: ${id}`);
|
|
1171
1204
|
}
|
|
1172
1205
|
}
|
|
1173
1206
|
finally {
|
|
@@ -1199,13 +1232,13 @@ export function supersede(ctx, oldId, newContent) {
|
|
|
1199
1232
|
// info leak.
|
|
1200
1233
|
const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
|
|
1201
1234
|
if (!old) {
|
|
1202
|
-
throw new
|
|
1235
|
+
throw new NotFoundError(`Memory not found: ${oldId}`);
|
|
1203
1236
|
}
|
|
1204
1237
|
// Guard: not already superseded. The CAS UPDATE below race-safely closes
|
|
1205
1238
|
// the window between this read and the write; this check just produces a
|
|
1206
1239
|
// clearer error in the common single-writer case.
|
|
1207
1240
|
if (old.superseded_by) {
|
|
1208
|
-
throw new
|
|
1241
|
+
throw new ConflictError(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
|
|
1209
1242
|
}
|
|
1210
1243
|
const newEntry = createSuccessor(old, newContent, {
|
|
1211
1244
|
tenantId: ctx.tenantId,
|
|
@@ -1233,7 +1266,7 @@ export function supersede(ctx, oldId, newContent) {
|
|
|
1233
1266
|
`).run(newEntry.id, oldId, ctx.tenantId);
|
|
1234
1267
|
if ((result.changes ?? 0) === 0) {
|
|
1235
1268
|
db.exec('ROLLBACK');
|
|
1236
|
-
throw new
|
|
1269
|
+
throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
|
|
1237
1270
|
}
|
|
1238
1271
|
// v0.30 / E2 — DAG live-coupling: OLD entry just transitioned to
|
|
1239
1272
|
// superseded. Its parent (if any) needs rebuild. Lands strictly
|
|
@@ -1304,7 +1337,7 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
|
|
|
1304
1337
|
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1305
1338
|
.get(id);
|
|
1306
1339
|
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1307
|
-
throw new
|
|
1340
|
+
throw new NotFoundError(`memory not found: ${id}`);
|
|
1308
1341
|
}
|
|
1309
1342
|
archiveRawMemory(db, id, {
|
|
1310
1343
|
reason,
|
|
@@ -1383,8 +1416,9 @@ export function authCreate(ctx, opts) {
|
|
|
1383
1416
|
},
|
|
1384
1417
|
});
|
|
1385
1418
|
}
|
|
1386
|
-
catch {
|
|
1419
|
+
catch (error) {
|
|
1387
1420
|
// Audit must not crash a successful mint.
|
|
1421
|
+
reportAuditWriteFailure('auth_create', String(error), result.keyId);
|
|
1388
1422
|
}
|
|
1389
1423
|
return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
|
|
1390
1424
|
}
|
|
@@ -1422,11 +1456,11 @@ export function authRevoke(ctx, keyId) {
|
|
|
1422
1456
|
.prepare(`SELECT key_id, tenant_id, revoked_at, role FROM api_keys WHERE key_id = ?`)
|
|
1423
1457
|
.get(keyId);
|
|
1424
1458
|
if (!row) {
|
|
1425
|
-
throw new
|
|
1459
|
+
throw new NotFoundError(`Unknown key_id: ${keyId}`);
|
|
1426
1460
|
}
|
|
1427
1461
|
// Cross-tenant access denied: same message as missing key, no info leak.
|
|
1428
1462
|
if (row.tenant_id !== ctx.tenantId) {
|
|
1429
|
-
throw new
|
|
1463
|
+
throw new NotFoundError(`Unknown key_id: ${keyId}`);
|
|
1430
1464
|
}
|
|
1431
1465
|
if (ctx.actor.viaAuthResolver && row.role === 'admin') {
|
|
1432
1466
|
throw new ForbiddenError('An auth resolver admin cannot revoke an admin key, which outranks it');
|
|
@@ -1455,8 +1489,9 @@ export function authRevoke(ctx, keyId) {
|
|
|
1455
1489
|
targetId: keyId,
|
|
1456
1490
|
});
|
|
1457
1491
|
}
|
|
1458
|
-
catch {
|
|
1492
|
+
catch (error) {
|
|
1459
1493
|
// Audit must not crash a successful revoke.
|
|
1494
|
+
reportAuditWriteFailure('auth_revoke', String(error), keyId);
|
|
1460
1495
|
}
|
|
1461
1496
|
}
|
|
1462
1497
|
return { ok: true, revokedAt };
|
|
@@ -1484,13 +1519,13 @@ function changeScopeGrant(ctx, keyId, scope, op) {
|
|
|
1484
1519
|
.prepare(`SELECT tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
|
|
1485
1520
|
.get(keyId);
|
|
1486
1521
|
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1487
|
-
throw new
|
|
1522
|
+
throw new NotFoundError(`Unknown key_id: ${keyId}`);
|
|
1488
1523
|
}
|
|
1489
1524
|
if (op === 'auth_grant' && row.revoked_at) {
|
|
1490
|
-
throw new
|
|
1525
|
+
throw new ConflictError(`${keyId} is revoked; a grant on it would never apply`);
|
|
1491
1526
|
}
|
|
1492
1527
|
if (!isRestrictedScope(scope)) {
|
|
1493
|
-
throw new
|
|
1528
|
+
throw new BadRequestError(`${scope} is not a restricted scope; it is already readable by default`);
|
|
1494
1529
|
}
|
|
1495
1530
|
if (op === 'auth_grant')
|
|
1496
1531
|
grantScope(db, keyId, scope);
|
|
@@ -1501,7 +1536,7 @@ function changeScopeGrant(ctx, keyId, scope, op) {
|
|
|
1501
1536
|
}
|
|
1502
1537
|
catch (err) {
|
|
1503
1538
|
// Audit must not undo a grant change that already committed; surface it instead.
|
|
1504
|
-
|
|
1539
|
+
reportAuditWriteFailure(op, String(err), keyId);
|
|
1505
1540
|
}
|
|
1506
1541
|
return { ok: true };
|
|
1507
1542
|
}
|
|
@@ -1551,6 +1586,8 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1551
1586
|
const limit = opts.limit ?? Number.POSITIVE_INFINITY;
|
|
1552
1587
|
const includeRecent = opts.includeRecent ?? 0;
|
|
1553
1588
|
const activeScope = opts.scope ?? '';
|
|
1589
|
+
assertScopeRequestAllowed(ctx.actor, opts.exactScope);
|
|
1590
|
+
const exactScope = opts.exactScope || undefined;
|
|
1554
1591
|
if (budget <= 0) {
|
|
1555
1592
|
return { entries: [], tokens: 0 };
|
|
1556
1593
|
}
|
|
@@ -1562,10 +1599,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1562
1599
|
const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
|
|
1563
1600
|
const hasLocalTaskState = hasLocal && !primaryIsGlobal;
|
|
1564
1601
|
// v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
|
|
1565
|
-
// S2: envelope-filter parity with api.recall
|
|
1566
|
-
// scopes and quarantine buckets never inject. `requested` is deliberately
|
|
1567
|
-
// undefined: opts.scope is the scope-TAG boost input here, not an
|
|
1568
|
-
// envelope-scope request (api.recall's exact-match semantics don't apply).
|
|
1602
|
+
// S2: envelope-filter parity with api.recall; opts.scope is only the tag boost, opts.exactScope the envelope request.
|
|
1569
1603
|
// S3: origin partition - other-project memories are excluded unless the
|
|
1570
1604
|
// caller explicitly asks for them (crossProject) or isolation is disabled.
|
|
1571
1605
|
const config = loadConfig(ctx.hippoRoot);
|
|
@@ -1612,9 +1646,8 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1612
1646
|
sessionId: opts.currentSessionId,
|
|
1613
1647
|
})
|
|
1614
1648
|
: null;
|
|
1615
|
-
// W1:
|
|
1616
|
-
|
|
1617
|
-
const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), undefined)
|
|
1649
|
+
// W1: the same envelope rule ambientAdmitEntry applies to memory rows.
|
|
1650
|
+
const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), exactScope)
|
|
1618
1651
|
? rawActiveSnapshot
|
|
1619
1652
|
: null;
|
|
1620
1653
|
// Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
|
|
@@ -1628,7 +1661,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1628
1661
|
// codex P2: admit scope in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
|
|
1629
1662
|
scopeFilter: 'default-deny',
|
|
1630
1663
|
});
|
|
1631
|
-
const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff),
|
|
1664
|
+
const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), exactScope)
|
|
1632
1665
|
? rawSessionHandoff
|
|
1633
1666
|
: null;
|
|
1634
1667
|
// Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
|
|
@@ -1636,7 +1669,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1636
1669
|
? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
|
|
1637
1670
|
session_id: rawActiveSnapshot.session_id,
|
|
1638
1671
|
limit: 5,
|
|
1639
|
-
}).filter((e) => passesScopeFilterForRecall(rowScope(e),
|
|
1672
|
+
}).filter((e) => passesScopeFilterForRecall(rowScope(e), exactScope))
|
|
1640
1673
|
: [];
|
|
1641
1674
|
const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
|
|
1642
1675
|
const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
|
|
@@ -1650,7 +1683,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1650
1683
|
digestHiddenForHandoff = true;
|
|
1651
1684
|
return false;
|
|
1652
1685
|
}
|
|
1653
|
-
return ambientAdmitEntry(e, currentProjectName, includeCrossProject);
|
|
1686
|
+
return ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope);
|
|
1654
1687
|
};
|
|
1655
1688
|
const ownSessionId = opts.currentSessionId || '';
|
|
1656
1689
|
// Inside admit, not after the load, so the loader's window widens past a session's own items.
|
|
@@ -2198,10 +2231,10 @@ export function restoreDormant(ctx, id) {
|
|
|
2198
2231
|
try {
|
|
2199
2232
|
const dormant = readDormantSnapshot(db, ctx.tenantId, id);
|
|
2200
2233
|
if (!dormant) {
|
|
2201
|
-
throw new
|
|
2234
|
+
throw new NotFoundError(`dormant memory not found: ${id}`);
|
|
2202
2235
|
}
|
|
2203
2236
|
if (db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(id) !== undefined) {
|
|
2204
|
-
throw new
|
|
2237
|
+
throw new ConflictError(`memory ${id} is already active; forget it before restoring its dormant copy`);
|
|
2205
2238
|
}
|
|
2206
2239
|
const now = new Date();
|
|
2207
2240
|
// Dormant rows are long-lived, so a snapshot can predate a field added
|
|
@@ -2260,7 +2293,7 @@ export function forgetDormant(ctx, id) {
|
|
|
2260
2293
|
const db = openHippoDb(ctx.hippoRoot);
|
|
2261
2294
|
try {
|
|
2262
2295
|
if (!deleteDormantRow(db, ctx.tenantId, id)) {
|
|
2263
|
-
throw new
|
|
2296
|
+
throw new NotFoundError(`dormant memory not found: ${id}`);
|
|
2264
2297
|
}
|
|
2265
2298
|
try {
|
|
2266
2299
|
appendAuditEvent(db, {
|
|
@@ -2271,8 +2304,9 @@ export function forgetDormant(ctx, id) {
|
|
|
2271
2304
|
metadata: { dormant: true },
|
|
2272
2305
|
});
|
|
2273
2306
|
}
|
|
2274
|
-
catch {
|
|
2307
|
+
catch (error) {
|
|
2275
2308
|
// Best-effort, like every other forget audit row: the delete stands.
|
|
2309
|
+
reportAuditWriteFailure('forget', String(error), id);
|
|
2276
2310
|
}
|
|
2277
2311
|
}
|
|
2278
2312
|
finally {
|
|
@@ -2318,9 +2352,9 @@ export function quarantineList(ctx, opts = {}) {
|
|
|
2318
2352
|
function loadPendingQuarantineRow(db, tenantId, id) {
|
|
2319
2353
|
const row = getQuarantineRow(db, tenantId, id);
|
|
2320
2354
|
if (!row)
|
|
2321
|
-
throw new
|
|
2355
|
+
throw new NotFoundError(`not quarantined: ${id}`);
|
|
2322
2356
|
if (row.status !== 'pending')
|
|
2323
|
-
throw new
|
|
2357
|
+
throw new ConflictError(`${id} is already ${row.status}`);
|
|
2324
2358
|
return row;
|
|
2325
2359
|
}
|
|
2326
2360
|
/** Release a quarantined memory to its original scope. Admin only; the scope guard refuses a row moved since (mirrors restoreDormant). */
|
|
@@ -2338,7 +2372,7 @@ export function quarantineApprove(ctx, id) {
|
|
|
2338
2372
|
.prepare(`UPDATE memories SET scope = ? WHERE id = ? AND tenant_id = ? AND scope = ?`)
|
|
2339
2373
|
.run(row.originalScope, id, ctx.tenantId, quarantineScope);
|
|
2340
2374
|
if (Number(updated.changes ?? 0) !== 1) {
|
|
2341
|
-
throw new
|
|
2375
|
+
throw new ConflictError(`memory ${id} scope changed since quarantine; refusing to approve`);
|
|
2342
2376
|
}
|
|
2343
2377
|
approveQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
|
|
2344
2378
|
appendAuditEvent(db, {
|
|
@@ -2655,16 +2689,8 @@ export async function sleep(ctx, opts = {}) {
|
|
|
2655
2689
|
}
|
|
2656
2690
|
}
|
|
2657
2691
|
catch (auditErr) {
|
|
2658
|
-
//
|
|
2659
|
-
|
|
2660
|
-
// This guards the case where consolidation AND audit-emit fail in the
|
|
2661
|
-
// same invocation against the same DB (correlated: same disk, same
|
|
2662
|
-
// schema state) — losing the original error makes diagnosis much harder.
|
|
2663
|
-
// SAFETY: this is a best-effort log message only; property access on
|
|
2664
|
-
// any JS value is safe (undefined if absent), preserving the existing
|
|
2665
|
-
// lenient formatting even when something non-Error was thrown.
|
|
2666
|
-
// eslint-disable-next-line no-console
|
|
2667
|
-
console.error(`[hippo] api.sleep audit emit failed: ${auditErr.message}`);
|
|
2692
|
+
// Logged, never thrown: a second failure must not mask the original phaseError.
|
|
2693
|
+
reportAuditWriteFailure('consolidate', String(auditErr));
|
|
2668
2694
|
}
|
|
2669
2695
|
}
|
|
2670
2696
|
}
|
package/dist/audit.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export type AuditQueryFields = {
|
|
|
33
33
|
};
|
|
34
34
|
export declare function auditQueryFields(query: string): AuditQueryFields;
|
|
35
35
|
export declare function appendAuditEvent(db: DatabaseSyncLike, opts: AppendAuditOpts): void;
|
|
36
|
+
/** For callers that keep a mutation when its audit row fails: the failure is logged and counted, never silent. */
|
|
37
|
+
export declare function reportAuditWriteFailure(op: AuditOp, reason: string, targetId?: string | null): void;
|
|
38
|
+
/** Audit rows this process failed to write; the loopback `/health` body reports it. */
|
|
39
|
+
export declare function auditWriteFailureCount(): number;
|
|
36
40
|
export interface QueryAuditOpts {
|
|
37
41
|
tenantId: string;
|
|
38
42
|
op?: AuditOp;
|
package/dist/audit.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { canAutoDelete } from './memory.js';
|
|
3
|
+
import { log } from './log.js';
|
|
3
4
|
export const STOP_WORDS = new Set([
|
|
4
5
|
'the', 'a', 'an', 'is', 'was', 'are', 'were', 'be', 'been', 'being',
|
|
5
6
|
'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'it',
|
|
@@ -250,6 +251,16 @@ export function auditQueryFields(query) {
|
|
|
250
251
|
export function appendAuditEvent(db, opts) {
|
|
251
252
|
db.prepare(`INSERT INTO audit_log (ts, tenant_id, actor, op, target_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?)`).run(new Date().toISOString(), opts.tenantId, opts.actor, opts.op, opts.targetId ?? null, JSON.stringify(opts.metadata ?? {}, bigintSafeReplacer));
|
|
252
253
|
}
|
|
254
|
+
let auditWriteFailures = 0;
|
|
255
|
+
/** For callers that keep a mutation when its audit row fails: the failure is logged and counted, never silent. */
|
|
256
|
+
export function reportAuditWriteFailure(op, reason, targetId) {
|
|
257
|
+
auditWriteFailures++;
|
|
258
|
+
log.error(`audit write failed: ${reason}`, { op, target: targetId ?? undefined });
|
|
259
|
+
}
|
|
260
|
+
/** Audit rows this process failed to write; the loopback `/health` body reports it. */
|
|
261
|
+
export function auditWriteFailureCount() {
|
|
262
|
+
return auditWriteFailures;
|
|
263
|
+
}
|
|
253
264
|
export function queryAuditEvents(db, opts) {
|
|
254
265
|
const where = ['tenant_id = ?'];
|
|
255
266
|
const params = [opts.tenantId];
|
package/dist/autolearn.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export declare function extractLessons(gitLog: string, customPatterns?: string[]
|
|
|
20
20
|
* steps: this function is the write-path gate, called by each caller that
|
|
21
21
|
* actually stores a lesson, not by the parser itself.
|
|
22
22
|
*
|
|
23
|
-
* Order is preserved in both output arrays.
|
|
23
|
+
* Order is preserved in both output arrays; both hold lessons with secret shapes redacted.
|
|
24
24
|
*/
|
|
25
25
|
export declare function partitionLessons(lessons: string[]): {
|
|
26
26
|
kept: string[];
|
package/dist/autolearn.js
CHANGED
|
@@ -7,14 +7,16 @@ import { createMemory, Layer, DEFAULT_HALF_LIFE_DAYS } from './memory.js';
|
|
|
7
7
|
import { loadAllEntries } from './store.js';
|
|
8
8
|
import { textOverlap } from './search.js';
|
|
9
9
|
import { isContentWorthStoring } from './audit.js';
|
|
10
|
+
import { redactSecretsStrict } from './secret-detect.js';
|
|
10
11
|
/** A memory of a failed command, "Command '<cmd>' failed: <truncated stderr>"; no store is in reach, so `hippo watch` re-derives its half-life from the store's config. */
|
|
11
12
|
export function captureError(exitCode, stderr, command, tenantId) {
|
|
12
13
|
// Truncate to first 500 chars to avoid storing megabytes of build logs
|
|
13
|
-
const
|
|
14
|
-
const
|
|
14
|
+
const clean = redactSecretsStrict(stderr);
|
|
15
|
+
const wasTruncated = clean.length > 500;
|
|
16
|
+
const truncated = clean.slice(0, 500).trim();
|
|
15
17
|
const suffix = wasTruncated ? ' [truncated]' : '';
|
|
16
18
|
// Strip leading env var assignments (KEY=val or key=val) before the actual command name
|
|
17
|
-
const safeCmd = command.replace(/^([A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim() || '(redacted)';
|
|
19
|
+
const safeCmd = redactSecretsStrict(command.replace(/^([A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim()) || '(redacted)';
|
|
18
20
|
const content = `Command '${safeCmd}' failed (exit ${exitCode}): ${truncated}${suffix}`;
|
|
19
21
|
// Derive a sanitized tag from the command name (first word, strip path)
|
|
20
22
|
const cmdBase = safeCmd.split(/\s+/)[0].replace(/[^a-zA-Z0-9-]/g, '');
|
|
@@ -78,12 +80,12 @@ export function extractLessons(gitLog, customPatterns) {
|
|
|
78
80
|
* steps: this function is the write-path gate, called by each caller that
|
|
79
81
|
* actually stores a lesson, not by the parser itself.
|
|
80
82
|
*
|
|
81
|
-
* Order is preserved in both output arrays.
|
|
83
|
+
* Order is preserved in both output arrays; both hold lessons with secret shapes redacted.
|
|
82
84
|
*/
|
|
83
85
|
export function partitionLessons(lessons) {
|
|
84
86
|
const kept = [];
|
|
85
87
|
const dropped = [];
|
|
86
|
-
for (const lesson of lessons) {
|
|
88
|
+
for (const lesson of lessons.map((l) => redactSecretsStrict(l))) {
|
|
87
89
|
if (isContentWorthStoring(lesson)) {
|
|
88
90
|
kept.push(lesson);
|
|
89
91
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** A host lifecycle moment hippo can capture from. */
|
|
2
|
+
export type CaptureEvent = 'prompt' | 'tool-failure' | 'pre-compact' | 'post-compact' | 'session-start' | 'session-end' | 'reset';
|
|
3
|
+
/** What hippo read from one host payload, before anything is written. */
|
|
4
|
+
export interface CaptureInput {
|
|
5
|
+
readonly runtime: string;
|
|
6
|
+
readonly event: CaptureEvent;
|
|
7
|
+
/** True when no host payload arrived: a person ran the verb by hand. */
|
|
8
|
+
readonly manual: boolean;
|
|
9
|
+
readonly sessionId: string | null;
|
|
10
|
+
readonly cwd: string | null;
|
|
11
|
+
readonly transcriptPath: string | null;
|
|
12
|
+
/** The host's own reason for the event, such as `auto` or `manual` compaction. */
|
|
13
|
+
readonly trigger: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** The outcome of reading a payload: usable input, a payload hippo refuses, or no payload at all. */
|
|
16
|
+
export type CaptureReceipt = {
|
|
17
|
+
readonly status: 'received';
|
|
18
|
+
readonly input: CaptureInput;
|
|
19
|
+
} | {
|
|
20
|
+
readonly status: 'skipped' | 'unavailable';
|
|
21
|
+
readonly reason: string;
|
|
22
|
+
};
|
|
23
|
+
/** Working state saved before the host drops context, so the session can resume; it is not a lesson. */
|
|
24
|
+
export interface Checkpoint {
|
|
25
|
+
readonly runtime: string;
|
|
26
|
+
readonly sessionId: string | null;
|
|
27
|
+
readonly task: string;
|
|
28
|
+
readonly summary: string;
|
|
29
|
+
readonly nextStep: string;
|
|
30
|
+
readonly savedAt: string;
|
|
31
|
+
}
|
|
32
|
+
/** How far capture has read one session's source, so a retry resumes instead of re-reading or skipping. */
|
|
33
|
+
export interface ProgressCursor {
|
|
34
|
+
readonly runtime: string;
|
|
35
|
+
readonly sessionId: string;
|
|
36
|
+
readonly source: string;
|
|
37
|
+
/** Opaque position in the source, such as a byte offset or the last turn id read. */
|
|
38
|
+
readonly position: string;
|
|
39
|
+
readonly updatedAt: string;
|
|
40
|
+
}
|
|
41
|
+
/** A string guard that avoids `typeof`; it matches it for every value `JSON.parse` can produce. */
|
|
42
|
+
export declare function isStringValue<T>(value: T): value is T & string;
|
|
43
|
+
/** An object guard that avoids `typeof`; arrays count as objects, as they do for `typeof`. */
|
|
44
|
+
export declare function isObjectLike<T>(value: T): value is T & object;
|
|
45
|
+
/** Reads a Claude Code PreCompact payload; only an empty stdin counts as a manual run. */
|
|
46
|
+
export declare function readClaudeCodePreCompact(stdinText: string | undefined, timedOut: boolean): CaptureReceipt;
|
|
47
|
+
//# sourceMappingURL=capture-contract.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The shared shape every agent adapter turns a host payload into, so capture code is written once for all runtimes.
|
|
2
|
+
// Field meanings, statuses and how to add a runtime: docs/integrations/agent-inventory.md.
|
|
3
|
+
/** A string guard that avoids `typeof`; it matches it for every value `JSON.parse` can produce. */
|
|
4
|
+
export function isStringValue(value) {
|
|
5
|
+
return String(value) === value;
|
|
6
|
+
}
|
|
7
|
+
/** An object guard that avoids `typeof`; arrays count as objects, as they do for `typeof`. */
|
|
8
|
+
export function isObjectLike(value) {
|
|
9
|
+
return value !== null && value instanceof Object;
|
|
10
|
+
}
|
|
11
|
+
/** Reads a Claude Code PreCompact payload; only an empty stdin counts as a manual run. */
|
|
12
|
+
export function readClaudeCodePreCompact(stdinText, timedOut) {
|
|
13
|
+
const empty = !stdinText || stdinText.trim() === '';
|
|
14
|
+
if (timedOut && empty) {
|
|
15
|
+
return { status: 'unavailable', reason: 'no PreCompact payload arrived before the stdin wait window closed' };
|
|
16
|
+
}
|
|
17
|
+
const base = { runtime: 'claude-code', event: 'pre-compact' };
|
|
18
|
+
if (empty) {
|
|
19
|
+
return { status: 'received', input: { ...base, manual: true, sessionId: null, cwd: null, transcriptPath: null, trigger: null } };
|
|
20
|
+
}
|
|
21
|
+
let payload;
|
|
22
|
+
try {
|
|
23
|
+
payload = JSON.parse(stdinText.trim());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Non-JSON stdin leaves payload undefined, which the shape check rejects like an explicit null.
|
|
27
|
+
}
|
|
28
|
+
// A bad payload is skipped, never treated as manual: discovery could pick up another session's transcript.
|
|
29
|
+
if (!isObjectLike(payload) || !('transcript_path' in payload) || !isStringValue(payload.transcript_path)) {
|
|
30
|
+
return { status: 'skipped', reason: 'malformed or incomplete PreCompact payload (missing string transcript_path)' };
|
|
31
|
+
}
|
|
32
|
+
const transcriptPath = payload.transcript_path;
|
|
33
|
+
// Path shape only: CLAUDE_CONFIG_DIR can move the transcript root, and a same-user process can already read every transcript.
|
|
34
|
+
if (!/\.jsonl$/i.test(transcriptPath)) {
|
|
35
|
+
return { status: 'skipped', reason: `payload transcript_path is not a .jsonl file: ${transcriptPath}` };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
status: 'received',
|
|
39
|
+
input: {
|
|
40
|
+
...base,
|
|
41
|
+
manual: false,
|
|
42
|
+
sessionId: 'session_id' in payload && isStringValue(payload.session_id) ? payload.session_id : null,
|
|
43
|
+
cwd: 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null,
|
|
44
|
+
transcriptPath,
|
|
45
|
+
trigger: 'trigger' in payload && isStringValue(payload.trigger) ? payload.trigger : null,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=capture-contract.js.map
|