hippo-memory 1.56.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +11 -0
  2. package/dist/agent-memories/claude-code.js +1 -1
  3. package/dist/agent-memories/gemini.js +1 -1
  4. package/dist/api-errors.d.ts +27 -0
  5. package/dist/api-errors.js +37 -0
  6. package/dist/api.d.ts +21 -14
  7. package/dist/api.js +97 -71
  8. package/dist/audit.d.ts +4 -0
  9. package/dist/audit.js +11 -0
  10. package/dist/autolearn.d.ts +1 -1
  11. package/dist/autolearn.js +7 -5
  12. package/dist/capture-contract.d.ts +47 -0
  13. package/dist/capture-contract.js +49 -0
  14. package/dist/capture-error.js +2 -1
  15. package/dist/capture.d.ts +0 -13
  16. package/dist/capture.js +5 -66
  17. package/dist/card-detail.d.ts +1 -1
  18. package/dist/card-detail.js +1 -1
  19. package/dist/cli/shared.d.ts +137 -0
  20. package/dist/cli/shared.js +834 -0
  21. package/dist/cli/sleep.d.ts +10 -0
  22. package/dist/cli/sleep.js +171 -0
  23. package/dist/cli.d.ts +0 -7
  24. package/dist/cli.js +322 -1827
  25. package/dist/client.js +9 -0
  26. package/dist/codex-patch.js +1 -1
  27. package/dist/compaction-record.d.ts +1 -1
  28. package/dist/compaction-record.js +3 -2
  29. package/dist/config.d.ts +5 -0
  30. package/dist/config.js +17 -0
  31. package/dist/connectors/github/dlq.js +5 -2
  32. package/dist/connectors/github/octokit-client.js +4 -2
  33. package/dist/connectors/github/webhook.d.ts +19 -0
  34. package/dist/connectors/github/webhook.js +313 -0
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/connectors/slack/webhook.d.ts +22 -0
  38. package/dist/connectors/slack/webhook.js +203 -0
  39. package/dist/consolidate.d.ts +10 -0
  40. package/dist/consolidate.js +38 -35
  41. package/dist/context-auto.d.ts +3 -0
  42. package/dist/context-auto.js +34 -0
  43. package/dist/customer-notes.js +16 -14
  44. package/dist/dag.js +3 -2
  45. package/dist/dashboard.js +3 -2
  46. package/dist/db.d.ts +12 -0
  47. package/dist/db.js +62 -1
  48. package/dist/decisions.js +11 -9
  49. package/dist/doctor.js +5 -0
  50. package/dist/embedding-provider.js +3 -3
  51. package/dist/embeddings.d.ts +4 -4
  52. package/dist/embeddings.js +72 -16
  53. package/dist/eval-stats.d.ts +58 -0
  54. package/dist/eval-stats.js +111 -0
  55. package/dist/extract.js +3 -2
  56. package/dist/goals.d.ts +49 -25
  57. package/dist/goals.js +39 -22
  58. package/dist/graph-extract.js +1 -1
  59. package/dist/graph-recall.d.ts +1 -1
  60. package/dist/graph-recall.js +1 -1
  61. package/dist/graph.js +1 -1
  62. package/dist/hooks.d.ts +1 -3
  63. package/dist/hooks.js +2 -4
  64. package/dist/http-retry.d.ts +21 -0
  65. package/dist/http-retry.js +50 -0
  66. package/dist/http-util.d.ts +39 -0
  67. package/dist/http-util.js +56 -0
  68. package/dist/importers.d.ts +2 -0
  69. package/dist/importers.js +16 -5
  70. package/dist/incidents.js +13 -11
  71. package/dist/index.d.ts +5 -2
  72. package/dist/index.js +5 -2
  73. package/dist/judgment.js +10 -17
  74. package/dist/log.d.ts +25 -0
  75. package/dist/log.js +48 -0
  76. package/dist/mcp/server.js +224 -308
  77. package/dist/mcp/tool-args.d.ts +21 -0
  78. package/dist/mcp/tool-args.js +80 -0
  79. package/dist/memory.d.ts +19 -0
  80. package/dist/memory.js +41 -2
  81. package/dist/overlap-index.d.ts +7 -0
  82. package/dist/overlap-index.js +38 -0
  83. package/dist/pilot-arm.d.ts +9 -0
  84. package/dist/pilot-arm.js +47 -0
  85. package/dist/policies.js +14 -12
  86. package/dist/predictions.js +11 -9
  87. package/dist/processes.js +16 -14
  88. package/dist/project-briefs.js +19 -16
  89. package/dist/project-identity.d.ts +1 -1
  90. package/dist/project-identity.js +25 -1
  91. package/dist/prompt-recall.js +1 -1
  92. package/dist/raw-archive.js +7 -6
  93. package/dist/recall-history.d.ts +5 -0
  94. package/dist/recall-history.js +9 -0
  95. package/dist/recall-pipeline.d.ts +101 -0
  96. package/dist/recall-pipeline.js +313 -0
  97. package/dist/recall-scope.d.ts +24 -1
  98. package/dist/recall-scope.js +29 -2
  99. package/dist/refine-llm.js +3 -2
  100. package/dist/reject-flow.js +6 -9
  101. package/dist/rejection.d.ts +2 -1
  102. package/dist/rejection.js +2 -1
  103. package/dist/search.d.ts +0 -20
  104. package/dist/search.js +16 -51
  105. package/dist/secret-detect.d.ts +13 -1
  106. package/dist/secret-detect.js +33 -1
  107. package/dist/server.d.ts +3 -1
  108. package/dist/server.js +1854 -2566
  109. package/dist/session-digest.js +2 -1
  110. package/dist/shared.js +7 -6
  111. package/dist/skills.js +17 -15
  112. package/dist/store-cards.d.ts +53 -0
  113. package/dist/store-cards.js +512 -0
  114. package/dist/store.d.ts +2 -89
  115. package/dist/store.js +10 -566
  116. package/dist/tenant.d.ts +22 -0
  117. package/dist/tenant.js +26 -0
  118. package/dist/token-ledger.d.ts +4 -2
  119. package/dist/token-ledger.js +2 -2
  120. package/dist/tokenize.d.ts +2 -0
  121. package/dist/tokenize.js +8 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  125. package/extensions/openclaw-plugin/package.json +1 -1
  126. package/openclaw.plugin.json +1 -1
  127. package/package.json +1 -1
  128. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  129. package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/store.js CHANGED
