hippo-memory 1.68.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 (70) 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/context-select.d.ts +10 -0
  5. package/dist/api/context-select.js +16 -18
  6. package/dist/api/context.js +101 -60
  7. package/dist/api/forget.d.ts +2 -2
  8. package/dist/api/forget.js +11 -0
  9. package/dist/api/outcome.d.ts +4 -4
  10. package/dist/api/outcome.js +29 -6
  11. package/dist/api/promote.d.ts +3 -3
  12. package/dist/api/promote.js +45 -90
  13. package/dist/api/remember.d.ts +3 -2
  14. package/dist/api/remember.js +38 -11
  15. package/dist/capture/compact.js +21 -2
  16. package/dist/capture/copilot-transcript.d.ts +3 -0
  17. package/dist/capture/copilot-transcript.js +8 -1
  18. package/dist/capture/session-worker.d.ts +13 -0
  19. package/dist/capture/session-worker.js +194 -0
  20. package/dist/capture-contract.d.ts +3 -1
  21. package/dist/capture-contract.js +36 -0
  22. package/dist/cli/session-hooks.js +90 -22
  23. package/dist/cli/setup.js +70 -24
  24. package/dist/cli/usage.js +4 -1
  25. package/dist/cli.js +1 -1
  26. package/dist/connectors/github/ingest.js +2 -1
  27. package/dist/connectors/slack/deletion.js +2 -1
  28. package/dist/connectors/slack/ingest.js +2 -1
  29. package/dist/env.d.ts +2 -0
  30. package/dist/env.js +2 -0
  31. package/dist/hooks/copilot.d.ts +50 -8
  32. package/dist/hooks/copilot.js +116 -30
  33. package/dist/hooks/json-hooks.d.ts +2 -0
  34. package/dist/hooks/json-hooks.js +11 -2
  35. package/dist/hooks/shared.d.ts +7 -1
  36. package/dist/hooks/shared.js +18 -3
  37. package/dist/mcp/memory-tools.d.ts +2 -2
  38. package/dist/mcp/memory-tools.js +9 -8
  39. package/dist/mcp/request.js +3 -3
  40. package/dist/memory.d.ts +2 -0
  41. package/dist/memory.js +7 -0
  42. package/dist/prompt-recall.d.ts +5 -0
  43. package/dist/prompt-recall.js +13 -0
  44. package/dist/raw-archive.d.ts +2 -2
  45. package/dist/raw-archive.js +5 -3
  46. package/dist/recall-scope.d.ts +2 -5
  47. package/dist/recall-scope.js +5 -6
  48. package/dist/server/routes/memories.js +9 -6
  49. package/dist/server-detect.js +3 -2
  50. package/dist/server.d.ts +14 -1
  51. package/dist/server.js +16 -6
  52. package/dist/store/audit-event.d.ts +1 -1
  53. package/dist/store/audit-event.js +1 -1
  54. package/dist/store/entry-writes-group.d.ts +9 -0
  55. package/dist/store/entry-writes-group.js +157 -0
  56. package/dist/store/handoffs.d.ts +5 -2
  57. package/dist/store/handoffs.js +35 -5
  58. package/dist/store/open.d.ts +2 -0
  59. package/dist/store/open.js +10 -0
  60. package/dist/store/search-rows.js +3 -9
  61. package/dist/store/sessions.d.ts +5 -0
  62. package/dist/store/sessions.js +4 -1
  63. package/dist/store-port.d.ts +71 -0
  64. package/dist/store-port.js +23 -11
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  68. package/extensions/openclaw-plugin/package.json +1 -1
  69. package/openclaw.plugin.json +1 -1
  70. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { envHealthProbeMs } from './env.js';
3
4
  // Pidfile sits directly inside hippoRoot. `hippoRoot` is the `.hippo`
4
5
  // directory itself (the same convention used by api.ts / store.ts /
5
6
  // openHippoDb), so this resolves to `${hippoRoot}/server.pid`.
@@ -8,7 +9,7 @@ const PIDFILE = 'server.pid';
8
9
  * How long detectServer waits for the `/health` liveness probe before
9
10
  * treating the pidfile as stale. Short by design: the probe only fires on
10
11
  * the rare path where a pidfile exists and its pid is live, and the target
11
- * is always loopback, so a healthy server answers well within this bound.
12
+ * is always loopback, so a healthy server answers well within this bound. HIPPO_HEALTH_PROBE_MS overrides it.
12
13
  */
13
14
  const HEALTH_PROBE_TIMEOUT_MS = 300;
