hippo-memory 1.63.2 → 1.64.1
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 -5
- package/dist/agent-memories/claude-code.js +3 -3
- package/dist/ambient-store.d.ts +2 -0
- package/dist/ambient-store.js +4 -3
- package/dist/api/assemble.d.ts +2 -0
- package/dist/api/assemble.js +11 -7
- package/dist/api/auth.d.ts +15 -11
- package/dist/api/auth.js +116 -22
- package/dist/api/context-select.d.ts +5 -3
- package/dist/api/context-select.js +6 -11
- package/dist/api/context-types.d.ts +2 -1
- package/dist/api/context.d.ts +1 -1
- package/dist/api/context.js +79 -55
- package/dist/api/dormant.d.ts +5 -5
- package/dist/api/dormant.js +13 -9
- package/dist/api/drill-down.d.ts +2 -0
- package/dist/api/drill-down.js +12 -7
- package/dist/api/forget.d.ts +1 -1
- package/dist/api/forget.js +8 -4
- package/dist/api/learn.js +5 -3
- package/dist/api/outcome.d.ts +1 -1
- package/dist/api/outcome.js +2 -1
- package/dist/api/promote.js +8 -5
- package/dist/api/recall-types.d.ts +4 -2
- package/dist/api/recall.js +51 -36
- package/dist/api/remember.d.ts +7 -0
- package/dist/api/remember.js +26 -2
- package/dist/api/sleep-run.d.ts +28 -0
- package/dist/api/sleep-run.js +267 -0
- package/dist/api/sleep.d.ts +5 -45
- package/dist/api/sleep.js +5 -252
- package/dist/api/types.d.ts +5 -0
- package/dist/api/types.js +4 -0
- package/dist/audit-prune.js +1 -1
- package/dist/audit.d.ts +1 -3
- package/dist/audit.js +7 -103
- package/dist/auth.d.ts +48 -4
- package/dist/auth.js +111 -42
- package/dist/autolearn.js +2 -2
- package/dist/capture/caller-session.d.ts +16 -0
- package/dist/capture/caller-session.js +58 -0
- package/dist/capture/command.d.ts +19 -1
- package/dist/capture/command.js +28 -21
- package/dist/capture/compact-resume-caller.d.ts +11 -0
- package/dist/capture/compact-resume-caller.js +35 -0
- package/dist/capture/compact.d.ts +0 -14
- package/dist/capture/compact.js +7 -160
- package/dist/capture/compaction-items-caller.d.ts +16 -0
- package/dist/capture/compaction-items-caller.js +32 -0
- package/dist/capture/extract.d.ts +2 -14
- package/dist/capture/extract.js +135 -346
- package/dist/capture/failure-caller.d.ts +21 -0
- package/dist/capture/failure-caller.js +74 -0
- package/dist/capture/failure-reading.d.ts +44 -0
- package/dist/capture/failure-reading.js +78 -0
- package/dist/capture/pre-compact-caller.d.ts +16 -0
- package/dist/capture/pre-compact-caller.js +39 -0
- package/dist/capture/session-end-caller.d.ts +21 -0
- package/dist/capture/session-end-caller.js +30 -0
- package/dist/capture/session-texts.d.ts +14 -0
- package/dist/capture/session-texts.js +42 -0
- package/dist/capture/transcript.d.ts +6 -1
- package/dist/capture/transcript.js +20 -16
- package/dist/capture/working-state.d.ts +21 -0
- package/dist/capture/working-state.js +154 -0
- package/dist/capture-error.d.ts +8 -22
- package/dist/capture-error.js +9 -61
- package/dist/cli/audit.js +25 -0
- package/dist/cli/auth.js +4 -3
- package/dist/cli/context.js +13 -158
- package/dist/cli/continuity.js +2 -1
- package/dist/cli/init.js +5 -3
- package/dist/cli/last-sleep.d.ts +6 -0
- package/dist/cli/last-sleep.js +68 -0
- package/dist/cli/maintenance.d.ts +2 -1
- package/dist/cli/maintenance.js +17 -11
- package/dist/cli/quality-repair-once.d.ts +3 -0
- package/dist/cli/quality-repair-once.js +36 -0
- package/dist/cli/recall.js +2 -1
- package/dist/cli/remember.d.ts +2 -0
- package/dist/cli/remember.js +4 -1
- package/dist/cli/session-hooks.d.ts +0 -3
- package/dist/cli/session-hooks.js +12 -49
- package/dist/cli/setup.js +10 -2
- package/dist/cli/shared.d.ts +7 -14
- package/dist/cli/shared.js +19 -78
- package/dist/cli/sleep.js +6 -3
- package/dist/cli/usage.js +18 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -2
- package/dist/client.js +1 -1
- package/dist/compaction-record.d.ts +19 -1
- package/dist/compaction-record.js +61 -132
- package/dist/compaction-spool.d.ts +45 -0
- package/dist/compaction-spool.js +454 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +49 -0
- package/dist/connectors/github/deletion.d.ts +1 -1
- package/dist/connectors/github/deletion.js +5 -17
- package/dist/consolidate/conflicts.js +4 -0
- package/dist/consolidate/flush-units.d.ts +9 -0
- package/dist/consolidate/flush-units.js +63 -0
- package/dist/consolidate/llm-passes.js +5 -4
- package/dist/consolidate/merge.js +5 -1
- package/dist/consolidate/run.d.ts +2 -1
- package/dist/consolidate/sleep.d.ts +2 -0
- package/dist/consolidate/sleep.js +82 -15
- package/dist/context-render.d.ts +14 -0
- package/dist/context-render.js +28 -0
- package/dist/dag.js +6 -0
- package/dist/dashboard-actions.js +3 -2
- package/dist/dashboard.js +4 -3
- package/dist/db/backup.d.ts +6 -0
- package/dist/db/backup.js +29 -0
- package/dist/db/busy.d.ts +9 -0
- package/dist/db/busy.js +35 -0
- package/dist/db/continuity.d.ts +1 -0
- package/dist/db/continuity.js +31 -4
- package/dist/db/meta.d.ts +2 -0
- package/dist/db/meta.js +7 -0
- package/dist/db/migrate.js +2 -3
- package/dist/db/migrations/index.d.ts +1 -1
- package/dist/db/migrations/index.js +5 -1
- package/dist/db/migrations/v39.js +2 -7
- package/dist/db/migrations/v53.d.ts +3 -0
- package/dist/db/migrations/v53.js +16 -0
- package/dist/db/migrations/v54.d.ts +3 -0
- package/dist/db/migrations/v54.js +25 -0
- package/dist/db/open.d.ts +11 -0
- package/dist/db/open.js +27 -0
- package/dist/db/tables.d.ts +2 -0
- package/dist/db/tables.js +6 -2
- package/dist/db.d.ts +2 -2
- package/dist/db.js +2 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +31 -15
- package/dist/dormant.d.ts +16 -20
- package/dist/dormant.js +24 -30
- package/dist/embedding-provider.js +4 -3
- package/dist/entry/json-hooks.d.ts +3 -0
- package/dist/entry/json-hooks.js +4 -0
- package/dist/entry/project-identity.d.ts +2 -0
- package/dist/entry/project-identity.js +3 -0
- package/dist/entry/session-text.d.ts +7 -0
- package/dist/entry/session-text.js +8 -0
- package/dist/extract.js +6 -0
- package/dist/failure-log.d.ts +10 -2
- package/dist/failure-log.js +19 -2
- package/dist/gated-write.js +1 -1
- package/dist/graph/delta.d.ts +44 -0
- package/dist/graph/delta.js +109 -0
- package/dist/graph/types.d.ts +5 -0
- package/dist/graph/write.d.ts +20 -20
- package/dist/graph/write.js +103 -36
- package/dist/graph-extract.d.ts +43 -9
- package/dist/graph-extract.js +140 -157
- package/dist/graph-recall.d.ts +1 -0
- package/dist/graph-recall.js +2 -2
- package/dist/graph-view.d.ts +1 -0
- package/dist/graph-view.js +25 -2
- package/dist/half-life-migration.js +5 -1
- package/dist/handoff-evidence.d.ts +5 -0
- package/dist/handoff-evidence.js +30 -0
- package/dist/home-path.d.ts +5 -0
- package/dist/home-path.js +24 -0
- package/dist/hooks/json-hooks.d.ts +13 -2
- package/dist/hooks/json-hooks.js +246 -117
- package/dist/hooks/opencode.js +1 -1
- package/dist/hooks/shared.d.ts +2 -0
- package/dist/hooks/shared.js +6 -1
- package/dist/http-util.d.ts +16 -7
- package/dist/http-util.js +65 -20
- package/dist/importers/vault.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +7 -0
- package/dist/ledger-db.d.ts +10 -0
- package/dist/ledger-db.js +42 -0
- package/dist/local-embedding.d.ts +2 -0
- package/dist/local-embedding.js +20 -4
- package/dist/mcp/admin-tools.d.ts +2 -2
- package/dist/mcp/admin-tools.js +47 -5
- package/dist/mcp/memory-tools.js +7 -2
- package/dist/mcp/protocol.d.ts +6 -0
- package/dist/mcp/protocol.js +4 -2
- package/dist/mcp/recall-tools.js +19 -6
- package/dist/mcp/request.js +4 -0
- package/dist/mcp/session-state.d.ts +4 -0
- package/dist/mcp/session-state.js +15 -6
- package/dist/mcp/shared-gate.d.ts +4 -0
- package/dist/mcp/shared-gate.js +22 -0
- package/dist/mcp/tools.js +1 -0
- package/dist/memory-quality.d.ts +35 -0
- package/dist/memory-quality.js +191 -0
- package/dist/memory.d.ts +4 -4
- package/dist/physics-state.js +1 -1
- package/dist/pilot-arm.d.ts +14 -3
- package/dist/pilot-arm.js +32 -10
- package/dist/postinstall.js +2 -2
- package/dist/project-briefs.js +4 -4
- package/dist/project-identity.d.ts +14 -10
- package/dist/project-identity.js +29 -11
- package/dist/project-merge.d.ts +0 -2
- package/dist/project-merge.js +9 -13
- package/dist/prompt-hook.d.ts +65 -0
- package/dist/prompt-hook.js +213 -0
- package/dist/prompt-recall.js +1 -1
- package/dist/quality-repair.d.ts +27 -0
- package/dist/quality-repair.js +220 -0
- package/dist/rate-limit.d.ts +1 -0
- package/dist/rate-limit.js +5 -3
- package/dist/raw-archive.d.ts +1 -1
- package/dist/raw-archive.js +6 -16
- package/dist/recall-scope.d.ts +28 -5
- package/dist/recall-scope.js +52 -7
- package/dist/recall-trace.js +1 -1
- package/dist/reject-flow.d.ts +2 -0
- package/dist/reject-flow.js +20 -6
- package/dist/secret-detect.js +7 -8
- package/dist/server/auth.d.ts +8 -6
- package/dist/server/auth.js +85 -38
- package/dist/server/client-ip.d.ts +2 -0
- package/dist/server/client-ip.js +16 -1
- package/dist/server/mcp-http.d.ts +9 -3
- package/dist/server/mcp-http.js +56 -13
- package/dist/server/request.d.ts +1 -1
- package/dist/server/request.js +5 -5
- package/dist/server/routes/admin.js +12 -6
- package/dist/server/routes/memories.js +9 -1
- package/dist/server/routes/recall.js +14 -2
- package/dist/server/types.d.ts +42 -5
- package/dist/server/validation.d.ts +5 -2
- package/dist/server/validation.js +19 -2
- package/dist/server.d.ts +21 -4
- package/dist/server.js +207 -49
- package/dist/session-digest.d.ts +0 -1
- package/dist/session-digest.js +3 -12
- package/dist/session-owners.d.ts +7 -0
- package/dist/session-owners.js +54 -0
- package/dist/share-scrub.d.ts +3 -0
- package/dist/share-scrub.js +8 -0
- package/dist/shared.d.ts +1 -0
- package/dist/shared.js +26 -18
- package/dist/store/candidates.d.ts +14 -3
- package/dist/store/candidates.js +48 -26
- package/dist/store/conflicts.d.ts +15 -0
- package/dist/store/conflicts.js +68 -44
- package/dist/store/delete-and-batch.d.ts +25 -1
- package/dist/store/delete-and-batch.js +121 -69
- package/dist/store/entry-reads.d.ts +5 -6
- package/dist/store/entry-reads.js +24 -12
- package/dist/store/entry-row.d.ts +7 -10
- package/dist/store/entry-row.js +40 -18
- package/dist/store/entry-writes.d.ts +3 -3
- package/dist/store/entry-writes.js +9 -23
- package/dist/store/handoffs.d.ts +9 -6
- package/dist/store/handoffs.js +72 -59
- package/dist/store/index-and-stats.js +2 -2
- package/dist/store/open.d.ts +3 -1
- package/dist/store/open.js +3 -3
- package/dist/store/search-rows.d.ts +8 -5
- package/dist/store/search-rows.js +15 -18
- package/dist/store/sessions.d.ts +25 -6
- package/dist/store/sessions.js +61 -19
- package/dist/store/summaries.d.ts +1 -1
- package/dist/store/summaries.js +3 -18
- package/dist/store/tenant-lookup.d.ts +7 -3
- package/dist/store/tenant-lookup.js +9 -9
- package/dist/store-port.d.ts +13 -0
- package/dist/store-port.js +20 -0
- package/dist/version.d.ts +5 -1
- package/dist/version.js +5 -1
- package/dist/working-memory.js +1 -1
- package/dist/write-budget.d.ts +15 -0
- package/dist/write-budget.js +15 -0
- 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 +8 -4
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { raiseMinBinary } from './db/meta.js';
|
|
3
3
|
import { keysetAfter } from './keyset.js';
|
|
4
|
+
import { EXPIRING_KEYS_MIN_BINARY } from './version.js';
|
|
4
5
|
/** Every minted API key starts with this, so the server can route a bearer token by shape. */
|
|
5
6
|
export const API_KEY_PREFIX = 'hk_';
|
|
6
7
|
const ID_LEN = 24; // base32 chars after prefix
|
|
@@ -42,30 +43,64 @@ export function createApiKey(db, opts) {
|
|
|
42
43
|
const hash = hashKey(plaintext);
|
|
43
44
|
// openHippoDb runs runMigrations synchronously before returning the db handle,
|
|
44
45
|
// so migration v26 (adds role column) is in place before this INSERT runs.
|
|
45
|
-
|
|
46
|
+
// An older binary ignores expires_at and would honour an expired key, so the store shuts it out before the first one exists.
|
|
47
|
+
if (opts.expiresAt !== undefined)
|
|
48
|
+
raiseMinBinary(db, EXPIRING_KEYS_MIN_BINARY);
|
|
49
|
+
db.prepare(`INSERT INTO api_keys (key_id, key_hash, tenant_id, label, created_at, role, owner_subject, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(keyId, hash, opts.tenantId, opts.label ?? null, new Date().toISOString(), opts.role ?? 'admin', opts.ownerSubject ?? null, opts.expiresAt ?? null);
|
|
46
50
|
return { keyId, plaintext };
|
|
47
51
|
}
|
|
48
|
-
/**
|
|
49
|
-
function
|
|
50
|
-
if (!MINTED_KEY_PATTERN.test(plaintext))
|
|
51
|
-
return null;
|
|
52
|
-
const keyId = plaintext.slice(0, API_KEY_PREFIX.length + ID_LEN);
|
|
52
|
+
/** The api_keys row for `keyId` with its scope grants; null when no row matches. */
|
|
53
|
+
export function readApiKeyRecord(db, keyId) {
|
|
53
54
|
// SAFETY: row comes from the SELECT above, which projects exactly
|
|
54
|
-
// key_hash, tenant_id, revoked_at, role; `.get` returns undefined when no
|
|
55
|
+
// key_hash, tenant_id, revoked_at, role, expires_at, owner_subject; `.get` returns undefined when no
|
|
55
56
|
// row matches key_id.
|
|
56
57
|
const row = db
|
|
57
|
-
.prepare(`SELECT key_hash, tenant_id, revoked_at, role FROM api_keys WHERE key_id = ?`)
|
|
58
|
+
.prepare(`SELECT key_hash, tenant_id, revoked_at, role, expires_at, owner_subject FROM api_keys WHERE key_id = ?`)
|
|
58
59
|
.get(keyId);
|
|
60
|
+
if (!row)
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
keyHash: row.key_hash, tenantId: row.tenant_id, revokedAt: row.revoked_at, role: row.role, scopes: listScopeGrants(db, keyId),
|
|
64
|
+
expiresAt: row.expires_at, ownerSubject: row.owner_subject,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** When a key stops working, in epoch ms: Infinity for null, and already past for a missing field (a store that predates expiry) or a stamp that does not parse, so both fail closed. */
|
|
68
|
+
function keyExpiryMs(expiresAt) {
|
|
69
|
+
if (expiresAt === null)
|
|
70
|
+
return Infinity;
|
|
71
|
+
if (expiresAt === undefined)
|
|
72
|
+
return -Infinity;
|
|
73
|
+
const ms = Date.parse(expiresAt);
|
|
74
|
+
return Number.isNaN(ms) ? -Infinity : ms;
|
|
75
|
+
}
|
|
76
|
+
/** The key id of a minted-key-shaped token, else null. */
|
|
77
|
+
function mintedKeyId(plaintext) {
|
|
78
|
+
return MINTED_KEY_PATTERN.test(plaintext) ? plaintext.slice(0, API_KEY_PREFIX.length + ID_LEN) : null;
|
|
79
|
+
}
|
|
80
|
+
/** Revocation, expiry, then the secret, against a stored record. Null for any failure. */
|
|
81
|
+
function checkApiKey(keyId, record, now, secretMatches) {
|
|
59
82
|
// Ids are 120 random bits and not secret, so padding the miss path with scrypt hid nothing and let junk tokens burn CPU.
|
|
60
|
-
if (!
|
|
83
|
+
if (!record)
|
|
84
|
+
return null;
|
|
85
|
+
const expiresAtMs = keyExpiryMs(record.expiresAt);
|
|
86
|
+
if (record.revokedAt || now >= expiresAtMs || !secretMatches(record.keyHash))
|
|
61
87
|
return null;
|
|
62
88
|
// Fail-safe to least privilege: any role value but 'admin' reads as 'member'.
|
|
63
|
-
const role =
|
|
64
|
-
|
|
89
|
+
const role = record.role === 'admin' ? 'admin' : 'member';
|
|
90
|
+
const key = { tenantId: record.tenantId, keyId, role, scopes: [...record.scopes] };
|
|
91
|
+
// Only a real name counts as an owner; anything else leaves the key keyed on its own id.
|
|
92
|
+
if (record.ownerSubject)
|
|
93
|
+
key.ownerSubject = record.ownerSubject;
|
|
94
|
+
return { key, expiresAtMs, keyHash: record.keyHash };
|
|
95
|
+
}
|
|
96
|
+
/** One full check against the store: shape, row, revocation, expiry, then scrypt. Null for any failure. */
|
|
97
|
+
function lookupApiKey(db, plaintext, now) {
|
|
98
|
+
const keyId = mintedKeyId(plaintext);
|
|
99
|
+
return keyId === null ? null : checkApiKey(keyId, readApiKeyRecord(db, keyId), now, (hash) => verifyKey(plaintext, hash));
|
|
65
100
|
}
|
|
66
101
|
export function validateApiKey(db, plaintext) {
|
|
67
|
-
const
|
|
68
|
-
return
|
|
102
|
+
const found = lookupApiKey(db, plaintext, Date.now());
|
|
103
|
+
return found ? { valid: true, ...found.key } : { valid: false };
|
|
69
104
|
}
|
|
70
105
|
/** How long a verified key is trusted without a store read; also the ceiling on a revoke made by another process. */
|
|
71
106
|
export const VERIFIED_KEY_TTL_MS = 60_000;
|
|
@@ -79,6 +114,7 @@ export class VerifiedKeyCache {
|
|
|
79
114
|
ttlMs;
|
|
80
115
|
// Map iterates in insertion order, so re-inserting on a hit makes the first key the least recent.
|
|
81
116
|
entries = new Map();
|
|
117
|
+
deletes = 0;
|
|
82
118
|
constructor(capacity, ttlMs) {
|
|
83
119
|
this.capacity = capacity;
|
|
84
120
|
this.ttlMs = ttlMs;
|
|
@@ -86,58 +122,70 @@ export class VerifiedKeyCache {
|
|
|
86
122
|
get size() {
|
|
87
123
|
return this.entries.size;
|
|
88
124
|
}
|
|
89
|
-
|
|
125
|
+
/** Moves on every delete, so a store read that straddled a revoke or grant is not cached. */
|
|
126
|
+
get epoch() {
|
|
127
|
+
return this.deletes;
|
|
128
|
+
}
|
|
129
|
+
/** The entry this exact token was verified into, fresh or lapsed. */
|
|
130
|
+
match(hippoRoot, keyId, plaintext) {
|
|
90
131
|
const entry = this.entries.get(keyId);
|
|
91
132
|
if (!entry || entry.hippoRoot !== hippoRoot)
|
|
92
133
|
return undefined;
|
|
93
|
-
if (now >= entry.expiresAt) {
|
|
94
|
-
this.entries.delete(keyId);
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
134
|
// A wrong secret misses but leaves the entry, so a flood of bad guesses cannot evict a good key.
|
|
98
|
-
|
|
135
|
+
return timingSafeEqual(entry.digest, secretDigest(plaintext)) ? entry : undefined;
|
|
136
|
+
}
|
|
137
|
+
get(hippoRoot, keyId, plaintext, now) {
|
|
138
|
+
const entry = this.match(hippoRoot, keyId, plaintext);
|
|
139
|
+
if (!entry || now >= entry.expiresAt)
|
|
99
140
|
return undefined;
|
|
100
141
|
this.entries.delete(keyId);
|
|
101
142
|
this.entries.set(keyId, entry);
|
|
102
143
|
return { ...entry.key, scopes: [...entry.key.scopes] };
|
|
103
144
|
}
|
|
104
|
-
|
|
145
|
+
/** The key hash this exact token was last verified against, kept past the TTL so a re-check can skip scrypt. */
|
|
146
|
+
verifiedHash(hippoRoot, keyId, plaintext) {
|
|
147
|
+
return this.match(hippoRoot, keyId, plaintext)?.keyHash;
|
|
148
|
+
}
|
|
149
|
+
set(hippoRoot, keyId, plaintext, found, now) {
|
|
105
150
|
this.entries.delete(keyId);
|
|
106
151
|
if (this.entries.size >= this.capacity) {
|
|
107
152
|
const oldest = this.entries.keys().next();
|
|
108
153
|
if (!oldest.done)
|
|
109
154
|
this.entries.delete(oldest.value);
|
|
110
155
|
}
|
|
111
|
-
const frozen = Object.freeze({ ...key, scopes: [...key.scopes] });
|
|
112
|
-
|
|
156
|
+
const frozen = Object.freeze({ ...found.key, scopes: [...found.key.scopes] });
|
|
157
|
+
// A key that expires inside the TTL leaves the cache at its expiry, so the cache never extends its life.
|
|
158
|
+
const expiresAt = Math.min(now + this.ttlMs, found.expiresAtMs);
|
|
159
|
+
this.entries.set(keyId, { hippoRoot, digest: secretDigest(plaintext), key: frozen, keyHash: found.keyHash, expiresAt });
|
|
113
160
|
}
|
|
114
161
|
delete(keyId) {
|
|
162
|
+
this.deletes++;
|
|
115
163
|
this.entries.delete(keyId);
|
|
116
164
|
}
|
|
117
165
|
}
|
|
118
166
|
// SHORTCUT: per-process cache, so a revoke or scope change made by another process (the CLI) lands within VERIFIED_KEY_TTL_MS; a shared revocation epoch in the store if that is too slow.
|
|
119
167
|
const verifiedKeys = new VerifiedKeyCache(VERIFIED_KEY_CACHE_CAP, VERIFIED_KEY_TTL_MS);
|
|
120
|
-
/** Verify a bearer API key
|
|
121
|
-
export function verifyApiKeyCached(hippoRoot, plaintext) {
|
|
122
|
-
|
|
168
|
+
/** Verify a bearer API key against `store`, served at `hippoRoot`; a cache hit skips both scrypt and the store. Null when invalid. A token whose cache entry lapsed re-reads its record without scrypt; a throw from `beforeScrypt` refuses before scrypt runs. */
|
|
169
|
+
export async function verifyApiKeyCached(hippoRoot, plaintext, store, beforeScrypt) {
|
|
170
|
+
const keyId = mintedKeyId(plaintext);
|
|
171
|
+
if (keyId === null)
|
|
123
172
|
return null;
|
|
124
|
-
const keyId = plaintext.slice(0, API_KEY_PREFIX.length + ID_LEN);
|
|
125
173
|
const hit = verifiedKeys.get(hippoRoot, keyId, plaintext, Date.now());
|
|
126
174
|
if (hit)
|
|
127
175
|
return hit;
|
|
176
|
+
const provenHash = verifiedKeys.verifiedHash(hippoRoot, keyId, plaintext);
|
|
128
177
|
verifyStats.storeLookups++;
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
178
|
+
const epoch = verifiedKeys.epoch;
|
|
179
|
+
const found = checkApiKey(keyId, await store.findApiKey(keyId), Date.now(), (hash) => {
|
|
180
|
+
if (hash === provenHash)
|
|
181
|
+
return true;
|
|
182
|
+
beforeScrypt?.();
|
|
183
|
+
return verifyKey(plaintext, hash);
|
|
184
|
+
});
|
|
137
185
|
// Only successes are cached: caching misses would let junk tokens fill the cache and evict real keys.
|
|
138
|
-
if (
|
|
139
|
-
verifiedKeys.set(hippoRoot, keyId, plaintext,
|
|
140
|
-
return key;
|
|
186
|
+
if (found && verifiedKeys.epoch === epoch)
|
|
187
|
+
verifiedKeys.set(hippoRoot, keyId, plaintext, found, Date.now());
|
|
188
|
+
return found?.key ?? null;
|
|
141
189
|
}
|
|
142
190
|
export function revokeApiKey(db, keyId) {
|
|
143
191
|
db.prepare(`UPDATE api_keys SET revoked_at = ? WHERE key_id = ? AND revoked_at IS NULL`)
|
|
@@ -167,19 +215,30 @@ export function listScopeGrants(db, keyId) {
|
|
|
167
215
|
export function listApiKeyRows(db, opts) {
|
|
168
216
|
const where = ['1 = 1'];
|
|
169
217
|
const params = [];
|
|
170
|
-
if (opts.active)
|
|
171
|
-
|
|
218
|
+
if (opts.active) {
|
|
219
|
+
// In SQL so a page holds `limit` usable keys; toISOString stamps compare correctly as strings.
|
|
220
|
+
where.push('revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)');
|
|
221
|
+
params.push(new Date().toISOString());
|
|
222
|
+
}
|
|
172
223
|
if (opts.tenantId !== undefined) {
|
|
173
224
|
where.push('tenant_id = ?');
|
|
174
225
|
params.push(opts.tenantId);
|
|
175
226
|
}
|
|
227
|
+
if (opts.ownerSubject !== undefined) {
|
|
228
|
+
where.push('owner_subject = ?');
|
|
229
|
+
params.push(opts.ownerSubject);
|
|
230
|
+
}
|
|
231
|
+
if (opts.keyId !== undefined) {
|
|
232
|
+
where.push('key_id = ?');
|
|
233
|
+
params.push(opts.keyId);
|
|
234
|
+
}
|
|
176
235
|
const after = keysetAfter('id', 'id', opts.after);
|
|
177
236
|
params.push(...after.params);
|
|
178
237
|
const limitSql = opts.limit === undefined ? '' : ' LIMIT ?';
|
|
179
238
|
if (opts.limit !== undefined)
|
|
180
239
|
params.push(opts.limit);
|
|
181
|
-
const sql = `SELECT id, key_id, tenant_id, label, created_at, revoked_at, role FROM api_keys WHERE ${where.join(' AND ')}${after.sql} ORDER BY id DESC${limitSql}`;
|
|
182
|
-
// SAFETY: the SELECT above projects exactly these
|
|
240
|
+
const sql = `SELECT id, key_id, tenant_id, label, created_at, revoked_at, role, expires_at, owner_subject FROM api_keys WHERE ${where.join(' AND ')}${after.sql} ORDER BY id DESC${limitSql}`;
|
|
241
|
+
// SAFETY: the SELECT above projects exactly these 9 columns, in this order.
|
|
183
242
|
const rows = db.prepare(sql).all(...params);
|
|
184
243
|
return rows.map(r => ({
|
|
185
244
|
rowId: r.id,
|
|
@@ -188,10 +247,20 @@ export function listApiKeyRows(db, opts) {
|
|
|
188
247
|
createdAt: r.created_at, revokedAt: r.revoked_at,
|
|
189
248
|
role: r.role === 'admin' ? 'admin' : 'member',
|
|
190
249
|
scopes: listScopeGrants(db, r.key_id),
|
|
250
|
+
expiresAt: r.expires_at, ownerSubject: r.owner_subject,
|
|
191
251
|
},
|
|
192
252
|
}));
|
|
193
253
|
}
|
|
194
254
|
export function listApiKeys(db, opts) {
|
|
195
255
|
return listApiKeyRows(db, opts).map(r => r.key);
|
|
196
256
|
}
|
|
257
|
+
/** Ids of the unrevoked, unexpired keys `ownerSubject` minted in `tenantId`, oldest first. */
|
|
258
|
+
export function listLiveOwnedKeyIds(db, tenantId, ownerSubject, now) {
|
|
259
|
+
// SAFETY: the SELECT names exactly these two columns.
|
|
260
|
+
const rows = db
|
|
261
|
+
.prepare(`SELECT key_id, expires_at FROM api_keys WHERE tenant_id = ? AND owner_subject = ? AND revoked_at IS NULL ORDER BY id`)
|
|
262
|
+
.all(tenantId, ownerSubject);
|
|
263
|
+
// Expiry is filtered here, not in SQL, so it follows the same Date.parse rule the key check uses.
|
|
264
|
+
return rows.filter((r) => now < keyExpiryMs(r.expires_at)).map((r) => r.key_id);
|
|
265
|
+
}
|
|
197
266
|
//# sourceMappingURL=auth.js.map
|
package/dist/autolearn.js
CHANGED
|
@@ -6,7 +6,7 @@ import { execSync, execFileSync, spawn } from 'child_process';
|
|
|
6
6
|
import { createMemory, Layer, DEFAULT_HALF_LIFE_DAYS } from './memory.js';
|
|
7
7
|
import { loadAllEntries } from './store/entry-reads.js';
|
|
8
8
|
import { textOverlap } from './tokenize.js';
|
|
9
|
-
import {
|
|
9
|
+
import { assessAutomaticMemory } from './memory-quality.js';
|
|
10
10
|
import { redactSecretsStrict } from './secret-detect.js';
|
|
11
11
|
import { log } from './log.js';
|
|
12
12
|
/** 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. */
|
|
@@ -87,7 +87,7 @@ export function partitionLessons(lessons) {
|
|
|
87
87
|
const kept = [];
|
|
88
88
|
const dropped = [];
|
|
89
89
|
for (const lesson of lessons.map((l) => redactSecretsStrict(l))) {
|
|
90
|
-
if (
|
|
90
|
+
if (assessAutomaticMemory(lesson).accepted) {
|
|
91
91
|
kept.push(lesson);
|
|
92
92
|
}
|
|
93
93
|
else {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Context } from '../api/types.js';
|
|
2
|
+
import { type DatabaseSyncLike } from '../db.js';
|
|
3
|
+
import { type CallerProject } from '../prompt-hook.js';
|
|
4
|
+
import type { ContinuityKey } from '../store/sessions.js';
|
|
5
|
+
import { type WorkingState } from './working-state.js';
|
|
6
|
+
/** Checks the ids, then binds the session to the caller's owner (a ConflictError when another owner holds it). Returns the key the call's rows go under. */
|
|
7
|
+
export declare function bindCaller(ctx: Context, sessionId: string, project: CallerProject): ContinuityKey;
|
|
8
|
+
/** Read only: the session's first prompt booked its arm, and a holdout session gets nothing from hippo. */
|
|
9
|
+
export declare function callerInHoldout(ctx: Context, sessionId: string): boolean;
|
|
10
|
+
/** Refuses a field past the cap a transcript read gives, naming it; scrubbed again, since the caller's scrub is not trusted, then cut back to the cap. */
|
|
11
|
+
export declare function checkedWorkingState(state: WorkingState | null): WorkingState | null;
|
|
12
|
+
export declare function assertTrigger(trigger: string | null): void;
|
|
13
|
+
export declare function assertRequestId(requestId: string): void;
|
|
14
|
+
/** Opens with the request's own lock wait, so a busy store becomes a 503 rather than a long stall. */
|
|
15
|
+
export declare function withCallerDb<T>(ctx: Context, fn: (db: DatabaseSyncLike) => T): T;
|
|
16
|
+
//# sourceMappingURL=caller-session.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The checks every caller call shares: ids, project, the session's owner and the pilot arm, all from the request, never the server's env or folder.
|
|
2
|
+
import { BadRequestError } from '../api-errors.js';
|
|
3
|
+
import { ownerOrSubject } from '../api/types.js';
|
|
4
|
+
import { closeHippoDb, openHippoDb } from '../db.js';
|
|
5
|
+
import { MAX_ID_LEN } from '../http-util.js';
|
|
6
|
+
import { sessionPilotArm } from '../pilot-arm.js';
|
|
7
|
+
import { assertCallerIds } from '../prompt-hook.js';
|
|
8
|
+
import { assertCallerProject, projectNames } from '../project-identity.js';
|
|
9
|
+
import { maskEmails, redactSecretsStrict } from '../secret-detect.js';
|
|
10
|
+
import { bindSessionOwner } from '../session-owners.js';
|
|
11
|
+
import { fitWorkingState, WORKING_STATE_CAPS } from './working-state.js';
|
|
12
|
+
/** Checks the ids, then binds the session to the caller's owner (a ConflictError when another owner holds it). Returns the key the call's rows go under. */
|
|
13
|
+
export function bindCaller(ctx, sessionId, project) {
|
|
14
|
+
assertCallerIds(sessionId, project);
|
|
15
|
+
if (sessionId.trim() === '')
|
|
16
|
+
throw new BadRequestError('session id: required');
|
|
17
|
+
// A '' project would stamp user-global rows; a rewritten one would split a project.
|
|
18
|
+
assertCallerProject(project);
|
|
19
|
+
bindSessionOwner(ctx, sessionId);
|
|
20
|
+
return { owner: ownerOrSubject(ctx.actor), project: projectNames(project) };
|
|
21
|
+
}
|
|
22
|
+
/** Read only: the session's first prompt booked its arm, and a holdout session gets nothing from hippo. */
|
|
23
|
+
export function callerInHoldout(ctx, sessionId) {
|
|
24
|
+
return sessionPilotArm(ctx.hippoRoot, ctx.tenantId, sessionId, false, { sharedStore: true, ownTenantOnly: true }) === 'holdout';
|
|
25
|
+
}
|
|
26
|
+
/** Refuses a field past the cap a transcript read gives, naming it; scrubbed again, since the caller's scrub is not trusted, then cut back to the cap. */
|
|
27
|
+
export function checkedWorkingState(state) {
|
|
28
|
+
if (state === null)
|
|
29
|
+
return null;
|
|
30
|
+
for (const field of ['task', 'summary', 'next_step']) {
|
|
31
|
+
const cap = WORKING_STATE_CAPS[field];
|
|
32
|
+
if (state[field].length > cap)
|
|
33
|
+
throw new BadRequestError(`working state ${field}: at most ${cap} characters`);
|
|
34
|
+
}
|
|
35
|
+
const scrub = (text) => maskEmails(redactSecretsStrict(text));
|
|
36
|
+
return fitWorkingState({ task: scrub(state.task), summary: scrub(state.summary), next_step: scrub(state.next_step) });
|
|
37
|
+
}
|
|
38
|
+
export function assertTrigger(trigger) {
|
|
39
|
+
if (trigger !== null && trigger.length > MAX_ID_LEN)
|
|
40
|
+
throw new BadRequestError(`trigger: at most ${MAX_ID_LEN} characters`);
|
|
41
|
+
}
|
|
42
|
+
export function assertRequestId(requestId) {
|
|
43
|
+
if (requestId.trim() === '')
|
|
44
|
+
throw new BadRequestError('request id: required');
|
|
45
|
+
if (requestId.length > MAX_ID_LEN)
|
|
46
|
+
throw new BadRequestError(`request id: at most ${MAX_ID_LEN} characters`);
|
|
47
|
+
}
|
|
48
|
+
/** Opens with the request's own lock wait, so a busy store becomes a 503 rather than a long stall. */
|
|
49
|
+
export function withCallerDb(ctx, fn) {
|
|
50
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
51
|
+
try {
|
|
52
|
+
return fn(db);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
closeHippoDb(db);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=caller-session.js.map
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { type ProjectRef } from '../project-identity.js';
|
|
2
|
+
import { type ExtractedItem } from './extract.js';
|
|
2
3
|
import { type SessionTurn } from './transcript.js';
|
|
3
4
|
export interface CaptureOptions {
|
|
4
5
|
source: 'stdin' | 'file' | 'last-session';
|
|
5
6
|
filePath?: string;
|
|
6
7
|
/** Explicit transcript path for `--last-session`. Without one, `stdinText`
|
|
7
|
-
* is used, then auto-discovery under
|
|
8
|
+
* is used, then auto-discovery under `<claude config dir>/projects/` on a manual run. */
|
|
8
9
|
transcriptPath?: string;
|
|
9
10
|
/** Read from stdin by the caller (cli.ts), which owns the bounded wait.
|
|
10
11
|
* `stdinTimedOut` marks an empty read "unknown", not "no payload". */
|
|
@@ -31,4 +32,21 @@ export interface CaptureOptions {
|
|
|
31
32
|
originProject?: ProjectRef;
|
|
32
33
|
}
|
|
33
34
|
export declare function cmdCapture(hippoRoot: string, options: CaptureOptions): void;
|
|
35
|
+
export interface CaptureTally {
|
|
36
|
+
captured: number;
|
|
37
|
+
skipped: number;
|
|
38
|
+
rejected: number;
|
|
39
|
+
}
|
|
40
|
+
/** What a capture's rows carry. The CLI passes no tenant for the global store, so `global` never reaches the writes. */
|
|
41
|
+
export interface CaptureWriteOptions {
|
|
42
|
+
readonly dryRun: boolean;
|
|
43
|
+
readonly tenantId: string | undefined;
|
|
44
|
+
readonly originProject: ProjectRef | undefined;
|
|
45
|
+
readonly sessionId?: string;
|
|
46
|
+
/** The audit actor; the CLI's own writes leave it out and audit as `cli`. */
|
|
47
|
+
readonly actor?: string;
|
|
48
|
+
/** No stats and no embedding, since embedding runs a model in a server process. */
|
|
49
|
+
readonly lean: boolean;
|
|
50
|
+
}
|
|
51
|
+
export declare function captureExtractedItems(targetRoot: string, options: CaptureWriteOptions, extracted: readonly ExtractedItem[], keys: Set<string>): CaptureTally;
|
|
34
52
|
//# sourceMappingURL=command.d.ts.map
|
package/dist/capture/command.js
CHANGED
|
@@ -17,8 +17,8 @@ import { loadConfig } from '../config.js';
|
|
|
17
17
|
import { classifyOriginProject, projectId } from '../project-identity.js';
|
|
18
18
|
import { isStringValue } from '../capture-contract.js';
|
|
19
19
|
import { errorMessage, log } from '../log.js';
|
|
20
|
-
import {
|
|
21
|
-
import { collectSessionTurns,
|
|
20
|
+
import { extractFromTexts } from './extract.js';
|
|
21
|
+
import { collectSessionTurns, sessionTail, resolveLastSessionTranscript } from './transcript.js';
|
|
22
22
|
export function cmdCapture(hippoRoot, options) {
|
|
23
23
|
// Tee stdout/stderr to a log file when --log-file is set. Used by the
|
|
24
24
|
// SessionEnd hook so output (otherwise swallowed by TUI teardown) surfaces
|
|
@@ -102,16 +102,15 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
102
102
|
process.exit(1);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (text === null)
|
|
105
|
+
const texts = readCaptureTexts(options);
|
|
106
|
+
if (texts === null)
|
|
108
107
|
return;
|
|
109
|
-
if (
|
|
108
|
+
if (texts.every((text) => text.trim().length === 0)) {
|
|
110
109
|
console.log('No text to capture from.');
|
|
111
110
|
return;
|
|
112
111
|
}
|
|
113
112
|
// Scrub once here, as the snapshot fields are: every source can carry a pasted token (AGENTS.md: no secrets in memories).
|
|
114
|
-
const extracted =
|
|
113
|
+
const extracted = extractFromTexts(texts.map((text) => maskEmails(redactSecretsStrict(text))));
|
|
115
114
|
if (extracted.length === 0) {
|
|
116
115
|
console.log('No actionable items found in the input.');
|
|
117
116
|
return;
|
|
@@ -123,19 +122,25 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
123
122
|
const keys = storedTextKeys(origin === undefined
|
|
124
123
|
? stored
|
|
125
124
|
: stored.filter((e) => classifyOriginProject(e.origin_project, origin) !== 'cross-project'));
|
|
126
|
-
const
|
|
125
|
+
const writeOpts = {
|
|
126
|
+
dryRun: options.dryRun,
|
|
127
|
+
tenantId: useGlobal ? undefined : options.tenantId,
|
|
128
|
+
originProject: origin,
|
|
129
|
+
lean: false,
|
|
130
|
+
};
|
|
131
|
+
const { captured, skipped, rejected } = captureExtractedItems(targetRoot, writeOpts, extracted, keys);
|
|
127
132
|
const prefix = options.dryRun ? '[dry-run] ' : '';
|
|
128
133
|
const globalPrefix = useGlobal ? '[global] ' : '';
|
|
129
134
|
console.log(`\n${prefix}${globalPrefix}Captured ${captured} items (${skipped} skipped as duplicates` +
|
|
130
135
|
(rejected > 0 ? `, ${rejected} rejected` : '') +
|
|
131
136
|
')');
|
|
132
137
|
}
|
|
133
|
-
/** The raw
|
|
134
|
-
function
|
|
138
|
+
/** The raw texts for the chosen source, one per session turn; null after printing why a last-session capture has nothing. */
|
|
139
|
+
function readCaptureTexts(options) {
|
|
135
140
|
switch (options.source) {
|
|
136
141
|
case 'stdin': {
|
|
137
142
|
try {
|
|
138
|
-
return fs.readFileSync(0, 'utf8');
|
|
143
|
+
return [fs.readFileSync(0, 'utf8')];
|
|
139
144
|
}
|
|
140
145
|
catch {
|
|
141
146
|
console.error('No input on stdin. Pipe text in or use --file <path>.');
|
|
@@ -151,7 +156,7 @@ function readCaptureText(options) {
|
|
|
151
156
|
console.error(`File not found: ${options.filePath}`);
|
|
152
157
|
process.exit(1);
|
|
153
158
|
}
|
|
154
|
-
return fs.readFileSync(options.filePath, 'utf8');
|
|
159
|
+
return [fs.readFileSync(options.filePath, 'utf8')];
|
|
155
160
|
}
|
|
156
161
|
case 'last-session': {
|
|
157
162
|
let turns = options.sessionTurns;
|
|
@@ -163,16 +168,16 @@ function readCaptureText(options) {
|
|
|
163
168
|
}
|
|
164
169
|
turns = collectSessionTurns(fs.readFileSync(resolved, 'utf8'));
|
|
165
170
|
}
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
171
|
+
const { users, assistants } = sessionTail(turns);
|
|
172
|
+
if (users.length === 0 && assistants.length === 0) {
|
|
168
173
|
console.log('Transcript had no user/assistant messages to summarise.');
|
|
169
174
|
return null;
|
|
170
175
|
}
|
|
171
|
-
return
|
|
176
|
+
return [...users, ...assistants];
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
|
-
function captureExtractedItems(targetRoot, options, extracted, keys) {
|
|
180
|
+
export function captureExtractedItems(targetRoot, options, extracted, keys) {
|
|
176
181
|
const tally = { captured: 0, skipped: 0, rejected: 0 };
|
|
177
182
|
const baseHalfLifeDays = loadConfig(targetRoot).defaultHalfLifeDays;
|
|
178
183
|
// Dry run probes the same checkRejectionGuard the real write uses, on a read-only handle,
|
|
@@ -201,7 +206,6 @@ function captureExtractedItems(targetRoot, options, extracted, keys) {
|
|
|
201
206
|
return tally;
|
|
202
207
|
}
|
|
203
208
|
function captureEntry(item, options, baseHalfLifeDays) {
|
|
204
|
-
const useGlobal = options.global;
|
|
205
209
|
// kind stays 'distilled': these are curated items, not raw transcript (see MEMORY_ENVELOPE.md).
|
|
206
210
|
// The write tenant must match the dedup read's, or scoped dedup passes and the row lands in 'default'.
|
|
207
211
|
const created = createMemory(item.content, {
|
|
@@ -209,7 +213,8 @@ function captureEntry(item, options, baseHalfLifeDays) {
|
|
|
209
213
|
tags: item.tags,
|
|
210
214
|
source: 'capture',
|
|
211
215
|
confidence: 'observed',
|
|
212
|
-
tenantId:
|
|
216
|
+
tenantId: options.tenantId,
|
|
217
|
+
source_session_id: options.sessionId,
|
|
213
218
|
baseHalfLifeDays,
|
|
214
219
|
});
|
|
215
220
|
return options.originProject === undefined ? created : { ...created, origin_project: projectId(options.originProject) };
|
|
@@ -235,15 +240,17 @@ function captureOne(ctx, item, entry) {
|
|
|
235
240
|
else if (writeDb !== null) {
|
|
236
241
|
// One rejected item must not abort the rest of this capture's items.
|
|
237
242
|
const stamped = stampOriginProject(targetRoot, entry);
|
|
238
|
-
const outcome = gatedWrite(writeDb, targetRoot, stamped);
|
|
243
|
+
const outcome = gatedWrite(writeDb, targetRoot, stamped, { actor: options.actor });
|
|
239
244
|
if (outcome === 'skipped:rejected')
|
|
240
245
|
return 'rejected';
|
|
241
246
|
if (outcome !== 'written')
|
|
242
247
|
return 'skipped';
|
|
243
248
|
writeEntryMirrors(targetRoot, stamped);
|
|
244
|
-
|
|
249
|
+
if (!options.lean)
|
|
250
|
+
updateStats(targetRoot, { remembered: 1 });
|
|
245
251
|
keys.add(duplicateKey(item.content)); // within-batch dedup
|
|
246
|
-
|
|
252
|
+
if (!options.lean)
|
|
253
|
+
void embedMemory(targetRoot, entry);
|
|
247
254
|
}
|
|
248
255
|
return 'captured';
|
|
249
256
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Context } from '../api/types.js';
|
|
2
|
+
import type { CallerProject } from '../prompt-hook.js';
|
|
3
|
+
import type { CallerHookOutput } from './pre-compact-caller.js';
|
|
4
|
+
export interface CallerCompactResumeRequest {
|
|
5
|
+
readonly sessionId: string;
|
|
6
|
+
readonly project: CallerProject;
|
|
7
|
+
/** The SessionStart payload's source; only 'compact' restores. */
|
|
8
|
+
readonly source: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function compactResumeForCaller(ctx: Context, req: CallerCompactResumeRequest): CallerHookOutput;
|
|
11
|
+
//# sourceMappingURL=compact-resume-caller.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { COMPACT_RESUME_EVENT_CONTENT_CAP, COMPACT_RESUME_MAX_AGE_MS, compactResumeText } from '../context-render.js';
|
|
2
|
+
import { withLedgerDb } from '../ledger-db.js';
|
|
3
|
+
import { errorMessage, log } from '../log.js';
|
|
4
|
+
import { listSessionEvents, loadFreshActiveTaskSnapshot } from '../store/sessions.js';
|
|
5
|
+
import { estimateTokens, recordTokenUse } from '../token-ledger.js';
|
|
6
|
+
import { truncateCodePointSafe } from '../transcript-tail.js';
|
|
7
|
+
import { bindCaller, callerInHoldout } from './caller-session.js';
|
|
8
|
+
export function compactResumeForCaller(ctx, req) {
|
|
9
|
+
const key = bindCaller(ctx, req.sessionId, req.project);
|
|
10
|
+
if (req.source !== 'compact' || callerInHoldout(ctx, req.sessionId))
|
|
11
|
+
return { stdout: '' };
|
|
12
|
+
const snapshot = loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, { maxAgeMs: COMPACT_RESUME_MAX_AGE_MS }, key);
|
|
13
|
+
// One owner's concurrent sessions must not cross-restore; a snapshot with no session still restores, as on the local path.
|
|
14
|
+
if (snapshot === null || (snapshot.session_id !== null && snapshot.session_id !== req.sessionId))
|
|
15
|
+
return { stdout: '' };
|
|
16
|
+
const text = compactResumeText(snapshot, sessionTrail(ctx, snapshot.session_id));
|
|
17
|
+
withLedgerDb(ctx.hippoRoot, (db) => recordTokenUse(db, {
|
|
18
|
+
tenantId: ctx.tenantId, sessionId: req.sessionId, surface: 'compact_resume', event: 'inject', items: 1, tokens: estimateTokens(text),
|
|
19
|
+
}), { sharedStore: true });
|
|
20
|
+
return { stdout: text };
|
|
21
|
+
}
|
|
22
|
+
/** A bad trail row costs the trail, not the snapshot. */
|
|
23
|
+
function sessionTrail(ctx, sessionId) {
|
|
24
|
+
if (!sessionId)
|
|
25
|
+
return [];
|
|
26
|
+
try {
|
|
27
|
+
return listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId })
|
|
28
|
+
.map((e) => ({ ...e, content: truncateCodePointSafe(e.content, COMPACT_RESUME_EVENT_CONTENT_CAP) }));
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
log.warn(`compact-resume: trail skipped: ${errorMessage(err)}`);
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=compact-resume-caller.js.map
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
import { type TaskSnapshot } from '../store/rows.js';
|
|
2
|
-
export declare const PRE_COMPACT_TASK_CAP = 200;
|
|
3
|
-
export declare const PRE_COMPACT_SUMMARY_CAP = 2000;
|
|
4
|
-
export declare const PRE_COMPACT_NEXT_STEP_CAP = 500;
|
|
5
|
-
/**
|
|
6
|
-
* Cap from the RECENT end: summariseTranscript emits user turns oldest-to-
|
|
7
|
-
* newest with assistant responses after them, so a head-first cap keeps
|
|
8
|
-
* stale context and drops exactly the newest working state this feature
|
|
9
|
-
* exists to preserve. Keep the LAST maxChars instead, aligned forward to a
|
|
10
|
-
* nearby line start, with a trim marker.
|
|
11
|
-
*/
|
|
12
|
-
export declare function truncateKeepNewest(text: string, maxChars: number): string;
|
|
13
1
|
/**
|
|
14
2
|
* Log-forgery guard: messages here interpolate payload-controlled values
|
|
15
3
|
* (transcript paths, session ids). Strip C0 control chars — newlines above
|
|
@@ -19,8 +7,6 @@ export declare function truncateKeepNewest(text: string, maxChars: number): stri
|
|
|
19
7
|
* guard, not per-file copies.
|
|
20
8
|
*/
|
|
21
9
|
export declare function sanitizeLogMessage(message: string): string;
|
|
22
|
-
/** A session's task, summary and next step from its transcript tail, secrets scrubbed and capped, '' where none; null with a logged reason when nothing is derivable. */
|
|
23
|
-
export declare function transcriptWorkingState(transcriptPath: string, log: (message: string) => void): Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null;
|
|
24
10
|
export interface PreCompactOptions {
|
|
25
11
|
stdinText?: string;
|
|
26
12
|
stdinTimedOut?: boolean;
|