hippo-memory 1.67.0 → 1.69.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 +1 -1
- package/dist/agent-memories/copilot.d.ts +3 -1
- package/dist/agent-memories/copilot.js +2 -2
- package/dist/api/auth.d.ts +14 -18
- package/dist/api/auth.js +93 -60
- package/dist/api/context-select.d.ts +10 -0
- package/dist/api/context-select.js +16 -18
- package/dist/api/context.js +101 -60
- package/dist/api/forget.d.ts +2 -2
- package/dist/api/forget.js +11 -0
- package/dist/api/outcome.d.ts +4 -4
- package/dist/api/outcome.js +29 -6
- package/dist/api/promote.d.ts +3 -3
- package/dist/api/promote.js +45 -90
- package/dist/api/remember.d.ts +3 -2
- package/dist/api/remember.js +38 -11
- package/dist/api/types.d.ts +9 -0
- package/dist/auth.d.ts +19 -0
- package/dist/auth.js +14 -6
- package/dist/capture/compact.js +21 -2
- package/dist/capture/copilot-transcript.d.ts +3 -0
- package/dist/capture/copilot-transcript.js +8 -1
- package/dist/capture/session-worker.d.ts +13 -0
- package/dist/capture/session-worker.js +194 -0
- package/dist/capture-contract.d.ts +3 -1
- package/dist/capture-contract.js +36 -0
- package/dist/cli/session-hooks.js +90 -22
- package/dist/cli/setup.js +70 -24
- package/dist/cli/usage.js +4 -1
- package/dist/cli.js +1 -1
- package/dist/connectors/github/ingest.js +2 -1
- package/dist/connectors/slack/deletion.js +2 -1
- package/dist/connectors/slack/ingest.js +2 -1
- package/dist/embeddings.d.ts +10 -10
- package/dist/embeddings.js +103 -18
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -0
- package/dist/hooks/copilot.d.ts +50 -8
- package/dist/hooks/copilot.js +116 -30
- package/dist/hooks/json-hooks.d.ts +2 -0
- package/dist/hooks/json-hooks.js +11 -2
- package/dist/hooks/shared.d.ts +7 -1
- package/dist/hooks/shared.js +18 -3
- package/dist/mcp/memory-tools.d.ts +2 -2
- package/dist/mcp/memory-tools.js +9 -8
- package/dist/mcp/request.js +3 -3
- package/dist/memory.d.ts +2 -0
- package/dist/memory.js +7 -0
- package/dist/prompt-recall.d.ts +5 -0
- package/dist/prompt-recall.js +13 -0
- package/dist/raw-archive.d.ts +2 -2
- package/dist/raw-archive.js +5 -3
- package/dist/recall-scope.d.ts +2 -5
- package/dist/recall-scope.js +5 -6
- package/dist/server/routes/admin.js +2 -2
- package/dist/server/routes/memories.js +9 -6
- package/dist/server-detect.js +3 -2
- package/dist/server.d.ts +20 -4
- package/dist/server.js +23 -11
- package/dist/store/audit-event.d.ts +1 -1
- package/dist/store/audit-event.js +1 -1
- package/dist/store/entry-writes-group.d.ts +9 -0
- package/dist/store/entry-writes-group.js +157 -0
- package/dist/store/handoffs.d.ts +5 -2
- package/dist/store/handoffs.js +35 -5
- package/dist/store/key-writes.d.ts +9 -0
- package/dist/store/key-writes.js +30 -0
- package/dist/store/open.d.ts +2 -0
- package/dist/store/open.js +10 -0
- package/dist/store/search-rows.js +3 -9
- package/dist/store/sessions.d.ts +5 -0
- package/dist/store/sessions.js +4 -1
- package/dist/store/vector-writes.d.ts +6 -0
- package/dist/store/vector-writes.js +59 -0
- package/dist/store-port.d.ts +143 -1
- package/dist/store-port.js +48 -12
- 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/api/promote.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// Promote to the global store, supersede with a successor, and archive raw memories.
|
|
2
|
-
import { openHippoDb, closeHippoDb
|
|
2
|
+
import { openHippoDb, closeHippoDb } from '../db.js';
|
|
3
3
|
import { ConflictError, NotFoundError } from '../api-errors.js';
|
|
4
|
-
import { auditRejectionRefusal } from '../store/audit-event.js';
|
|
5
4
|
import { stampOriginProject } from '../store/entry-row.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { writeEntryMirrors } from '../store/entry-writes.js';
|
|
6
|
+
import { cleanArchivedMirrors, commitSupersede } from '../store/entry-writes-group.js';
|
|
8
7
|
import { readEntry } from '../store/entry-reads.js';
|
|
9
8
|
import { updateStatsUnlessBusy } from '../store/index-and-stats.js';
|
|
10
|
-
import {
|
|
11
|
-
import { RejectedValueError } from '../rejection.js';
|
|
9
|
+
import { requireGroup } from '../store-port.js';
|
|
12
10
|
import { log } from '../log.js';
|
|
13
11
|
import { createSuccessor } from '../memory.js';
|
|
14
12
|
import { appendAuditEvent } from '../audit.js';
|
|
@@ -16,7 +14,7 @@ import { promoteToGlobal } from '../shared.js';
|
|
|
16
14
|
import { archiveRawMemory } from '../raw-archive.js';
|
|
17
15
|
import { loadConfig } from '../config.js';
|
|
18
16
|
import { selectMemoryReach } from '../store/tenant-lookup.js';
|
|
19
|
-
import { canTouchScope } from '../recall-scope.js';
|
|
17
|
+
import { canTouchScope, personalScopeOf } from '../recall-scope.js';
|
|
20
18
|
export function promote(ctx, id) {
|
|
21
19
|
// Tenant scope: promoteToGlobal reads the entry from the local root via
|
|
22
20
|
// readEntry without a tenant filter, so a Bearer for tenant A could
|
|
@@ -49,11 +47,28 @@ export function promote(ctx, id) {
|
|
|
49
47
|
return { ok: true, sourceId: id, globalId: globalEntry.id };
|
|
50
48
|
}
|
|
51
49
|
export function supersede(ctx, oldId, newContent) {
|
|
52
|
-
const
|
|
53
|
-
|
|
50
|
+
const reply = ctx.store ? supersedeThroughStore(ctx, ctx.store, oldId, newContent) : supersedeOnHippoDb(ctx, oldId, newContent);
|
|
51
|
+
// SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
|
|
52
|
+
return reply;
|
|
53
|
+
}
|
|
54
|
+
async function supersedeThroughStore(ctx, store, oldId, newContent) {
|
|
55
|
+
const entryWrites = requireGroup(store, 'entryWrites');
|
|
56
|
+
const [old] = await store.entriesByIds([oldId], ctx.tenantId);
|
|
57
|
+
const newEntry = createSuccessor(assertSupersedable(ctx, oldId, old ?? null), newContent, {
|
|
54
58
|
tenantId: ctx.tenantId,
|
|
55
59
|
baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
|
|
56
60
|
});
|
|
61
|
+
// The store writes origin_project as given, so the served folder's fallback is stamped here.
|
|
62
|
+
const successor = stampOriginProject(ctx.hippoRoot, newEntry);
|
|
63
|
+
await entryWrites.supersede({ tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), oldId, successor });
|
|
64
|
+
return { ok: true, oldId, newId: successor.id };
|
|
65
|
+
}
|
|
66
|
+
function supersedeOnHippoDb(ctx, oldId, newContent) {
|
|
67
|
+
const old = assertSupersedable(ctx, oldId, readEntry(ctx.hippoRoot, oldId, ctx.tenantId));
|
|
68
|
+
const successor = stampOriginProject(ctx.hippoRoot, createSuccessor(old, newContent, {
|
|
69
|
+
tenantId: ctx.tenantId,
|
|
70
|
+
baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
|
|
71
|
+
}));
|
|
57
72
|
// Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
|
|
58
73
|
// three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
|
|
59
74
|
// inside the same transaction. Two concurrent supersedes: exactly one CAS
|
|
@@ -62,14 +77,14 @@ export function supersede(ctx, oldId, newContent) {
|
|
|
62
77
|
// the old.superseded_by pointer.
|
|
63
78
|
const db = openHippoDb(ctx.hippoRoot);
|
|
64
79
|
try {
|
|
65
|
-
commitSupersede(db, ctx,
|
|
80
|
+
commitSupersede(db, { tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), oldId, successor });
|
|
66
81
|
// Mirrors after COMMIT, while the db handle is still open. Same
|
|
67
82
|
// invariant as the original writeEntry: a mirror failure leaves disk
|
|
68
83
|
// MISSING the markdown for the new memory (rebuildIndex rewrites every
|
|
69
84
|
// markdown mirror from the DB) but DOES NOT desync the DB or
|
|
70
85
|
// roll back the supersede. Logged + swallowed, non-fatal.
|
|
71
86
|
try {
|
|
72
|
-
writeEntryMirrors(ctx.hippoRoot,
|
|
87
|
+
writeEntryMirrors(ctx.hippoRoot, successor);
|
|
73
88
|
}
|
|
74
89
|
catch (mirrorErr) {
|
|
75
90
|
log.error(`supersede: mirror write failed (non-fatal, will self-heal): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
|
|
@@ -78,11 +93,10 @@ export function supersede(ctx, oldId, newContent) {
|
|
|
78
93
|
finally {
|
|
79
94
|
closeHippoDb(db);
|
|
80
95
|
}
|
|
81
|
-
return { ok: true, oldId, newId:
|
|
96
|
+
return { ok: true, oldId, newId: successor.id };
|
|
82
97
|
}
|
|
83
98
|
/** The tenant-scoped row to supersede; another tenant's id, or someone else's personal row, reads as not found. */
|
|
84
|
-
function
|
|
85
|
-
const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
|
|
99
|
+
function assertSupersedable(ctx, oldId, old) {
|
|
86
100
|
if (!old || !canTouchScope(ctx.actor, old.scope ?? null)) {
|
|
87
101
|
throw new NotFoundError(`Memory not found: ${oldId}`);
|
|
88
102
|
}
|
|
@@ -92,50 +106,22 @@ function readSupersedable(ctx, oldId) {
|
|
|
92
106
|
}
|
|
93
107
|
return old;
|
|
94
108
|
}
|
|
95
|
-
/** CAS on the old row, the successor's insert and the 'supersede' audit row, in one BEGIN IMMEDIATE transaction. */
|
|
96
|
-
function commitSupersede(db, ctx, oldId, old, newEntry) {
|
|
97
|
-
db.exec('BEGIN IMMEDIATE');
|
|
98
|
-
try {
|
|
99
|
-
// The tenant filter repeats readEntry's check at no cost, closing an ownership change between read and update.
|
|
100
|
-
const result = db.prepare(`
|
|
101
|
-
UPDATE memories
|
|
102
|
-
SET superseded_by = ?
|
|
103
|
-
WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
|
|
104
|
-
`).run(newEntry.id, oldId, ctx.tenantId);
|
|
105
|
-
if ((result.changes ?? 0) === 0) {
|
|
106
|
-
db.exec('ROLLBACK');
|
|
107
|
-
throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
|
|
108
|
-
}
|
|
109
|
-
// After the CAS guard, so a lost race throws before the old parent is marked for rebuild.
|
|
110
|
-
if (old.dag_parent_id) {
|
|
111
|
-
markSummaryDirtyInTx(db, old.dag_parent_id, ctx.tenantId, ctx.actor.subject);
|
|
112
|
-
}
|
|
113
|
-
// Emits its own 'remember' audit row inside the same transaction.
|
|
114
|
-
writeEntryDbOnly(db, stampOriginProject(ctx.hippoRoot, newEntry), { actor: ctx.actor.subject });
|
|
115
|
-
appendAuditEvent(db, {
|
|
116
|
-
tenantId: ctx.tenantId,
|
|
117
|
-
actor: ctx.actor.subject,
|
|
118
|
-
op: 'supersede',
|
|
119
|
-
targetId: oldId,
|
|
120
|
-
metadata: { newId: newEntry.id },
|
|
121
|
-
});
|
|
122
|
-
db.exec('COMMIT');
|
|
123
|
-
}
|
|
124
|
-
catch (err) {
|
|
125
|
-
try {
|
|
126
|
-
db.exec('ROLLBACK');
|
|
127
|
-
}
|
|
128
|
-
catch { /* already rolled back */ }
|
|
129
|
-
// The refusal audit lands after ROLLBACK, in a fresh implicit transaction the aborted one cannot undo.
|
|
130
|
-
if (err instanceof RejectedValueError) {
|
|
131
|
-
auditRejectionRefusal(db, err, ctx.actor.subject);
|
|
132
|
-
}
|
|
133
|
-
throw err;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
109
|
export function archiveRaw(ctx, id, reason, opts = {}) {
|
|
110
|
+
const reply = ctx.store ? archiveRawThroughStore(ctx, ctx.store, id, reason, opts) : archiveRawOnHippoDb(ctx, id, reason, opts);
|
|
111
|
+
// SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
|
|
112
|
+
return reply;
|
|
113
|
+
}
|
|
114
|
+
/** afterArchive writes on hippo.db's own handle, and only connectors send it, so a store refuses it. */
|
|
115
|
+
async function archiveRawThroughStore(ctx, store, id, reason, opts) {
|
|
116
|
+
const entryWrites = requireGroup(store, 'entryWrites');
|
|
117
|
+
if (opts.afterArchive)
|
|
118
|
+
throw new Error('afterArchive runs on hippo.db only, never through a store');
|
|
119
|
+
const archivedAt = await entryWrites.archiveRaw({ tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), id, reason });
|
|
120
|
+
return { ok: true, archivedAt };
|
|
121
|
+
}
|
|
122
|
+
function archiveRawOnHippoDb(ctx, id, reason, opts) {
|
|
137
123
|
const db = openHippoDb(ctx.hippoRoot);
|
|
138
|
-
let
|
|
124
|
+
let archivedAt;
|
|
139
125
|
try {
|
|
140
126
|
// Tenant scope: archiveRawMemory looks up the row by id alone, so a
|
|
141
127
|
// Bearer for tenant A could archive tenant B's raw row without this
|
|
@@ -146,40 +132,12 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
|
|
|
146
132
|
if (reach?.tenantId !== ctx.tenantId || !canTouchScope(ctx.actor, reach.scope)) {
|
|
147
133
|
throw new NotFoundError(`memory not found: ${id}`);
|
|
148
134
|
}
|
|
149
|
-
archiveRawMemory(db, id, {
|
|
135
|
+
archivedAt = archiveRawMemory(db, id, {
|
|
150
136
|
reason,
|
|
151
137
|
who: ctx.actor.subject,
|
|
152
138
|
afterArchive: opts.afterArchive,
|
|
153
139
|
});
|
|
154
|
-
|
|
155
|
-
// mirror in <root>/{buffer,episodic,semantic}/<id>.md untouched. If we left
|
|
156
|
-
// the mirror in place, a subsequent initStore() on an empty memories table
|
|
157
|
-
// would silently re-import the row via bootstrapLegacyStore — defeating the
|
|
158
|
-
// archive (and the GDPR right-to-be-forgotten promise on raw rows). Mirror
|
|
159
|
-
// forget() at src/store.ts:1046, which uses the same removeEntryMirrors call.
|
|
160
|
-
// The DB transaction has already committed; if filesystem unlink fails here
|
|
161
|
-
// we log and continue. The mirror reaper in openHippoDb will catch it on
|
|
162
|
-
// next DB open: raw_archive.mirror_cleaned_at stays NULL until every layer
|
|
163
|
-
// mirror for this id is gone, so the reaper genuinely retries.
|
|
164
|
-
try {
|
|
165
|
-
removeEntryMirrors(ctx.hippoRoot, id);
|
|
166
|
-
mirrorOk = true;
|
|
167
|
-
}
|
|
168
|
-
catch (mirrorErr) {
|
|
169
|
-
log.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
|
|
170
|
-
}
|
|
171
|
-
if (mirrorOk) {
|
|
172
|
-
// Stamp mirror_cleaned_at now so the next openHippoDb reaper SELECT
|
|
173
|
-
// returns empty for this row. NULL stays untouched on failure -> retry.
|
|
174
|
-
try {
|
|
175
|
-
db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
if (!isSqliteBusy(err))
|
|
179
|
-
throw err;
|
|
180
|
-
log.warnThenDebug('archive-mirror-stamp-busy', `archived ${id}; the store was busy, so the mirror reaper will re-check it on the next open`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
140
|
+
cleanArchivedMirrors(db, ctx.hippoRoot, id);
|
|
183
141
|
}
|
|
184
142
|
finally {
|
|
185
143
|
closeHippoDb(db);
|
|
@@ -187,9 +145,6 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
|
|
|
187
145
|
// Counted here rather than in the CLI: the HTTP archive route calls this too,
|
|
188
146
|
// so a routed archive would otherwise never reach the forgotten counter.
|
|
189
147
|
updateStatsUnlessBusy(ctx.hippoRoot, { forgotten: 1 }, `removed ${id}`);
|
|
190
|
-
|
|
191
|
-
// emit a fresh ISO timestamp here for the API response. Within a millisecond
|
|
192
|
-
// of the actual write, fine for a server response shape.
|
|
193
|
-
return { ok: true, archivedAt: new Date().toISOString() };
|
|
148
|
+
return { ok: true, archivedAt };
|
|
194
149
|
}
|
|
195
150
|
//# sourceMappingURL=promote.js.map
|
package/dist/api/remember.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DatabaseSyncLike } from '../db.js';
|
|
2
2
|
import { type MemoryKind } from '../memory.js';
|
|
3
|
-
import type { Context } from './types.js';
|
|
3
|
+
import type { Context, StoreReply } from './types.js';
|
|
4
4
|
export interface RememberOpts {
|
|
5
5
|
content: string;
|
|
6
6
|
kind?: MemoryKind;
|
|
@@ -38,5 +38,6 @@ export interface RememberResult {
|
|
|
38
38
|
/** Set only when the content held secret material: untrusted text had it redacted, typed text was stored as sent. */
|
|
39
39
|
warnings?: string[];
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
/** Store one memory. With `ctx.store`, its entryWrites group writes the row and its remember row; hippo.db is opened only when there is no store. */
|
|
42
|
+
export declare function remember<C extends Context>(ctx: C, opts: RememberOpts): StoreReply<C, RememberResult>;
|
|
42
43
|
//# sourceMappingURL=remember.d.ts.map
|
package/dist/api/remember.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Write path: remember stores one memory after secret vetting and, for untrusted content, the instruction check.
|
|
2
2
|
import { writeEntry } from '../store/entry-writes.js';
|
|
3
|
+
import { stampOriginProject } from '../store/entry-row.js';
|
|
4
|
+
import { requireGroup } from '../store-port.js';
|
|
3
5
|
import { detectInstruction } from '../instruction-detect.js';
|
|
4
6
|
import { quarantineScopeFor, recordQuarantine } from '../quarantine.js';
|
|
5
7
|
import { createMemory } from '../memory.js';
|
|
@@ -24,7 +26,7 @@ function rememberScope(ctx, opts) {
|
|
|
24
26
|
}
|
|
25
27
|
return own;
|
|
26
28
|
}
|
|
27
|
-
|
|
29
|
+
function prepareRemember(ctx, opts) {
|
|
28
30
|
if (opts.project)
|
|
29
31
|
assertCallerProject(opts.project);
|
|
30
32
|
const requestedScope = rememberScope(ctx, opts);
|
|
@@ -39,6 +41,39 @@ export function remember(ctx, opts) {
|
|
|
39
41
|
tenantId: ctx.tenantId,
|
|
40
42
|
baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
|
|
41
43
|
});
|
|
44
|
+
// Aliases only widen what a reader matches; a row has one origin. A personal row's '' follows its owner into every project.
|
|
45
|
+
const origin = opts.personal ? '' : opts.project?.name;
|
|
46
|
+
const stamped = origin !== undefined ? { ...entry, origin_project: origin } : entry;
|
|
47
|
+
return { entry: stamped, requestedScope, detection, warnings: vetted.warnings };
|
|
48
|
+
}
|
|
49
|
+
function rememberResult(ctx, prepared) {
|
|
50
|
+
const { entry, detection, warnings } = prepared;
|
|
51
|
+
const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
|
|
52
|
+
if (detection.flagged)
|
|
53
|
+
result.quarantined = { reason: detection.reason ?? 'unknown' };
|
|
54
|
+
if (warnings.length > 0)
|
|
55
|
+
result.warnings = [...warnings];
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/** Store one memory. With `ctx.store`, its entryWrites group writes the row and its remember row; hippo.db is opened only when there is no store. */
|
|
59
|
+
export function remember(ctx, opts) {
|
|
60
|
+
const reply = ctx.store ? rememberThroughStore(ctx, ctx.store, opts) : rememberOnHippoDb(ctx, opts);
|
|
61
|
+
// SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
|
|
62
|
+
return reply;
|
|
63
|
+
}
|
|
64
|
+
/** afterWrite and the quarantine row write on hippo.db's own handle, and only connectors send them, so a store refuses both. */
|
|
65
|
+
async function rememberThroughStore(ctx, store, opts) {
|
|
66
|
+
const entryWrites = requireGroup(store, 'entryWrites');
|
|
67
|
+
if (opts.afterWrite || opts.untrusted)
|
|
68
|
+
throw new Error('afterWrite and untrusted content are written to hippo.db only, never through a store');
|
|
69
|
+
const prepared = prepareRemember(ctx, opts);
|
|
70
|
+
// The store writes origin_project as given, so the served folder's fallback is stamped here.
|
|
71
|
+
await entryWrites.writeEntry({ entry: stampOriginProject(ctx.hippoRoot, prepared.entry), actor: ctx.actor.subject });
|
|
72
|
+
return rememberResult(ctx, prepared);
|
|
73
|
+
}
|
|
74
|
+
function rememberOnHippoDb(ctx, opts) {
|
|
75
|
+
const prepared = prepareRemember(ctx, opts);
|
|
76
|
+
const { requestedScope, detection } = prepared;
|
|
42
77
|
// writeEntry threads ctx.actor.subject into its internal audit hook, so exactly
|
|
43
78
|
// one 'remember' event lands in the log with the supplied actor.
|
|
44
79
|
const afterWrite = detection.flagged
|
|
@@ -53,15 +88,7 @@ export function remember(ctx, opts) {
|
|
|
53
88
|
opts.afterWrite?.(db, memoryId);
|
|
54
89
|
}
|
|
55
90
|
: opts.afterWrite;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const stamped = origin !== undefined ? { ...entry, origin_project: origin } : entry;
|
|
59
|
-
writeEntry(ctx.hippoRoot, stamped, { actor: ctx.actor.subject, afterWrite });
|
|
60
|
-
const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
|
|
61
|
-
if (detection.flagged)
|
|
62
|
-
result.quarantined = { reason: detection.reason ?? 'unknown' };
|
|
63
|
-
if (vetted.warnings.length > 0)
|
|
64
|
-
result.warnings = vetted.warnings;
|
|
65
|
-
return result;
|
|
91
|
+
writeEntry(ctx.hippoRoot, prepared.entry, { actor: ctx.actor.subject, afterWrite });
|
|
92
|
+
return rememberResult(ctx, prepared);
|
|
66
93
|
}
|
|
67
94
|
//# sourceMappingURL=remember.js.map
|
package/dist/api/types.d.ts
CHANGED
|
@@ -25,6 +25,15 @@ export interface Context {
|
|
|
25
25
|
actor: Actor;
|
|
26
26
|
store?: HippoStore;
|
|
27
27
|
}
|
|
28
|
+
export type HippoDbContext = Context & {
|
|
29
|
+
store?: undefined;
|
|
30
|
+
};
|
|
31
|
+
export type StoreReply<C extends Context, R> = C extends {
|
|
32
|
+
readonly store: HippoStore;
|
|
33
|
+
} ? Promise<R> : C extends {
|
|
34
|
+
readonly hippoRoot: string;
|
|
35
|
+
readonly store?: undefined;
|
|
36
|
+
} ? R : R | Promise<R>;
|
|
28
37
|
/**
|
|
29
38
|
* Helper for building process-local Actor values (admin and host admin),
|
|
30
39
|
* used by CLI and CLI-run connector Context constructors so the role
|
package/dist/auth.d.ts
CHANGED
|
@@ -23,6 +23,25 @@ export interface CreateApiKeyResult {
|
|
|
23
23
|
keyId: string;
|
|
24
24
|
plaintext: string;
|
|
25
25
|
}
|
|
26
|
+
/** A fresh key: the plaintext its caller sees once, and the scrypt hash a store keeps in its place. */
|
|
27
|
+
export interface MintedApiKey extends CreateApiKeyResult {
|
|
28
|
+
keyHash: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function mintApiKey(): MintedApiKey;
|
|
31
|
+
/** One api_keys row as core hands it to a store: the hash of the secret, never the secret. */
|
|
32
|
+
export interface NewApiKey {
|
|
33
|
+
readonly keyId: string;
|
|
34
|
+
readonly keyHash: string;
|
|
35
|
+
readonly tenantId: string;
|
|
36
|
+
readonly label: string | null;
|
|
37
|
+
readonly role: 'admin' | 'member';
|
|
38
|
+
readonly createdAt: string;
|
|
39
|
+
/** The auth-resolver subject a self-service key belongs to; null for a key an admin or the CLI mints. */
|
|
40
|
+
readonly ownerSubject: string | null;
|
|
41
|
+
/** ISO time the key stops working; null means it never expires. */
|
|
42
|
+
readonly expiresAt: string | null;
|
|
43
|
+
}
|
|
44
|
+
export declare function insertApiKey(db: DatabaseSyncLike, key: NewApiKey): void;
|
|
26
45
|
export declare function createApiKey(db: DatabaseSyncLike, opts: CreateApiKeyOpts): CreateApiKeyResult;
|
|
27
46
|
export interface ValidateResult {
|
|
28
47
|
valid: boolean;
|
package/dist/auth.js
CHANGED
|
@@ -36,17 +36,25 @@ function verifyKey(plaintext, stored) {
|
|
|
36
36
|
const actual = scryptSync(plaintext, salt, expected.length);
|
|
37
37
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
38
38
|
}
|
|
39
|
-
export function
|
|
39
|
+
export function mintApiKey() {
|
|
40
40
|
const keyId = `${API_KEY_PREFIX}${randBase32(ID_LEN)}`;
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
const plaintext = `${keyId}.${randBase32(SECRET_LEN)}`;
|
|
42
|
+
return { keyId, plaintext, keyHash: hashKey(plaintext) };
|
|
43
|
+
}
|
|
44
|
+
export function insertApiKey(db, key) {
|
|
44
45
|
// openHippoDb runs runMigrations synchronously before returning the db handle,
|
|
45
46
|
// so migration v26 (adds role column) is in place before this INSERT runs.
|
|
46
47
|
// 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 (
|
|
48
|
+
if (key.expiresAt !== null)
|
|
48
49
|
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,
|
|
50
|
+
db.prepare(`INSERT INTO api_keys (key_id, key_hash, tenant_id, label, created_at, role, owner_subject, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key.keyId, key.keyHash, key.tenantId, key.label, key.createdAt, key.role, key.ownerSubject, key.expiresAt);
|
|
51
|
+
}
|
|
52
|
+
export function createApiKey(db, opts) {
|
|
53
|
+
const { keyId, plaintext, keyHash } = mintApiKey();
|
|
54
|
+
insertApiKey(db, {
|
|
55
|
+
keyId, keyHash, tenantId: opts.tenantId, label: opts.label ?? null, role: opts.role ?? 'admin', createdAt: new Date().toISOString(),
|
|
56
|
+
ownerSubject: opts.ownerSubject ?? null, expiresAt: opts.expiresAt ?? null,
|
|
57
|
+
});
|
|
50
58
|
return { keyId, plaintext };
|
|
51
59
|
}
|
|
52
60
|
/** The api_keys row for `keyId` with its scope grants; null when no row matches. */
|
package/dist/capture/compact.js
CHANGED
|
@@ -4,10 +4,11 @@ import { isInitialized } from '../store/open.js';
|
|
|
4
4
|
import { saveActiveTaskSnapshot, loadActiveTaskSnapshot } from '../store/sessions.js';
|
|
5
5
|
import { PRE_COMPACT_INSTRUCTION, parsePostCompactPayload, postCompactLine, recordCompactionStart, recordSnapshotSaved, saveCompaction, replayCompactionsAt, COMPACTION_DB_WAIT_MS, } from '../compaction-record.js';
|
|
6
6
|
import { resolveTenantId } from '../tenant.js';
|
|
7
|
-
import { defaultPreCompactLogPath } from '../hooks/shared.js';
|
|
7
|
+
import { defaultPreCompactLogPath, vscodeUserHooksFile } from '../hooks/shared.js';
|
|
8
8
|
import { readClaudeCodePreCompact } from '../capture-contract.js';
|
|
9
9
|
import { errorMessage } from '../log.js';
|
|
10
10
|
import { resolveLastSessionTranscript } from './transcript.js';
|
|
11
|
+
import { isVscodeTranscript } from './copilot-transcript.js';
|
|
11
12
|
import { mergeWorkingState, transcriptWorkingState } from './working-state.js';
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// `hippo pre-compact` — PreCompact hook producer
|
|
@@ -58,6 +59,14 @@ function printPreCompactInstruction(logFile) {
|
|
|
58
59
|
appendPreCompactLog(logFile, `instruction not printed: ${errorMessage(err)}`);
|
|
59
60
|
}
|
|
60
61
|
}
|
|
62
|
+
/** The Copilot CLI accepts both preCompact and PreCompact, so hippo.json's pair can run twice for one compaction. */
|
|
63
|
+
const TWIN_FIRE_MS = 10_000;
|
|
64
|
+
function snapshotJustSaved(hippoRoot, sessionId) {
|
|
65
|
+
if (sessionId === null || sessionId === '')
|
|
66
|
+
return false;
|
|
67
|
+
const active = loadActiveTaskSnapshot(hippoRoot, resolveTenantId({}));
|
|
68
|
+
return active?.session_id === sessionId && active.source === 'pre-compact' && Date.now() - Date.parse(active.updated_at) < TWIN_FIRE_MS;
|
|
69
|
+
}
|
|
61
70
|
/** Runs the PreCompact producer: records the compaction, asks the summariser for memories, saves a working-state snapshot. Never extracts memories itself; SessionEnd capture owns that. */
|
|
62
71
|
function runPreCompact(hippoRoot, options, logFile) {
|
|
63
72
|
const { stdinText, stdinTimedOut = false, runtime = 'claude-code' } = options;
|
|
@@ -74,10 +83,20 @@ function runPreCompact(hippoRoot, options, logFile) {
|
|
|
74
83
|
return;
|
|
75
84
|
}
|
|
76
85
|
const { sessionId, transcriptPath: payloadTranscriptPath, cwd: payloadCwd, trigger: payloadTrigger } = receipt.input;
|
|
86
|
+
// With chat.useClaudeHooks on, VS Code runs Claude Code's hooks too; it never sends PostCompact to close a record, nor reads the summariser text.
|
|
87
|
+
const vscode = runtime === 'claude-code' && isVscodeTranscript(payloadTranscriptPath);
|
|
88
|
+
if (vscode && fs.existsSync(vscodeUserHooksFile())) {
|
|
89
|
+
appendPreCompactLog(logFile, 'skip: VS Code payload, hippo.json runs pre-compact for this chat');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (runtime === 'copilot' && snapshotJustSaved(hippoRoot, sessionId)) {
|
|
93
|
+
appendPreCompactLog(logFile, `skip: snapshot for session ${sessionId} saved under ${TWIN_FIRE_MS / 1000} s ago, by the other preCompact entry`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
77
96
|
// The record is the "something saved before every compaction", so it lands even when no snapshot is derivable below.
|
|
78
97
|
// Copilot has no PostCompact hook to close a record, so its compactions get the snapshot alone.
|
|
79
98
|
let recordId = null;
|
|
80
|
-
if (runtime === 'claude-code' && sessionId !== null && sessionId !== '') {
|
|
99
|
+
if (runtime === 'claude-code' && !vscode && sessionId !== null && sessionId !== '') {
|
|
81
100
|
recordId = recordCompactionStart(hippoRoot, { sessionId, trigger: payloadTrigger, cwd: payloadCwd, transcriptPath: payloadTranscriptPath }, (message) => appendPreCompactLog(logFile, message));
|
|
82
101
|
printPreCompactInstruction(logFile);
|
|
83
102
|
}
|
|
@@ -8,8 +8,11 @@ export interface CopilotTurn {
|
|
|
8
8
|
role: 'user' | 'assistant';
|
|
9
9
|
text: string;
|
|
10
10
|
}
|
|
11
|
+
export declare const SESSION_ID_RE: RegExp;
|
|
11
12
|
/** The Copilot CLI log for a session, `<copilotHome>/session-state/<id>/events.jsonl`; null for an unsafe id or a missing file. */
|
|
12
13
|
export declare function copilotTranscriptFor(sessionId: string): string | null;
|
|
14
|
+
/** True for VS Code's own chat log, `<workspaceStorage>/<id>/github.copilot-chat/transcripts/<session id>.jsonl`, in either slash and any case. */
|
|
15
|
+
export declare function isVscodeTranscript(file: string | null): boolean;
|
|
13
16
|
/** The turn one Copilot event-log line carries: the text of a human user.message or of an assistant.message; null for any other line, a sub-agent's included. */
|
|
14
17
|
export declare function copilotTurn(line: CopilotEventLine): CopilotTurn | null;
|
|
15
18
|
//# sourceMappingURL=copilot-transcript.d.ts.map
|
|
@@ -5,7 +5,7 @@ import * as path from 'path';
|
|
|
5
5
|
import { copilotHomeDir } from '../hooks/shared.js';
|
|
6
6
|
import { isObjectLike, isStringValue } from '../capture-contract.js';
|
|
7
7
|
// A session id becomes a folder name, so anything beyond word characters and hyphens could climb out of session-state.
|
|
8
|
-
const SESSION_ID_RE = /^[\w-]+$/;
|
|
8
|
+
export const SESSION_ID_RE = /^[\w-]+$/;
|
|
9
9
|
// user.message sources that mark text hippo did not get from the human: a hidden skill injection or another agent's prompt.
|
|
10
10
|
const INJECTED_SOURCE_PREFIXES = ['skill-', 'agent-'];
|
|
11
11
|
/** The Copilot CLI log for a session, `<copilotHome>/session-state/<id>/events.jsonl`; null for an unsafe id or a missing file. */
|
|
@@ -15,6 +15,13 @@ export function copilotTranscriptFor(sessionId) {
|
|
|
15
15
|
const file = path.join(copilotHomeDir(), 'session-state', sessionId, 'events.jsonl');
|
|
16
16
|
return fs.existsSync(file) ? file : null;
|
|
17
17
|
}
|
|
18
|
+
/** True for VS Code's own chat log, `<workspaceStorage>/<id>/github.copilot-chat/transcripts/<session id>.jsonl`, in either slash and any case. */
|
|
19
|
+
export function isVscodeTranscript(file) {
|
|
20
|
+
if (!file)
|
|
21
|
+
return false;
|
|
22
|
+
const parts = file.toLowerCase().split(/[\\/]/);
|
|
23
|
+
return parts.length >= 3 && parts[parts.length - 2] === 'transcripts' && parts[parts.length - 3] === 'github.copilot-chat';
|
|
24
|
+
}
|
|
18
25
|
function isSubagentLine(line, data) {
|
|
19
26
|
if (isStringValue(line.agentId) && line.agentId !== '')
|
|
20
27
|
return true;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SessionTurn } from './transcript.js';
|
|
2
|
+
/** A turn close runs after each reply; a full close runs once, when the session ends. */
|
|
3
|
+
export type WorkerMode = 'turn' | 'full';
|
|
4
|
+
/** Runs `work` unless a live worker holds this session, which then runs the request after its own so the last reply is never dropped. */
|
|
5
|
+
export declare function runSessionWorker(sessionId: string | null, mode: WorkerMode, work: (mode: WorkerMode) => Promise<void>): Promise<void>;
|
|
6
|
+
/** The cursor position after `turns`: how many there were and a hash of the last. */
|
|
7
|
+
export declare function turnPosition(turns: readonly SessionTurn[]): string;
|
|
8
|
+
/** The turns after `position`; all of them when it is null or its last turn is no longer in view. */
|
|
9
|
+
export declare function turnsAfter(turns: readonly SessionTurn[], position: string | null): readonly SessionTurn[];
|
|
10
|
+
/** The saved position for this session's transcript; null when none was saved, it names another file, or it is unreadable. */
|
|
11
|
+
export declare function loadTurnPosition(sessionId: string, transcriptPath: string): string | null;
|
|
12
|
+
export declare function saveTurnPosition(sessionId: string, transcriptPath: string, turns: readonly SessionTurn[], log: (message: string) => void): void;
|
|
13
|
+
//# sourceMappingURL=session-worker.d.ts.map
|