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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-memories/copilot.d.ts +3 -1
  3. package/dist/agent-memories/copilot.js +2 -2
  4. package/dist/api/auth.d.ts +14 -18
  5. package/dist/api/auth.js +93 -60
  6. package/dist/api/context-select.d.ts +10 -0
  7. package/dist/api/context-select.js +16 -18
  8. package/dist/api/context.js +101 -60
  9. package/dist/api/forget.d.ts +2 -2
  10. package/dist/api/forget.js +11 -0
  11. package/dist/api/outcome.d.ts +4 -4
  12. package/dist/api/outcome.js +29 -6
  13. package/dist/api/promote.d.ts +3 -3
  14. package/dist/api/promote.js +45 -90
  15. package/dist/api/remember.d.ts +3 -2
  16. package/dist/api/remember.js +38 -11
  17. package/dist/api/types.d.ts +9 -0
  18. package/dist/auth.d.ts +19 -0
  19. package/dist/auth.js +14 -6
  20. package/dist/capture/compact.js +21 -2
  21. package/dist/capture/copilot-transcript.d.ts +3 -0
  22. package/dist/capture/copilot-transcript.js +8 -1
  23. package/dist/capture/session-worker.d.ts +13 -0
  24. package/dist/capture/session-worker.js +194 -0
  25. package/dist/capture-contract.d.ts +3 -1
  26. package/dist/capture-contract.js +36 -0
  27. package/dist/cli/session-hooks.js +90 -22
  28. package/dist/cli/setup.js +70 -24
  29. package/dist/cli/usage.js +4 -1
  30. package/dist/cli.js +1 -1
  31. package/dist/connectors/github/ingest.js +2 -1
  32. package/dist/connectors/slack/deletion.js +2 -1
  33. package/dist/connectors/slack/ingest.js +2 -1
  34. package/dist/embeddings.d.ts +10 -10
  35. package/dist/embeddings.js +103 -18
  36. package/dist/env.d.ts +2 -0
  37. package/dist/env.js +2 -0
  38. package/dist/hooks/copilot.d.ts +50 -8
  39. package/dist/hooks/copilot.js +116 -30
  40. package/dist/hooks/json-hooks.d.ts +2 -0
  41. package/dist/hooks/json-hooks.js +11 -2
  42. package/dist/hooks/shared.d.ts +7 -1
  43. package/dist/hooks/shared.js +18 -3
  44. package/dist/mcp/memory-tools.d.ts +2 -2
  45. package/dist/mcp/memory-tools.js +9 -8
  46. package/dist/mcp/request.js +3 -3
  47. package/dist/memory.d.ts +2 -0
  48. package/dist/memory.js +7 -0
  49. package/dist/prompt-recall.d.ts +5 -0
  50. package/dist/prompt-recall.js +13 -0
  51. package/dist/raw-archive.d.ts +2 -2
  52. package/dist/raw-archive.js +5 -3
  53. package/dist/recall-scope.d.ts +2 -5
  54. package/dist/recall-scope.js +5 -6
  55. package/dist/server/routes/admin.js +2 -2
  56. package/dist/server/routes/memories.js +9 -6
  57. package/dist/server-detect.js +3 -2
  58. package/dist/server.d.ts +20 -4
  59. package/dist/server.js +23 -11
  60. package/dist/store/audit-event.d.ts +1 -1
  61. package/dist/store/audit-event.js +1 -1
  62. package/dist/store/entry-writes-group.d.ts +9 -0
  63. package/dist/store/entry-writes-group.js +157 -0
  64. package/dist/store/handoffs.d.ts +5 -2
  65. package/dist/store/handoffs.js +35 -5
  66. package/dist/store/key-writes.d.ts +9 -0
  67. package/dist/store/key-writes.js +30 -0
  68. package/dist/store/open.d.ts +2 -0
  69. package/dist/store/open.js +10 -0
  70. package/dist/store/search-rows.js +3 -9
  71. package/dist/store/sessions.d.ts +5 -0
  72. package/dist/store/sessions.js +4 -1
  73. package/dist/store/vector-writes.d.ts +6 -0
  74. package/dist/store/vector-writes.js +59 -0
  75. package/dist/store-port.d.ts +143 -1
  76. package/dist/store-port.js +48 -12
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  80. package/extensions/openclaw-plugin/package.json +1 -1
  81. package/openclaw.plugin.json +1 -1
  82. package/package.json +1 -1
@@ -1,14 +1,12 @@
1
1
  // Promote to the global store, supersede with a successor, and archive raw memories.
2
- import { openHippoDb, closeHippoDb, isSqliteBusy } from '../db.js';
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 { removeEntryMirrors } from '../store/mirrors.js';
7
- import { writeEntryDbOnly, writeEntryMirrors } from '../store/entry-writes.js';
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 { markSummaryDirtyInTx } from '../summary-dirty.js';
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 old = readSupersedable(ctx, oldId);
53
- const newEntry = createSuccessor(old, newContent, {
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, oldId, old, newEntry);
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, newEntry);
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: newEntry.id };
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 readSupersedable(ctx, oldId) {
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 mirrorOk = false;
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
- // archiveRawMemory deletes the memories row but leaves any legacy markdown
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
- // archiveRawMemory does not return the archive_at timestamp it wrote. We
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
@@ -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
- export declare function remember(ctx: Context, opts: RememberOpts): RememberResult;
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
@@ -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
- export function remember(ctx, opts) {
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
- // Aliases only widen what a reader matches; a row has one origin. A personal row's '' follows its owner into every project.
57
- const origin = opts.personal ? '' : opts.project?.name;
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
@@ -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 createApiKey(db, opts) {
39
+ export function mintApiKey() {
40
40
  const keyId = `${API_KEY_PREFIX}${randBase32(ID_LEN)}`;
41
- const secret = randBase32(SECRET_LEN);
42
- const plaintext = `${keyId}.${secret}`;
43
- const hash = hashKey(plaintext);
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 (opts.expiresAt !== undefined)
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, hash, opts.tenantId, opts.label ?? null, new Date().toISOString(), opts.role ?? 'admin', opts.ownerSubject ?? null, opts.expiresAt ?? null);
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. */
@@ -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