@@ -6,15 +6,16 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { Layer, generateId, AUTO_DELETABLE_SQL, DEFAULT_HALF_LIFE_DAYS } from './memory.js';
9
+ import { Layer, AUTO_DELETABLE_SQL, DEFAULT_HALF_LIFE_DAYS, markRetrieved } from './memory.js';
10
10
  import { dumpFrontmatter, parseFrontmatter } from './yaml.js';
11
11
  import { openHippoDb, closeHippoDb, getMeta, setMeta, isFtsAvailable, pruneConsolidationRuns, getHippoDbPath, } from './db.js';
12
12
  import { rowToSessionHandoff, isHandoffOutcome } from './handoff.js';
13
- import { CARD_TRANSITIONS, CARD_LEASE_MS } from './card.js';
14
- import { tokenize, markRetrieved } from './search.js';
13
+ import { tokenize } from './tokenize.js';
14
+ import { RECALL_DEFAULT_DENY_SCOPES } from './recall-scope.js';
15
+ import { assertTenantId } from './tenant.js';
15
16
  import { isRecallBoostAblated } from './ablation.js';
16
17
  import { rarestPromptTerms, RAREST_TERM_COUNT } from './prompt-recall.js';
17
- import { appendAuditEvent } from './audit.js';
18
+ import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
18
19
  import { resolveTenantId } from './tenant.js';
19
20
  import { redactSecretsStrict } from './secret-detect.js';
20
21
  import { deriveOriginProject, originFromSource, findHippoStoreDir, realpathOrResolve } from './project-identity.js';
@@ -41,9 +42,9 @@ function audit(db, op, targetId, metadata, actor = 'cli', tenantId) {
41
42
  metadata,
42
43
  });
43
44
  }
44
- catch {
45
- // Audit must never crash a mutation. Failures here mean the audit_log
46
- // table is broken; the mutation has already succeeded.
45
+ catch (error) {
46
+ // The mutation has already succeeded; a broken audit table must not undo it.
47
+ reportAuditWriteFailure(op, String(error), targetId);
47
48
  }
48
49
  }
49
50
  /**
@@ -70,33 +71,6 @@ const MEMORY_SEARCH_COLUMNS = `m.id AS id, m.created AS created, m.last_retrieve
70
71
  * this for `RecallResult.windowSize` reporting so the two cannot drift.
71
72
  */
72
73
  export const DEFAULT_SEARCH_CANDIDATE_LIMIT = 200;
73
- /**
74
- * v1.7.2 — literal scopes excluded from recall by default-deny when the
75
- * caller passes no `scope`. The SQL clause in `loadSearchRows` and the JS
76
- * helper `passesScopeFilterForRecall` (src/api.ts) both read from this
77
- * constant. Adding a deny scope is a one-place change.
78
- *
79
- * Regex-based denies (e.g. `<source>:private:*`) stay in
80
- * `passesScopeFilterForRecall` as a separate JS step — they don't translate
81
- * cleanly to SQL.
82
- *
83
- * Invariant: never empty. An empty array would silently allow quarantine
84
- * scopes through both paths (SQL clause omitted, JS check vacuous). The
85
- * module-load assertion below pins this loudly.
86
- */
87
- export const RECALL_DEFAULT_DENY_SCOPES = ['unknown:legacy'];
88
- /**
89
- * @internal v1.7.3 — runtime guard against a future maintainer blanking a
90
- * load-bearing literal array. Extracted from the inline guard so the throw
91
- * path is directly testable. `as const` arrays widen via `readonly T[]` at
92
- * the call site so the empty case is reachable at runtime.
93
- */
94
- export function assertNonEmpty(arr, name) {
95
- if (arr.length === 0) {
96
- throw new Error(`${name} cannot be empty — would silently allow quarantine scopes`);
97
- }
98
- }
99
- assertNonEmpty(RECALL_DEFAULT_DENY_SCOPES, 'RECALL_DEFAULT_DENY_SCOPES');
100
74
  function layerDir(root, layer) {
101
75
  return path.join(root, layer);
102
76
  }