14
15
  /**
@@ -84,7 +85,7 @@ async function healthMatchesPidfile(hippoRoot, info) {
84
85
  // returns null WITHOUT unlinking. The pidfile survives for the next probe.
85
86
  try {
86
87
  const res = await fetch(`${info.url}/health`, {
87
- signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS),
88
+ signal: AbortSignal.timeout(envHealthProbeMs() ?? HEALTH_PROBE_TIMEOUT_MS),
88
89
  });
89
90
  if (!res.ok || !res.body) {
90
91
  removePidfile(hippoRoot);
package/dist/server.d.ts CHANGED
@@ -11,7 +11,11 @@ export { HttpError } from './http-util.js';
11
11
  export { promptHookContext, type CallerProject } from './prompt-hook.js';
12
12
  export type { JsonValue } from './json.js';
13
13
  export { captureSessionTexts, type SessionCaptureRequest, type SessionCaptureResult } from './capture/session-texts.js';
14
- export { hasGroup, sqliteStore, type HippoStore, type KeyAudit, type KeyListQuery, type KeyMint, type KeyRevoke, type KeyWrites, type RecallSearchArgs, type RecallWrites, type SelfKeyMint, type StoreGroup, type StoreGroups, type VectorReads, type VectorBackfillQuery, type VectorRowWrite, type VectorWrite, type VectorWriteResult, type VectorWrites, } from './store-port.js';
14
+ export { hasGroup, sqliteStore, type AmbientCandidateRequest, type ContextReads, type EntryRemoval, type EntryTarget, type EntryWrite, type EntryWrites, type HippoStore, type KeyAudit, type KeyListQuery, type KeyMint, type KeyRevoke, type KeyWrites, type OutcomeWrite, type RawArchive, type RecallSearchArgs, type RecallWrites, type SelfKeyMint, type StoreGroup, type StoreGroups, type SupersedeWrite, type VectorReads, type VectorBackfillQuery, type VectorRowWrite, type VectorWrite, type VectorWriteResult, type VectorWrites, } from './store-port.js';
15
+ export { entryAfterOutcome } from './memory.js';
16
+ export { rejectionDigest, RejectedValueError } from './rejection.js';
17
+ export { ownScopeTouches } from './recall-scope.js';
18
+ export { BadRequestError, ConflictError } from './api-errors.js';
15
19
  export type { HippoDbContext, StoreReply } from './api/types.js';
16
20
  export type { ApiKeyListItem, ApiKeyListRow, ApiKeyRecord, ListApiKeysOpts, NewApiKey } from './auth.js';
17
21
  export type { KeysetPosition } from './keyset.js';
@@ -25,6 +29,11 @@ export type { RecallTraceInput } from './recall-trace.js';
25
29
  export type { StrengthenOptions } from './store/entry-writes.js';
26
30
  export type { OriginFilter, RecallScopeFilter, VectorCandidateSpec } from './store/search-rows.js';
27
31
  export type { ContinuityKey } from './store/sessions.js';
32
+ export type { SessionEvent, TaskSnapshot } from './store/rows.js';
33
+ export type { SessionHandoff } from './handoff.js';
34
+ export type { AmbientLoadResult, AmbientRecallRequest, ContextCandidateFilter, RecentOrigins } from './store/candidates.js';
35
+ export type { AmbientStoreFilter } from './ambient-store.js';
36
+ export type { AmbientTallies } from './ambient.js';
28
37
  export type { TokenUse } from './token-ledger.js';
29
38
  export type { EmbeddingIndexState } from './embeddings.js';
30
39
  export type { PhysicsParticle } from './physics.js';
@@ -32,6 +41,10 @@ export { StoreBusyError } from './db.js';
32
41
  export { decodeVector, EMBEDDING_MODEL_META_KEY, encodeVector, rankVectorRows, type VectorMatch, type VectorRow } from './vector-store.js';
33
42
  export { bufferToFloat32, float32ToBuffer } from './physics-state.js';
34
43
  export { replacesIndex } from './embeddings.js';
44
+ export { passesScopeFilterForRecall, RECALL_DEFAULT_DENY_SCOPES } from './recall-scope.js';
45
+ export { SECRET_TAGS } from './secret-detect.js';
46
+ export { tallyAmbientEntries } from './ambient.js';
47
+ export { ftsTermParts, rarestFtsQuery } from './prompt-recall.js';
35
48
  export { OTHER_STORE_MARKER, OtherStoreFolderError, withSqliteAllowed } from './db.js';
36
49
  export { authCreate, type AuthCreateOpts, type AuthCreateResult } from './api.js';
37
50
  export { isSharedStore } from './config.js';
package/dist/server.js CHANGED
@@ -47,12 +47,22 @@ export { promptHookContext } from './prompt-hook.js';
47
47
  export { captureSessionTexts } from './capture/session-texts.js';
48
48
  // An add-on serves from another database by passing serve() its own HippoStore.
49
49
  export { hasGroup, sqliteStore, } from './store-port.js';
50
+ // An add-on store's entry writes apply an outcome, guard tombstones and check reach exactly as hippo.db does.
51
+ export { entryAfterOutcome } from './memory.js';
52
+ export { rejectionDigest, RejectedValueError } from './rejection.js';
53
+ export { ownScopeTouches } from './recall-scope.js';
54
+ export { BadRequestError, ConflictError } from './api-errors.js';
50
55
  export { StoreBusyError } from './db.js';
51
56
  // An add-on store encodes, decodes and ranks vectors and particles with hippo.db's own code, and drops the index by its rule,
52
57
  // so both stores keep the same bytes and return the same ids in the same order.
53
58
  export { decodeVector, EMBEDDING_MODEL_META_KEY, encodeVector, rankVectorRows } from './vector-store.js';
54
59
  export { bufferToFloat32, float32ToBuffer } from './physics-state.js';
55
60
  export { replacesIndex } from './embeddings.js';
61
+ // An add-on's ContextReads applies hippo.db's scope, secret, tally and rarest-term rules with the same code.
62
+ export { passesScopeFilterForRecall, RECALL_DEFAULT_DENY_SCOPES } from './recall-scope.js';
63
+ export { SECRET_TAGS } from './secret-detect.js';
64
+ export { tallyAmbientEntries } from './ambient.js';
65
+ export { ftsTermParts, rarestFtsQuery } from './prompt-recall.js';
56
66
  // store copy --db writes the marker and reads the old hippo.db under the waiver.
57
67
  export { OTHER_STORE_MARKER, OtherStoreFolderError, withSqliteAllowed } from './db.js';
58
68
  // An add-on's install step mints the first admin key into a store folder it names, which `hippo auth create` cannot reach.
@@ -98,17 +108,17 @@ const VERSION = PACKAGE_VERSION;
98
108
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
99
109
  /** The /v1 routes in dispatch order; the first entry whose method and path match handles the request. */