@@ -2167,32 +2141,6 @@ export function incrementSleepCount(hippoRoot) {
2167
2141
  closeHippoDb(db);
2168
2142
  }
2169
2143
  }
2170
- /**
2171
- * Defensive runtime guard for tenant id arguments.
2172
- *
2173
- * The continuity helpers (saveActiveTaskSnapshot, listSessionEvents, etc.)
2174
- * gained a required `tenantId` parameter in v0.41 / schema v22 to close a
2175
- * cross-tenant data leak. TypeScript catches misbinding at compile time, but
2176
- * JavaScript callers from older versions can silently pass a `sessionId`
2177
- * where `tenantId` is now expected, e.g.
2178
- * loadLatestHandoff(root, 'sess-abc') // WRONG: 'sess-abc' becomes the tenant
2179
- * which would silently filter to a non-existent tenant and return null with
2180
- * no error. This guard rejects the most common shape of that mistake (any
2181
- * value beginning with the conventional `sess-` / `sess_` session prefix).
2182
- *
2183
- * False-positive cost: a tenant literally named `sess-...` will be rejected.
2184
- * Acceptable tradeoff for catching the silent-leak class.
2185
- */
2186
- export function assertTenantId(fnName, value) {
2187
- if (typeof value !== 'string' || value.length === 0) {
2188
- throw new Error(`${fnName}: tenantId is required (got ${typeof value})`);
2189
- }
2190
- if (/^sess[-_]/i.test(value)) {
2191
- throw new Error(`${fnName}: tenantId looks like a session id ('${value}'). ` +
2192
- `In v0.41+ these helpers take (hippoRoot, tenantId, ...). ` +
2193
- `Pass the tenant id (e.g. 'default') and the session id separately.`);
2194
- }
2195
- }
2196
2144
  export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
2197
2145
  assertTenantId('saveActiveTaskSnapshot', tenantId);
2198
2146
  const db = openStore(hippoRoot);
@@ -2849,7 +2797,8 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
2849
2797
  }
2850
2798
  // W1: the nine-column SELECT was cloned four times (plan rule 8); one
2851
2799
  // definition so a sixth caller can't drift from the other five.
2852
- const HANDOFF_COLUMNS = 'id, session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id';
2800
+ /** Column list shared by every session_handoffs SELECT; store-cards.ts reuses it for the card handoff lookup. */
2801
+ export const HANDOFF_COLUMNS = 'id, session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id';
2853
2802
  /**
2854
2803
  * Save a session handoff record. Returns the persisted handoff.
2855
2804
  */
@@ -3023,511 +2972,6 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
3023
2972
  cardId: carryForward ? existing.cardId : undefined,
3024
2973
  });
3025
2974
  }
3026
- const CARD_COLUMNS = 'id, title, status, assignee_runtime, repo, contract, budget, lease_until, heartbeat_at, created_at, updated_at, tenant_id, scope';
3027
- function rowToCard(row) {
3028
- return {
3029
- id: row.id,
3030
- title: row.title,
3031
- status: row.status,
3032
- assigneeRuntime: row.assignee_runtime,
3033
- repo: row.repo,
3034
- contract: row.contract,
3035
- budget: row.budget,
3036
- leaseUntil: row.lease_until,
3037
- heartbeatAt: row.heartbeat_at,
3038
- createdAt: row.created_at,
3039
- updatedAt: row.updated_at,
3040
- tenantId: row.tenant_id,
3041
- scope: row.scope,
3042
- };
3043
- }
3044
- function loadCardRow(db, tenantId, id) {
3045
- // SAFETY: row's shape matches CARD_COLUMNS; status only ever holds a CardStatus value.
3046
- const row = db.prepare(`SELECT ${CARD_COLUMNS} FROM cards WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
3047
- return row ? rowToCard(row) : null;
3048
- }
3049
- function rowToCardRun(row) {
3050
- return {
3051
- id: row.id,
3052
- card: row.card,
3053
- runtime: row.runtime,
3054
- sessionId: row.session_id,
3055
- started: row.started,
3056
- ended: row.ended,
3057
- outcome: row.outcome,
3058
- };
3059
- }
3060
- function rowToCardComment(row) {
3061
- return { id: row.id, cardId: row.card_id, author: row.author, body: row.body, createdAt: row.created_at };
3062
- }
3063
- function insertCardComment(db, tenantId, cardId, author, body) {
3064
- const now = new Date().toISOString();
3065
- const result = db.prepare(`
3066
- INSERT INTO card_comments (card_id, author, body, created_at, tenant_id)
3067
- SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM cards WHERE id = ? AND tenant_id = ?)
3068
- `).run(cardId, author, body, now, tenantId, cardId, tenantId);
3069
- if (Number(result.changes ?? 0) === 0) {
3070
- throw new Error(`unknown card id: ${cardId}`);
3071
- }
3072
- const id = Number(result.lastInsertRowid ?? 0);
3073
- return { id, cardId, author, body, createdAt: now };
3074
- }
3075
- function leaseUntilFrom(now) {
3076
- return new Date(Date.parse(now) + CARD_LEASE_MS).toISOString();
3077
- }
3078
- function assertRunId(runId) {
3079
- if (!Number.isSafeInteger(runId) || runId <= 0) {
3080
- throw new Error(`Invalid run id: ${runId} (expected a positive integer)`);
3081
- }
3082
- }
3083
- // A second run that has not ended means a corrupt store; throw rather than guess which run the caller means.
3084
- function isLiveRun(db, tenantId, cardId, runId) {
3085
- // SAFETY: rows' shape matches the single `id` column named in the SELECT below.
3086
- const rows = db.prepare(`SELECT id FROM card_runs WHERE tenant_id = ? AND card = ? AND ended IS NULL`).all(tenantId, cardId);
3087
- if (rows.length > 1) {
3088
- throw new Error(`card ${cardId} has ${rows.length} runs that have not ended`);
3089
- }
3090
- return rows[0]?.id === runId;
3091
- }
3092
- function closeLiveRun(db, tenantId, cardId, outcome, now) {
3093
- db.prepare(`
3094
- UPDATE card_runs SET ended = ?, outcome = ?, updated_at = ?
3095
- WHERE card = ? AND tenant_id = ? AND ended IS NULL
3096
- `).run(now, outcome, now, cardId, tenantId);
3097
- }
3098
- // The single status-mutating seam (rule 15): CARD_TRANSITIONS is the one
3099
- // runtime authority, so a hand-copied wrong `from` list fails fast here.
3100
- export function transitionCard(db, tenantId, cardId, from, to, extra) {
3101
- for (const status of from) {
3102
- if (!CARD_TRANSITIONS[status].includes(to)) {
3103
- throw new Error(`illegal card transition: ${status} -> ${to}`);
3104
- }
3105
- }
3106
- const now = new Date().toISOString();
3107
- // Lease columns follow status: set on the move to running, cleared on every other move (rule 15).
3108
- const lease = to === 'running' ? [leaseUntilFrom(now), now] : [null, null];
3109
- const fromPlaceholders = from.map(() => '?').join(', ');
3110
- const sql = `
3111
- UPDATE cards SET status = ?, updated_at = ?, lease_until = ?, heartbeat_at = ?${extra?.setSql ? `, ${extra.setSql}` : ''}
3112
- WHERE id = ? AND tenant_id = ? AND status IN (${fromPlaceholders})${extra?.whereSql ? ` AND ${extra.whereSql}` : ''}
3113
- `;
3114
- const params = [to, now, ...lease, ...(extra?.params ?? []), cardId, tenantId, ...from];
3115
- const result = db.prepare(sql).run(...params);
3116
- return Number(result.changes ?? 0);
3117
- }
3118
- /** Creates a card; status is ready with no deps or once every dependsOn id is done, else backlog. An unknown dependsOn id throws and commits nothing. A repeated dependsOn id is recorded once. */
3119
- export function createCard(hippoRoot, tenantId, input) {
3120
- assertTenantId('createCard', tenantId);
3121
- if (input.title.trim() === '') {
3122
- throw new Error('title must not be empty');
3123
- }
3124
- if (input.budget !== undefined && !(Number.isSafeInteger(input.budget) && input.budget > 0)) {
3125
- throw new Error(`Invalid budget: ${input.budget} (expected a positive integer)`);
3126
- }
3127
- const db = openStore(hippoRoot);
3128
- try {
3129
- const dependsOn = [...new Set(input.dependsOn ?? [])];
3130
- let id = '';
3131
- db.exec('BEGIN IMMEDIATE');
3132
- try {
3133
- // Probe runs inside the transaction (mirrors batchWriteAndDelete): a parent
3134
- // completing between an outside-the-lock read and the INSERT would strand the child.
3135
- let allParentsDone = true;
3136
- if (dependsOn.length > 0) {
3137
- const placeholders = dependsOn.map(() => '?').join(', ');
3138
- // SAFETY: rows' shape matches the two columns named in the SELECT below.
3139
- const rows = db.prepare(`SELECT id, status FROM cards WHERE tenant_id = ? AND id IN (${placeholders})`).all(tenantId, ...dependsOn);
3140
- const found = new Map(rows.map((r) => [r.id, r.status]));
3141
- // Pre-check before any write: a typo'd --depends-on can never commit a card row.
3142
- for (const parentId of dependsOn) {
3143
- if (!found.has(parentId)) {
3144
- throw new Error(`unknown parent card id: ${parentId}`);
3145
- }
3146
- }
3147
- allParentsDone = dependsOn.every((pid) => found.get(pid) === 'done');
3148
- }
3149
- id = generateId('card');
3150
- const now = new Date().toISOString();
3151
- const status = dependsOn.length === 0 || allParentsDone ? 'ready' : 'backlog';
3152
- db.prepare(`
3153
- INSERT INTO cards (id, title, status, repo, contract, budget, created_at, updated_at, tenant_id)
3154
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3155
- `).run(id, input.title, status, input.repo ?? null, input.contract ?? null, input.budget ?? null, now, now, tenantId);
3156
- for (const parentId of dependsOn) {
3157
- db.prepare(`
3158
- INSERT INTO card_deps (parent, child, tenant_id, created_at) VALUES (?, ?, ?, ?)
3159
- `).run(parentId, id, tenantId, now);
3160
- }
3161
- db.exec('COMMIT');
3162
- }
3163
- catch (error) {
3164
- try {
3165
- db.exec('ROLLBACK');
3166
- }
3167
- catch { /* commit may have already rolled back */ }
3168
- throw error;
3169
- }
3170
- return loadCardRow(db, tenantId, id);
3171
- }
3172
- finally {
3173
- closeHippoDb(db);
3174
- }
3175
- }
3176
- /** Returns the card row for id, or null if it does not exist under this tenant. */
3177
- export function loadCard(hippoRoot, tenantId, id) {
3178
- assertTenantId('loadCard', tenantId);
3179
- const db = openStore(hippoRoot);
3180
- try {
3181
- return loadCardRow(db, tenantId, id);
3182
- }
3183
- finally {
3184
- closeHippoDb(db);
3185
- }
3186
- }
3187
- /** Lists cards for this tenant, optionally filtered to one status, newest-updated first. */
3188
- export function listCards(hippoRoot, tenantId, opts = {}) {
3189
- assertTenantId('listCards', tenantId);
3190
- const db = openStore(hippoRoot);
3191
- try {
3192
- const conditions = ['tenant_id = ?'];
3193
- const params = [tenantId];
3194
- if (opts.status) {
3195
- conditions.push('status = ?');
3196
- params.push(opts.status);
3197
- }
3198
- // SAFETY: rows' shape matches CARD_COLUMNS; status only ever holds a CardStatus value.
3199
- const rows = db.prepare(`
3200
- SELECT ${CARD_COLUMNS} FROM cards WHERE ${conditions.join(' AND ')} ORDER BY updated_at DESC, id DESC
3201
- `).all(...params);
3202
- return rows.map(rowToCard);
3203
- }
3204
- finally {
3205
- closeHippoDb(db);
3206
- }
3207
- }
3208
- /** Returns this card's parent and child ids from card_deps. */
3209
- export function loadCardDeps(hippoRoot, tenantId, id) {
3210
- assertTenantId('loadCardDeps', tenantId);
3211
- const db = openStore(hippoRoot);
3212
- try {
3213
- // SAFETY: rows' shape matches the single `parent` column named in the SELECT below.
3214
- const parents = db.prepare(`SELECT parent FROM card_deps WHERE tenant_id = ? AND child = ?`).all(tenantId, id).map((r) => r.parent);
3215
- // SAFETY: rows' shape matches the single `child` column named in the SELECT below.
3216
- const children = db.prepare(`SELECT child FROM card_deps WHERE tenant_id = ? AND parent = ?`).all(tenantId, id).map((r) => r.child);
3217
- return { parents, children };
3218
- }
3219
- finally {
3220
- closeHippoDb(db);
3221
- }
3222
- }
3223
- /** Returns this card's run history, most recent first. */
3224
- export function loadCardRuns(hippoRoot, tenantId, id) {
3225
- assertTenantId('loadCardRuns', tenantId);
3226
- const db = openStore(hippoRoot);
3227
- try {
3228
- // SAFETY: rows' shape matches CardRunRow.
3229
- const rows = db.prepare(`
3230
- SELECT id, card, runtime, session_id, started, ended, outcome
3231
- FROM card_runs WHERE tenant_id = ? AND card = ? ORDER BY started DESC, id DESC
3232
- `).all(tenantId, id);
3233
- return rows.map(rowToCardRun);
3234
- }
3235
- finally {
3236
- closeHippoDb(db);
3237
- }
3238
- }
3239
- /** Returns this card's comments, most recent first. */
3240
- export function loadCardComments(hippoRoot, tenantId, id) {
3241
- assertTenantId('loadCardComments', tenantId);
3242
- const db = openStore(hippoRoot);
3243
- try {
3244
- // SAFETY: rows' shape matches CardCommentRow.
3245
- const rows = db.prepare(`
3246
- SELECT id, card_id, author, body, created_at
3247
- FROM card_comments WHERE tenant_id = ? AND card_id = ? ORDER BY created_at DESC, id DESC
3248
- `).all(tenantId, id);
3249
- return rows.map(rowToCardComment);
3250
- }
3251
- finally {
3252
- closeHippoDb(db);
3253
- }
3254
- }
3255
- /** Read side of the card <-> handoff round trip: the newest handoff filed against this card. */
3256
- export function loadLatestHandoffForCard(hippoRoot, tenantId, cardId) {
3257
- assertTenantId('loadLatestHandoffForCard', tenantId);
3258
- const db = openStore(hippoRoot);
3259
- try {
3260
- // SAFETY: row's shape matches HANDOFF_COLUMNS.
3261
- const row = db.prepare(`
3262
- SELECT ${HANDOFF_COLUMNS} FROM session_handoffs
3263
- WHERE tenant_id = ? AND card_id = ? ORDER BY created_at DESC, id DESC LIMIT 1
3264
- `).get(tenantId, cardId);
3265
- return row ? rowToSessionHandoff(row) : null;
3266
- }
3267
- finally {
3268
- closeHippoDb(db);
3269
- }
3270
- }
3271
- /** Atomic claim: WHERE status IN (ready, blocked) AND assignee_runtime IS NULL decides the race. Throws on an unknown card id; returns null for a card not ready/blocked or already claimed. Sets a CARD_LEASE_MS lease and returns the new run's id as runId. */
3272
- export function claimCard(hippoRoot, tenantId, id, runtime, sessionId) {
3273
- assertTenantId('claimCard', tenantId);
3274
- if (runtime.trim() === '') {
3275
- throw new Error('runtime must not be empty');
3276
- }
3277
- const db = openStore(hippoRoot);
3278
- try {
3279
- db.exec('BEGIN IMMEDIATE');
3280
- let runId = 0;
3281
- try {
3282
- const changes = transitionCard(db, tenantId, id, ['ready', 'blocked'], 'running', {
3283
- setSql: 'assignee_runtime = ?',
3284
- whereSql: 'assignee_runtime IS NULL',
3285
- params: [runtime],
3286
- });
3287
- if (changes === 0) {
3288
- if (!loadCardRow(db, tenantId, id)) {
3289
- throw new Error(`unknown card id: ${id}`);
3290
- }
3291
- db.exec('ROLLBACK');
3292
- return null;
3293
- }
3294
- const now = new Date().toISOString();
3295
- const insert = db.prepare(`
3296
- INSERT INTO card_runs (card, runtime, session_id, started, created_at, updated_at, tenant_id)
3297
- VALUES (?, ?, ?, ?, ?, ?, ?)
3298
- `).run(id, runtime, sessionId ?? null, now, now, now, tenantId);
3299
- runId = Number(insert.lastInsertRowid ?? 0);
3300
- db.exec('COMMIT');
3301
- }
3302
- catch (error) {
3303
- try {
3304
- db.exec('ROLLBACK');
3305
- }
3306
- catch { /* commit may have already rolled back */ }
3307
- throw error;
3308
- }
3309
- return { ...loadCardRow(db, tenantId, id), runId };
3310
- }
3311
- finally {
3312
- closeHippoDb(db);
3313
- }
3314
- }
3315
- /** Moves a running card's lease to CARD_LEASE_MS from now and records the heartbeat; updated_at is left alone. Throws on an unknown card id or a run id that is not a positive integer; returns null unless the card is running and runId is its live run. */
3316
- export function heartbeatCard(hippoRoot, tenantId, id, runId) {
3317
- assertTenantId('heartbeatCard', tenantId);
3318
- assertRunId(runId);
3319
- const db = openStore(hippoRoot);
3320
- try {
3321
- db.exec('BEGIN IMMEDIATE');
3322
- try {
3323
- const card = loadCardRow(db, tenantId, id);
3324
- if (!card) {
3325
- throw new Error(`unknown card id: ${id}`);
3326
- }
3327
- if (card.status !== 'running' || !isLiveRun(db, tenantId, id, runId)) {
3328
- db.exec('ROLLBACK');
3329
- return null;
3330
- }
3331
- const now = new Date().toISOString();
3332
- db.prepare(`UPDATE cards SET lease_until = ?, heartbeat_at = ? WHERE id = ? AND tenant_id = ?`)
3333
- .run(leaseUntilFrom(now), now, id, tenantId);
3334
- db.exec('COMMIT');
3335
- }
3336
- catch (error) {
3337
- try {
3338
- db.exec('ROLLBACK');
3339
- }
3340
- catch { /* commit may have already rolled back */ }
3341
- throw error;
3342
- }
3343
- return loadCardRow(db, tenantId, id);
3344
- }
3345
- finally {
3346
- closeHippoDb(db);
3347
- }
3348
- }
3349
- /** Requires the card be running; closes the live run as blocked and files reason as a comment. Throws on an unknown card id; returns null for a card not running. When runId is given, returns null unless it is the card's live run. */
3350
- export function blockCard(hippoRoot, tenantId, id, reason, runId) {
3351
- assertTenantId('blockCard', tenantId);
3352
- if (reason.trim() === '') {
3353
- throw new Error('reason must not be empty');
3354
- }
3355
- if (runId !== undefined)
3356
- assertRunId(runId);
3357
- const db = openStore(hippoRoot);
3358
- try {
3359
- db.exec('BEGIN IMMEDIATE');
3360
- try {
3361
- const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
3362
- const changes = allowed ? transitionCard(db, tenantId, id, ['running'], 'blocked', { setSql: 'assignee_runtime = NULL' }) : 0;
3363
- if (changes === 0) {
3364
- if (!loadCardRow(db, tenantId, id)) {
3365
- throw new Error(`unknown card id: ${id}`);
3366
- }
3367
- db.exec('ROLLBACK');
3368
- return null;
3369
- }
3370
- const now = new Date().toISOString();
3371
- // Close the interrupted run here so completeCard's ended IS NULL scope only ever matches the live run.
3372
- closeLiveRun(db, tenantId, id, 'blocked', now);
3373
- insertCardComment(db, tenantId, id, 'system', reason);
3374
- db.exec('COMMIT');
3375
- }
3376
- catch (error) {
3377
- try {
3378
- db.exec('ROLLBACK');
3379
- }
3380
- catch { /* commit may have already rolled back */ }
3381
- throw error;
3382
- }
3383
- return loadCardRow(db, tenantId, id);
3384
- }
3385
- finally {
3386
- closeHippoDb(db);
3387
- }
3388
- }
3389
- /** Requires the card be running; moves it to review, clearing its lease and heartbeat and keeping its live run. When runId is given, returns null unless it is the card's live run. Throws on an unknown card id; returns null for a card not running. */
3390
- export function reviewCard(hippoRoot, tenantId, id, runId) {
3391
- assertTenantId('reviewCard', tenantId);
3392
- if (runId !== undefined)
3393
- assertRunId(runId);
3394
- const db = openStore(hippoRoot);
3395
- try {
3396
- db.exec('BEGIN IMMEDIATE');
3397
- try {
3398
- const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
3399
- const changes = allowed ? transitionCard(db, tenantId, id, ['running'], 'review') : 0;
3400
- if (changes === 0) {
3401
- if (!loadCardRow(db, tenantId, id)) {
3402
- throw new Error(`unknown card id: ${id}`);
3403
- }
3404
- db.exec('ROLLBACK');
3405
- return null;
3406
- }
3407
- db.exec('COMMIT');
3408
- }
3409
- catch (error) {
3410
- try {
3411
- db.exec('ROLLBACK');
3412
- }
3413
- catch { /* commit may have already rolled back */ }
3414
- throw error;
3415
- }
3416
- return loadCardRow(db, tenantId, id);
3417
- }
3418
- finally {
3419
- closeHippoDb(db);
3420
- }
3421
- }
3422
- /** Requires the card be in review; closes the live run with outcome. Outcome 'success' moves the card to done and, in the same transaction, promotes any child whose parents are now all done; 'failure' or 'partial' moves it to shelved and promotes nothing. Throws on an unknown card id; returns null for a card not in review. When runId is given, returns null unless it is the card's live run. */
3423
- export function completeCard(hippoRoot, tenantId, id, outcome, runId) {
3424
- assertTenantId('completeCard', tenantId);
3425
- if (!isHandoffOutcome(outcome)) {
3426
- throw new Error(`invalid card outcome: ${String(outcome)}`);
3427
- }
3428
- if (runId !== undefined)
3429
- assertRunId(runId);
3430
- const db = openStore(hippoRoot);
3431
- try {
3432
- db.exec('BEGIN IMMEDIATE');
3433
- let promotedChildren = [];
3434
- try {
3435
- const target = outcome === 'success' ? 'done' : 'shelved';
3436
- const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
3437
- const changes = allowed ? transitionCard(db, tenantId, id, ['review'], target) : 0;
3438
- if (changes === 0) {
3439
- if (!loadCardRow(db, tenantId, id)) {
3440
- throw new Error(`unknown card id: ${id}`);
3441
- }
3442
- db.exec('ROLLBACK');
3443
- return null;
3444
- }
3445
- const now = new Date().toISOString();
3446
- closeLiveRun(db, tenantId, id, outcome, now);
3447
- // Not best-effort (rule 12): promotion runs in this same transaction, so a
3448
- // card can never be `done` with an un-evaluated child.
3449
- if (target === 'done') {
3450
- // SAFETY: rows' shape matches the single `child` column named in the SELECT below.
3451
- const children = db.prepare(`SELECT child FROM card_deps WHERE tenant_id = ? AND parent = ?`).all(tenantId, id).map((r) => r.child);
3452
- for (const childId of children) {
3453
- // SAFETY: row's shape matches the single `status` column named in the SELECT below.
3454
- const child = db.prepare(`SELECT status FROM cards WHERE tenant_id = ? AND id = ?`).get(tenantId, childId);
3455
- if (!child || child.status !== 'backlog')
3456
- continue;
3457
- // SAFETY: rows' shape matches the single `parent` column named in the SELECT below.
3458
- const parents = db.prepare(`SELECT parent FROM card_deps WHERE tenant_id = ? AND child = ?`).all(tenantId, childId).map((r) => r.parent);
3459
- const placeholders = parents.map(() => '?').join(', ');
3460
- // SAFETY: row's shape matches the single `c` column named in the SELECT below.
3461
- const doneCount = db.prepare(`SELECT COUNT(*) as c FROM cards WHERE tenant_id = ? AND id IN (${placeholders}) AND status = 'done'`).get(tenantId, ...parents).c;
3462
- if (doneCount === parents.length) {
3463
- transitionCard(db, tenantId, childId, ['backlog'], 'ready');
3464
- promotedChildren.push(childId);
3465
- }
3466
- }
3467
- }
3468
- db.exec('COMMIT');
3469
- }
3470
- catch (error) {
3471
- try {
3472
- db.exec('ROLLBACK');
3473
- }
3474
- catch { /* commit may have already rolled back */ }
3475
- throw error;
3476
- }
3477
- return { card: loadCardRow(db, tenantId, id), promotedChildren };
3478
- }
3479
- finally {
3480
- closeHippoDb(db);
3481
- }
3482
- }
3483
- /** Returns to ready every running card of the tenant whose lease has expired or is missing: clears its assignee, closes its live run as 'reclaimed' and leaves its handoffs alone, all in one write transaction. Returns the reclaimed card ids in id order. */
3484
- export function reclaimExpiredCards(hippoRoot, tenantId) {
3485
- assertTenantId('reclaimExpiredCards', tenantId);
3486
- const db = openStore(hippoRoot);
3487
- try {
3488
- db.exec('BEGIN IMMEDIATE');
3489
- try {
3490
- // Read lease times under the write lock, so a heartbeat that committed while we waited wins.
3491
- const now = new Date().toISOString();
3492
- // SAFETY: rows' shape matches the single `id` column named in the SELECT below.
3493
- const ids = db.prepare(`
3494
- SELECT id FROM cards
3495
- WHERE tenant_id = ? AND status = 'running' AND (lease_until IS NULL OR lease_until < ?)
3496
- ORDER BY id
3497
- `).all(tenantId, now).map((r) => r.id);
3498
- for (const id of ids) {
3499
- transitionCard(db, tenantId, id, ['running'], 'ready', { setSql: 'assignee_runtime = NULL' });
3500
- closeLiveRun(db, tenantId, id, 'reclaimed', now);
3501
- }
3502
- db.exec('COMMIT');
3503
- return ids;
3504
- }
3505
- catch (error) {
3506
- try {
3507
- db.exec('ROLLBACK');
3508
- }
3509
- catch { /* commit may have already rolled back */ }
3510
- throw error;
3511
- }
3512
- }
3513
- finally {
3514
- closeHippoDb(db);
3515
- }
3516
- }
3517
- /** Appends a comment to cardId in any card status; throws if cardId is not a card of this tenant. */
3518
- export function addCardComment(hippoRoot, tenantId, cardId, author, body) {
3519
- assertTenantId('addCardComment', tenantId);
3520
- if (body.trim() === '') {
3521
- throw new Error('body must not be empty');
3522
- }
3523
- const db = openStore(hippoRoot);
3524
- try {
3525
- return insertCardComment(db, tenantId, cardId, author, body);
3526
- }
3527
- finally {
3528
- closeHippoDb(db);
3529
- }
3530
- }
3531
2975
  // ---------------------------------------------------------------------------
3532
2976
  // v0.30 / E1 of DAG live-coupling — dirty-flag helpers for the existing
3533
2977
  // DAG layer's level-2 summaries.
package/dist/tenant.d.ts CHANGED
@@ -4,4 +4,26 @@ export interface ResolveOpts {
4
4
  apiKey?: string;
5
5
  }
6
6
  export declare function resolveTenantId(opts: ResolveOpts): string;
7
+ /** A value that round-trips through JSON.stringify/JSON.parse unchanged. */
8
+ type JsonValue = string | number | boolean | null | JsonValue[] | {
9
+ [key: string]: JsonValue;
10
+ };
11
+ /**
12
+ * Defensive runtime guard for tenant id arguments.
13
+ *
14
+ * The continuity helpers (saveActiveTaskSnapshot, listSessionEvents, etc.)
15
+ * gained a required `tenantId` parameter (schema v22) to close a
16
+ * cross-tenant data leak. TypeScript catches misbinding at compile time, but
17
+ * JavaScript callers from older versions can silently pass a `sessionId`
18
+ * where `tenantId` is now expected, e.g.
19
+ * loadLatestHandoff(root, 'sess-abc') // WRONG: 'sess-abc' becomes the tenant
20
+ * which would silently filter to a non-existent tenant and return null with
21
+ * no error. This guard rejects the most common shape of that mistake (any
22
+ * value beginning with the conventional `sess-` / `sess_` session prefix).
23
+ *
24
+ * False-positive cost: a tenant literally named `sess-...` will be rejected.
25
+ * Acceptable tradeoff for catching the silent-leak class.
26
+ */
27
+ export declare function assertTenantId(fnName: string, value: JsonValue): asserts value is string;
28
+ export {};
7
29
  //# sourceMappingURL=tenant.d.ts.map
package/dist/tenant.js CHANGED
@@ -14,4 +14,30 @@ export function resolveTenantId(opts) {
14
14
  const t = process.env.HIPPO_TENANT?.trim();
15
15
  return t ? t : 'default';
16
16
  }
17
+ /**
18
+ * Defensive runtime guard for tenant id arguments.
19
+ *
20
+ * The continuity helpers (saveActiveTaskSnapshot, listSessionEvents, etc.)
21
+ * gained a required `tenantId` parameter (schema v22) to close a
22
+ * cross-tenant data leak. TypeScript catches misbinding at compile time, but
23
+ * JavaScript callers from older versions can silently pass a `sessionId`
24
+ * where `tenantId` is now expected, e.g.
25
+ * loadLatestHandoff(root, 'sess-abc') // WRONG: 'sess-abc' becomes the tenant
26
+ * which would silently filter to a non-existent tenant and return null with
27
+ * no error. This guard rejects the most common shape of that mistake (any
28
+ * value beginning with the conventional `sess-` / `sess_` session prefix).
29
+ *
30
+ * False-positive cost: a tenant literally named `sess-...` will be rejected.
31
+ * Acceptable tradeoff for catching the silent-leak class.
32
+ */
33
+ export function assertTenantId(fnName, value) {
34
+ if (typeof value !== 'string' || value.length === 0) {
35
+ throw new Error(`${fnName}: tenantId is required (got ${typeof value})`);
36
+ }
37
+ if (/^sess[-_]/i.test(value)) {
38
+ throw new Error(`${fnName}: tenantId looks like a session id ('${value}'). ` +
39
+ `In v0.41+ these helpers take (hippoRoot, tenantId, ...). ` +
40
+ `Pass the tenant id (e.g. 'default') and the session id separately.`);
41
+ }
42
+ }
17
43
  //# sourceMappingURL=tenant.js.map