100
110
  const V1_ROUTES = [
101
- { method: 'POST', path: '/v1/memories', handler: handleCreateMemory },
111
+ { method: 'POST', path: '/v1/memories', storeReady: 'entryWrites', handler: handleCreateMemory },
102
112
  { method: 'GET', path: '/v1/graph', handler: handleGetGraph },
103
113
  { method: 'GET', path: '/v1/memories', storeReady: 'base', handler: handleRecallMemories },
104
114
  { method: 'GET', pattern: '/v1/sessions/:id/assemble', handler: handleAssembleSession },
105
115
  { method: 'GET', pattern: '/v1/recall/drill/:id', handler: handleDrillRecall },
106
- { method: 'POST', pattern: '/v1/memories/:id/archive', handler: handleArchiveMemory },
107
- { method: 'POST', pattern: '/v1/memories/:id/supersede', handler: handleSupersedeMemory },
116
+ { method: 'POST', pattern: '/v1/memories/:id/archive', storeReady: 'entryWrites', handler: handleArchiveMemory },
117
+ { method: 'POST', pattern: '/v1/memories/:id/supersede', storeReady: 'entryWrites', handler: handleSupersedeMemory },
108
118
  { method: 'POST', pattern: '/v1/memories/:id/promote', handler: handlePromoteMemory },
109
- { method: 'DELETE', pattern: '/v1/memories/:id', handler: handleForgetMemory },
110
- { method: 'POST', path: '/v1/outcome', handler: handleApplyOutcome },
111
- { method: 'GET', path: '/v1/context', handler: handleGetContext },
119
+ { method: 'DELETE', pattern: '/v1/memories/:id', storeReady: 'entryWrites', handler: handleForgetMemory },
120
+ { method: 'POST', path: '/v1/outcome', storeReady: 'entryWrites', handler: handleApplyOutcome },
121
+ { method: 'GET', path: '/v1/context', storeReady: 'contextReads', handler: handleGetContext },
112
122
  { method: 'POST', path: '/v1/sleep', handler: handleSleep },
113
123
  { method: 'POST', path: '/v1/auth/keys', storeReady: 'keyWrites', handler: handleCreateAuthKey },
114
124
  { method: 'GET', path: '/v1/auth/keys', storeReady: 'keyWrites', handler: handleListAuthKeys },
@@ -11,7 +11,7 @@ export declare function audit(db: ReturnType<typeof openHippoDb>, op: AuditOp, t
11
11
  /**
12
12
  * Refusal audit for the rejected-value guard. Written by the
13
13
  * transaction OWNER post-rollback — writeEntry's catch (no outer tx exists
14
- * there, so this lands in a fresh implicit transaction) and api.supersede's
14
+ * there, so this lands in a fresh implicit transaction) and commitSupersede's
15
15
  * catch (after its own ROLLBACK) — never inside a scope the caller's own
16
16
  * rollback could claw back. Best-effort `audit()` semantics: never throws.
17
17
  */
@@ -23,7 +23,7 @@ export function audit(db, op, targetId, metadata, actor = 'cli', tenantId) {
23
23
  /**
24
24
  * Refusal audit for the rejected-value guard. Written by the
25
25
  * transaction OWNER post-rollback — writeEntry's catch (no outer tx exists
26
- * there, so this lands in a fresh implicit transaction) and api.supersede's
26
+ * there, so this lands in a fresh implicit transaction) and commitSupersede's
27
27
  * catch (after its own ROLLBACK) — never inside a scope the caller's own
28
28
  * rollback could claw back. Best-effort `audit()` semantics: never throws.
29
29
  */
@@ -0,0 +1,9 @@
1
+ import { type DatabaseSyncLike } from '../db.js';
2
+ import type { EntryWrites, SupersedeWrite } from '../store-port.js';
3
+ /** Each call on its own handle; mirrors and the forgotten counter follow the commit, so a rolled-back write leaves neither. */
4
+ export declare function sqliteEntryWrites(hippoRoot: string): EntryWrites;
5
+ /** Reach, the CAS on the old row, the successor's insert and the supersede row in one BEGIN IMMEDIATE transaction. Two racing supersedes: one CAS wins. */
6
+ export declare function commitSupersede(db: DatabaseSyncLike, write: SupersedeWrite): void;
7
+ /** A mirror left on disk would bring the archived row back on the next import; on failure the reaper retries, as mirror_cleaned_at stays NULL. */
8
+ export declare function cleanArchivedMirrors(db: DatabaseSyncLike, hippoRoot: string, id: string): void;
9
+ //# sourceMappingURL=entry-writes-group.d.ts.map
@@ -0,0 +1,157 @@
1
+ // hippo.db's half of the EntryWrites store group: the queries remember, outcome, supersede, archive and forget run today.
2
+ import { ConflictError, NotFoundError } from '../api-errors.js';
3
+ import { appendAuditEvent } from '../audit.js';
4
+ import { isSqliteBusy, withWriteScope } from '../db.js';
5
+ import { log } from '../log.js';
6
+ import { entryAfterOutcome } from '../memory.js';
7
+ import { archiveRawMemory } from '../raw-archive.js';
8
+ import { ownScopeTouches } from '../recall-scope.js';
9
+ import { RejectedValueError } from '../rejection.js';
10
+ import { markSummaryDirtyInTx } from '../summary-dirty.js';
11
+ import { auditRejectionRefusal } from './audit-event.js';
12
+ import { deleteEntryCore } from './delete-and-batch.js';
13
+ import { selectEntriesByIds } from './entry-reads.js';
14
+ import { stampOriginProject } from './entry-row.js';
15
+ import { writeEntryDbOnly, writeEntryMirrors } from './entry-writes.js';
16
+ import { updateStatsUnlessBusy } from './index-and-stats.js';
17
+ import { purgeMirrorBestEffort, removeEntryMirrors } from './mirrors.js';
18
+ import { onHandle, openStore } from './open.js';
19
+ import { selectMemoryReach } from './tenant-lookup.js';
20
+ /** Each call on its own handle; mirrors and the forgotten counter follow the commit, so a rolled-back write leaves neither. */
21
+ export function sqliteEntryWrites(hippoRoot) {
22
+ return {
23
+ async writeEntry({ entry, actor }) {
24
+ const stamped = stampOriginProject(hippoRoot, entry);
25
+ onHandle(hippoRoot, (db) => writeInOwnTenant(db, stamped, actor), openStore);
26
+ writeEntryMirrors(hippoRoot, stamped);
27
+ },
28
+ async applyOutcome(outcome) {
29
+ const applied = onHandle(hippoRoot, (db) => applyOutcomeOn(db, outcome), openStore);
30
+ for (const entry of applied)
31
+ writeEntryMirrors(hippoRoot, entry);
32
+ return applied.map((entry) => entry.id);
33
+ },
34
+ async supersede(write) {
35
+ const successor = stampOriginProject(hippoRoot, write.successor);
36
+ onHandle(hippoRoot, (db) => commitSupersede(db, { ...write, successor }));
37
+ writeEntryMirrors(hippoRoot, successor);
38
+ },
39
+ async archiveRaw(archive) {
40
+ const archivedAt = onHandle(hippoRoot, (db) => {
41
+ const at = withWriteScope(db, 'archive_raw_in_reach', () => {
42
+ assertInReach(db, archive, archive.id);
43
+ return archiveRawMemory(db, archive.id, { reason: archive.reason, who: archive.actor });
44
+ });
45
+ cleanArchivedMirrors(db, hippoRoot, archive.id);
46
+ return at;
47
+ });
48
+ updateStatsUnlessBusy(hippoRoot, { forgotten: 1 }, `removed ${archive.id}`);
49
+ return archivedAt;
50
+ },
51
+ async forget(removal) {
52
+ onHandle(hippoRoot, (db) => withWriteScope(db, 'forget_in_reach', () => {
53
+ assertInReach(db, removal, removal.id);
54
+ if (!deleteEntryCore(db, removal.id, { actor: removal.actor }))
55
+ throw notFound(removal.id);
56
+ }), openStore);
57
+ purgeMirrorBestEffort(hippoRoot, removal.id, false, 'deleteEntry');
58
+ updateStatsUnlessBusy(hippoRoot, { forgotten: 1 }, `removed ${removal.id}`);
59
+ },
60
+ };
61
+ }
62
+ function notFound(id) {
63
+ return new NotFoundError(`memory not found: ${id}`);
64
+ }
65
+ /** Another tenant's row, or another person's personal row, answers as missing, so a caller learns nothing about it. */
66
+ function assertInReach(db, target, id) {
67
+ const reach = selectMemoryReach(db, id);
68
+ if (reach?.tenantId !== target.tenantId || !ownScopeTouches(target.ownScope, reach.scope))
69
+ throw notFound(id);
70
+ }
71
+ /** hippo.db's upsert would move the row into the writer's tenant, so a store write refuses an id another tenant holds. */
72
+ function assertIdInTenant(db, entry) {
73
+ const holder = selectMemoryReach(db, entry.id)?.tenantId;
74
+ if (holder !== undefined && holder !== entry.tenantId)
75
+ throw new ConflictError(`Memory ${entry.id} belongs to another tenant`);
76
+ }
77
+ /** The refusal row lands after the rollback, in a fresh implicit transaction the aborted one cannot undo. */
78
+ function writeInOwnTenant(db, entry, actor) {
79
+ try {
80
+ withWriteScope(db, 'write_entry_in_tenant', () => {
81
+ assertIdInTenant(db, entry);
82
+ writeEntryDbOnly(db, entry, { actor });
83
+ });
84
+ }
85
+ catch (err) {
86
+ if (err instanceof RejectedValueError)
87
+ auditRejectionRefusal(db, err, actor);
88
+ throw err;
89
+ }
90
+ }
91
+ /** The reads, rewrites and outcome rows share one BEGIN IMMEDIATE transaction, hippo.db's form of the port's row lock; the caller writes the mirrors after. */
92
+ function applyOutcomeOn(db, outcome) {
93
+ return withWriteScope(db, 'apply_outcome', () => {
94
+ const live = selectEntriesByIds(db, outcome.ids, outcome.tenantId);
95
+ const applied = [];
96
+ for (const id of outcome.ids) {
97
+ const entry = live.get(id);
98
+ if (!entry || !ownScopeTouches(outcome.ownScope, entry.scope))
99
+ continue;
100
+ const updated = entryAfterOutcome(entry, outcome.good);
101
+ writeEntryDbOnly(db, updated, { actor: outcome.actor });
102
+ live.set(id, updated);
103
+ appendAuditEvent(db, { tenantId: outcome.tenantId, actor: outcome.actor, op: 'outcome', targetId: id, metadata: { good: outcome.good } });
104
+ applied.push(updated);
105
+ }
106
+ return applied;
107
+ });
108
+ }
109
+ /** Reach, the CAS on the old row, the successor's insert and the supersede row in one BEGIN IMMEDIATE transaction. Two racing supersedes: one CAS wins. */
110
+ export function commitSupersede(db, write) {
111
+ const { tenantId, actor, oldId, successor } = write;
112
+ db.exec('BEGIN IMMEDIATE');
113
+ try {
114
+ assertInReach(db, write, oldId);
115
+ // SAFETY: RETURNING names one column, dag_parent_id; no row means another writer got there first.
116
+ const won = db.prepare('UPDATE memories SET superseded_by = ? WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL RETURNING dag_parent_id')
117
+ .get(successor.id, oldId, tenantId);
118
+ if (!won)
119
+ throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
120
+ // After the CAS, so a lost race marks no parent for rebuild.
121
+ if (won.dag_parent_id)
122
+ markSummaryDirtyInTx(db, won.dag_parent_id, tenantId, actor);
123
+ assertIdInTenant(db, successor);
124
+ writeEntryDbOnly(db, successor, { actor });
125
+ appendAuditEvent(db, { tenantId, actor, op: 'supersede', targetId: oldId, metadata: { newId: successor.id } });
126
+ db.exec('COMMIT');
127
+ }
128
+ catch (err) {
129
+ try {
130
+ db.exec('ROLLBACK');
131
+ }
132
+ catch { /* already rolled back */ }
133
+ // The refusal row lands after ROLLBACK, in a fresh implicit transaction the aborted one cannot undo.
134
+ if (err instanceof RejectedValueError)
135
+ auditRejectionRefusal(db, err, actor);
136
+ throw err;
137
+ }
138
+ }
139
+ /** A mirror left on disk would bring the archived row back on the next import; on failure the reaper retries, as mirror_cleaned_at stays NULL. */
140
+ export function cleanArchivedMirrors(db, hippoRoot, id) {
141
+ try {
142
+ removeEntryMirrors(hippoRoot, id);
143
+ }
144
+ catch (mirrorErr) {
145
+ log.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
146
+ return;
147
+ }
148
+ try {
149
+ db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
150
+ }
151
+ catch (err) {
152
+ if (!isSqliteBusy(err))
153
+ throw err;
154
+ log.warnThenDebug('archive-mirror-stamp-busy', `archived ${id}; the store was busy, so the mirror reaper will re-check it on the next open`);
155
+ }
156
+ }
157
+ //# sourceMappingURL=entry-writes-group.js.map
@@ -23,7 +23,10 @@ export declare function loadHandoffById(hippoRoot: string, tenantId: string, id:
23
23
  export declare function stampHandoffOutcome(hippoRoot: string, tenantId: string, sessionId: string, outcome: HandoffOutcome): number;
24
24
  /** Auto-write a handoff at session-end from the session's active snapshot, else from `derived`, its transcript state.
25
25
  * @param evidence best-effort git state; outcome comes from the newest session_complete event.
26
- * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript or already holds `derived`. */
27
- export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null, key?: ContinuityKey): SessionHandoff | null;
26
+ * @param options.inPlace rewrite an earlier transcript read instead of adding a revision, for a close that runs after every reply.
27
+ * @returns null when neither source is the session's, a newer handoff covers the snapshot, the session's latest handoff was not read off its transcript or already holds `derived`, or, in place, a handoff landed after the read. */
28
+ export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null, key?: ContinuityKey, options?: {
29
+ inPlace?: boolean;
30
+ }): SessionHandoff | null;
28
31
  export {};
29
32
  //# sourceMappingURL=handoffs.d.ts.map
@@ -128,10 +128,32 @@ export function stampHandoffOutcome(hippoRoot, tenantId, sessionId, outcome) {
128
128
  closeHippoDb(db);
129
129
  }
130
130
  }
131
+ /** Rewrites the session's newest handoff when it is still a transcript read, in one statement so a handoff written meanwhile is never overwritten; null when none was rewritten. */
132
+ function replaceTranscriptHandoff(hippoRoot, tenantId, handoff, key) {
133
+ const { conditions, params } = handoffConditions(tenantId, handoff.sessionId, {}, key);
134
+ const newest = `SELECT id FROM session_handoffs WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC, id DESC LIMIT 1`;
135
+ const db = openStore(hippoRoot);
136
+ try {
137
+ const result = db.prepare(`
138
+ UPDATE session_handoffs SET repo_root = ?, task_id = ?, summary = ?, next_action = ?, artifacts_json = ?, scope = ?, created_at = ?,
139
+ constraints_json = ?, evidence_json = ?, outcome = ?, target_runtime = ?, card_id = ?
140
+ WHERE id = (${newest}) AND json_valid(evidence_json) AND json_extract(evidence_json, '$.derivedFrom') = 'transcript'
141
+ `).run(handoff.repoRoot ?? null, handoff.taskId ?? null, handoff.summary, handoff.nextAction ?? null, JSON.stringify(handoff.artifacts ?? []), handoff.scope ?? null, new Date().toISOString(), JSON.stringify(handoff.constraints ?? []), handoff.evidence ? JSON.stringify(handoff.evidence) : null, handoff.outcome ?? null, handoff.targetRuntime ?? null, handoff.cardId ?? null, ...params);
142
+ if (Number(result.changes ?? 0) === 0)
143
+ return null;
144
+ // SAFETY: row's shape matches HANDOFF_COLUMNS.
145
+ const row = db.prepare(`SELECT ${HANDOFF_COLUMNS} FROM session_handoffs WHERE id = (${newest})`).get(...params);
146
+ return row ? rowToSessionHandoff(row) : null;
147
+ }
148
+ finally {
149
+ closeHippoDb(db);
150
+ }
151
+ }
131
152
  /** Auto-write a handoff at session-end from the session's active snapshot, else from `derived`, its transcript state.
132
153
  * @param evidence best-effort git state; outcome comes from the newest session_complete event.
133
- * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript or already holds `derived`. */
134
- export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null, key) {
154
+ * @param options.inPlace rewrite an earlier transcript read instead of adding a revision, for a close that runs after every reply.
155
+ * @returns null when neither source is the session's, a newer handoff covers the snapshot, the session's latest handoff was not read off its transcript or already holds `derived`, or, in place, a handoff landed after the read. */
156
+ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null, key, options = {}) {
135
157
  assertTenantId('writeSessionEndHandoff', tenantId);
136
158
  if (key)
137
159
  continuityStamp(key); // a partial key is a caller bug, so fail loud rather than write nothing
@@ -139,7 +161,10 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
139
161
  const existing = loadLatestHandoff(hippoRoot, tenantId, sessionId, {}, key);
140
162
  let snapshot;
141
163
  let handoffEvidence = evidence;
142
- if (active && active.session_id === sessionId) {
164
+ let fromTranscript = false;
165
+ // After every reply the transcript is newer than the last compaction's snapshot, which the open chat keeps for the next one.
166
+ const snapshotGivesWay = options.inPlace === true && derived !== null && active?.source === 'pre-compact';
167
+ if (active && active.session_id === sessionId && !snapshotGivesWay) {
143
168
  // Strict '>': a same-millisecond tie must not swallow the session's only write (test 6e).
144
169
  if (existing && existing.updatedAt > active.updated_at)
145
170
  return null;
@@ -154,13 +179,14 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
154
179
  return null;
155
180
  snapshot = { ...derived, scope: null };
156
181
  handoffEvidence = { ...evidence, derivedFrom: 'transcript' };
182
+ fromTranscript = true;
157
183
  }
158
184
  const outcome = sessionOutcome(hippoRoot, tenantId, sessionId);
159
185
  // A same-task refresh carries forward envelope fields nobody cleared; a scope
160
186
  // mismatch must not leak private metadata into an unscoped envelope.
161
187
  const carryForward = existing != null && existing.taskId === snapshot.task
162
188
  && (existing.scope ?? null) === (snapshot.scope ?? null);
163
- return saveSessionHandoff(hippoRoot, tenantId, {
189
+ const handoff = {
164
190
  version: 1,
165
191
  sessionId,
166
192
  repoRoot: carryForward ? existing.repoRoot : undefined,
@@ -174,7 +200,11 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
174
200
  constraints: carryForward ? existing.constraints : undefined,
175
201
  targetRuntime: carryForward ? existing.targetRuntime : undefined,
176
202
  cardId: carryForward ? existing.cardId : undefined,
177
- }, key);
203
+ };
204
+ // A miss means a handoff landed since the read, and a new transcript row would hide it.
205
+ if (fromTranscript && options.inPlace && existing)
206
+ return replaceTranscriptHandoff(hippoRoot, tenantId, handoff, key);
207
+ return saveSessionHandoff(hippoRoot, tenantId, handoff, key);
178
208
  }
179
209
  function sessionOutcome(hippoRoot, tenantId, sessionId) {
180
210
  const db = openHippoDb(hippoRoot);
@@ -9,6 +9,8 @@ export declare function initStore(hippoRoot: string): void;
9
9
  export declare function openStore(hippoRoot: string, opts?: {
10
10
  busyWaitMs?: number;
11
11
  }): DatabaseSyncLike;
12
+ /** One call on a handle of its own, closed after; pass openStore where the call also sets up mirror folders and legacy rows. */
13
+ export declare function onHandle<T>(hippoRoot: string, fn: (db: DatabaseSyncLike) => T, open?: (hippoRoot: string) => DatabaseSyncLike): T;
12
14
  /** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
13
15
  export declare const HALF_LIFE_BASE_META_KEY = "default_half_life_base";
14
16
  /** `meta` key set once no memory of a decision, incident or other object sits on the old flat 90 days. */
@@ -46,6 +46,16 @@ export function openStore(hippoRoot, opts) {
46
46
  throw error;
47
47
  }
48
48
  }
49
+ /** One call on a handle of its own, closed after; pass openStore where the call also sets up mirror folders and legacy rows. */
50
+ export function onHandle(hippoRoot, fn, open = openHippoDb) {
51
+ const db = open(hippoRoot);
52
+ try {
53
+ return fn(db);
54
+ }
55
+ finally {
56
+ closeHippoDb(db);
57
+ }
58
+ }
49
59
  /** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
50
60
  export const HALF_LIFE_BASE_META_KEY = 'default_half_life_base';
51
61
  /** `meta` key set once no memory of a decision, incident or other object sits on the old flat 90 days. */
@@ -2,7 +2,7 @@ import { envForceLikePath } from '../env.js';
2
2
  import { isFtsAvailable, closeHippoDb } from '../db.js';
3
3
  import { tokenize } from '../tokenize.js';
4
4
  import { isPersonalScope, scopeAdmitSql } from '../recall-scope.js';
5
- import { RAREST_TERM_COUNT, rarestPromptTerms } from '../prompt-recall.js';
5
+ import { ftsTermParts, RAREST_TERM_COUNT, rarestFtsQuery } from '../prompt-recall.js';
6
6
  import { log } from '../log.js';
7
7
  import { originInSql } from '../project-identity.js';
8
8
  import { topVectorMatches } from '../vector-store.js';
@@ -210,9 +210,7 @@ export function pickRarestFtsQuery(db, terms, maxTerms = RAREST_TERM_COUNT) {
210
210
  if (!isFtsAvailable(db))
211
211
  return terms.slice(0, 32).join(' ');
212
212
  db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS temp.z1_rarest_vocab USING fts5vocab(main, 'memories_fts', 'row')`);
213
- // unicode61 splits `journal_mode` into two vocab terms; a term's count is its rarest part's (an upper bound).
214
- const partsOf = (t) => t.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
215
- const vocab = Array.from(new Set(terms.flatMap(partsOf)));
213
+ const vocab = Array.from(new Set(terms.flatMap(ftsTermParts)));
216
214
  if (vocab.length === 0)
217
215
  return '';
218
216
  // SAFETY: rows' shape matches the two columns named in the SELECT.
@@ -220,10 +218,6 @@ export function pickRarestFtsQuery(db, terms, maxTerms = RAREST_TERM_COUNT) {
220
218
  .prepare(`SELECT term, doc FROM temp.z1_rarest_vocab WHERE term IN (${vocab.map(() => '?').join(', ')})`)
221
219
  .all(...vocab);
222
220
  const counts = new Map(rows.map((r) => [r.term, r.doc]));
223
- const docCount = (t) => {
224
- const parts = partsOf(t);
225
- return parts.length === 0 ? 0 : Math.min(...parts.map((x) => counts.get(x) ?? 0));
226
- };
227
- return rarestPromptTerms(terms, docCount, maxTerms).join(' ');
221
+ return rarestFtsQuery(terms, (part) => counts.get(part) ?? 0, maxTerms);
228
222
  }
229
223
  //# sourceMappingURL=search-rows.js.map
@@ -60,6 +60,11 @@ export declare function loadFreshActiveTaskSnapshot(hippoRoot: string, tenantId:
60
60
  maxAgeMs?: number;
61
61
  sessionId?: string | null;
62
62
  }, key?: ContinuityKey): TaskSnapshot | null;
63
+ /** `loadFreshActiveTaskSnapshot`'s owner-match-or-age rule, for a snapshot a store read. */
64
+ export declare function freshActiveSnapshot(snapshot: TaskSnapshot | null, opts?: {
65
+ maxAgeMs?: number;
66
+ sessionId?: string | null;
67
+ }): TaskSnapshot | null;
63
68
  export declare function clearActiveTaskSnapshot(hippoRoot: string, tenantId: string, clearedStatus?: string): boolean;
64
69
  /**
65
70
  * Close the `active` task snapshot(s) owned by `sessionId`, for the
@@ -142,7 +142,10 @@ function isNonEmptySessionId(value) {
142
142
  * No SQL change — age derives from the existing `updated_at` column.
143
143
  */
144
144
  export function loadFreshActiveTaskSnapshot(hippoRoot, tenantId, opts = {}, key) {
145
- const snapshot = loadActiveTaskSnapshot(hippoRoot, tenantId, key);
145
+ return freshActiveSnapshot(loadActiveTaskSnapshot(hippoRoot, tenantId, key), opts);
146
+ }
147
+ /** `loadFreshActiveTaskSnapshot`'s owner-match-or-age rule, for a snapshot a store read. */
148
+ export function freshActiveSnapshot(snapshot, opts = {}) {
146
149
  if (!snapshot)
147
150
  return null;
148
151
  const callerSessionId = opts.sessionId;
@@ -1,13 +1,17 @@
1
+ import type { AmbientTallies } from './ambient.js';
2
+ import { type AmbientStoreFilter } from './ambient-store.js';
1
3
  import { type ApiKeyListRow, type ApiKeyRecord, type ListApiKeysOpts, type NewApiKey } from './auth.js';
2
4
  import { type AppendAuditOpts, type AuditEvent, type ListAuditAfterOpts } from './audit.js';
3
5
  import type { ContinuityBlock } from './api/recall-types.js';
4
6
  import { type EmbeddingIndexState } from './embeddings.js';
5
7
  import { type ActiveGoals, type GetActiveGoalsOpts, type GoalRecallLogRow } from './goals.js';
8
+ import type { SessionHandoff } from './handoff.js';
6
9
  import type { JsonValue } from './json.js';
7
10
  import type { MemoryEntry } from './memory.js';
8
11
  import type { PhysicsParticle } from './physics.js';
9
12
  import { type PlanningFallacyEvidence } from './predictions/planning-fallacy.js';
10
13
  import { type RecallTraceInput } from './recall-trace.js';
14
+ import { type AmbientLoadResult, type AmbientRecallRequest, type ContextCandidateFilter, type RecentOrigins } from './store/candidates.js';
11
15
  import { type StrengthenOptions } from './store/entry-writes.js';
12
16
  import { type OriginFilter, type VectorCandidateSpec } from './store/search-rows.js';
13
17
  import { type ContinuityKey } from './store/sessions.js';
@@ -102,6 +106,71 @@ export interface KeyAudit {
102
106
  /** The highest audit id ever assigned, pruned rows included, 0 before the first; a tail that sees it drop knows the log was restored. */
103
107
  auditHighId(): Promise<number>;
104
108
  }
109
+ /** One ambient load: the pins, the `recentNeeded` newest rows `admit` keeps, and the prompt-recall candidates. */
110
+ export interface AmbientCandidateRequest {
111
+ readonly recentNeeded: number;
112
+ /** Sees each row in the order `loadAmbientCandidates` reads them, since the delivery ledger counts its refusals. */
113
+ readonly admit: (e: MemoryEntry) => boolean;
114
+ readonly recall?: AmbientRecallRequest;
115
+ readonly origins?: RecentOrigins;
116
+ }
117
+ /** The reads behind getContext beyond the base `continuity`: its fallback handoff, candidate rows and ambient tallies.
118
+ * getContext with a query under an embedding provider needs `vectors` too, and answers 501 without it. */
119
+ export interface ContextReads {
120
+ /** The newest handoff of the last `maxAgeMs` that is its session's highest id, has no outcome or a partial or failed one, and
121
+ * passes recall's default scope deny, as `loadLatestHandoff` with `unfinishedOnly` and `scopeFilter: 'default-deny'`. Rejects an empty tenant id. */
122
+ unfinishedHandoff(tenantId: string, maxAgeMs: number, key: ContinuityKey | null): Promise<SessionHandoff | null>;
123
+ /** As `loadAmbientCandidates`; `recall` searches with the query `rarestFtsQuery` builds from FTS document counts over every tenant's rows,
124
+ * and finds nothing when no term is indexed. Core keeps only `tenantId`'s rows that its own admit passes, whatever the store returns. */
125
+ ambientCandidates(tenantId: string, request: AmbientCandidateRequest): Promise<AmbientLoadResult>;
126
+ /** As `loadContextCandidates`: at most `filter.cap` live rows, pins and then the least decayed first, returned by created, then id. */
127
+ contextCandidates(tenantId: string, filter: ContextCandidateFilter): Promise<MemoryEntry[]>;
128
+ /** As `loadAmbientTallies`: one aggregate over the live, unarchived rows `filter` admits, less other projects' secret-tagged rows. */
129
+ ambientTallies(tenantId: string, filter: AmbientStoreFilter): Promise<AmbientTallies>;
130
+ }
131
+ export interface EntryWrite {
132
+ readonly entry: MemoryEntry;
133
+ readonly actor: string;
134
+ }
135
+ /** Who acts on which of a tenant's rows; `ownScope` is the caller's personal scope, so every other personal row is out of reach. */
136
+ export interface EntryTarget {
137
+ readonly tenantId: string;
138
+ readonly actor: string;
139
+ readonly ownScope: string | null;
140
+ }
141
+ export interface OutcomeWrite extends EntryTarget {
142
+ readonly ids: readonly string[];
143
+ readonly good: boolean;
144
+ }
145
+ export interface SupersedeWrite extends EntryTarget {
146
+ readonly oldId: string;
147
+ readonly successor: MemoryEntry;
148
+ }
149
+ export interface EntryRemoval extends EntryTarget {
150
+ readonly id: string;
151
+ }
152
+ export interface RawArchive extends EntryRemoval {
153
+ readonly reason: string;
154
+ }
155
+ /** The memory-row writes behind remember, outcome, supersede, archive and forget. Each writes its audit rows in the write's own transaction,
156
+ * a child row's change marks its level 2 or 3 summary parent dirty (one summary_marked_dirty row on the flip), and the schema's rules hold. */
157
+ export interface EntryWrites {
158
+ /** Upserts the row, its full-text row and one remember row ({kind, scope}) in the entry's tenant; an id another tenant holds rejects in the transaction with ConflictError `Memory <id>
159
+ * belongs to another tenant`. Content the write brings into a tenant that tombstoned it rejects with RejectedValueError and writes one reject_refusal row after the rollback, best effort. */
160
+ writeEntry(write: EntryWrite): Promise<void>;
161
+ /** For each id in order, a row of the tenant within reach gets `entryAfterOutcome`, a rewrite as writeEntry does and one outcome row ({good}); other ids are skipped, a repeated id builds
162
+ * on its first outcome. Each read holds the row's lock to commit (SELECT ... FOR UPDATE), so a supersede between read and rewrite is not undone. Resolves to the ids applied, repeats kept. */
163
+ applyOutcome(outcome: OutcomeWrite): Promise<string[]>;
164
+ /** Sets the old row's superseded_by where it is within reach and not yet superseded, both checked in the transaction, else rejects with NotFoundError `memory not found: <oldId>` or
165
+ * ConflictError `Memory <oldId> already superseded by another writer`. Then writes the successor as writeEntry does and one supersede row ({newId}), all or nothing. */
166
+ supersede(write: SupersedeWrite): Promise<void>;
167
+ /** Moves a raw row to raw_archive (metadata only, no content), deletes it and its full-text row and writes one archive_raw row ({reason});
168
+ * resolves to the archived_at written. A row out of reach rejects with NotFoundError `memory not found: <id>`, any other kind with BadRequestError. */
169
+ archiveRaw(archive: RawArchive): Promise<string>;
170
+ /** Deletes the row and its full-text row and writes one forget row; a raw row rejects, being append-only. A row out of reach rejects with
171
+ * NotFoundError `memory not found: <id>`. Archive and forget then add one to the store-wide forgotten counter, best effort. */
172
+ forget(removal: EntryRemoval): Promise<void>;
173
+ }
105
174
  /** A key to insert and the actor and metadata of its auth_create row, whose tenant and target are the key's. Neither holds the plaintext. */
106
175
  export interface KeyMint {
107
176
  readonly key: NewApiKey;
@@ -139,6 +208,8 @@ export interface StoreGroups {
139
208
  readonly keyWrites: KeyWrites;
140
209
  /** embedMemory and embedAll with a store need it and `vectors`. */
141
210
  readonly vectorWrites: VectorWrites;
211
+ readonly entryWrites: EntryWrites;
212
+ readonly contextReads: ContextReads;
142
213
  }
143
214
  export type StoreGroup = 'base' | keyof StoreGroups;
144
215
  export declare function hasGroup(store: HippoStore, group: StoreGroup): boolean;