hippo-memory 1.57.0 → 1.59.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 (109) hide show
  1. package/README.md +24 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.js +1 -1
  4. package/dist/agent-memories/gemini.js +1 -1
  5. package/dist/agent-memories/legacy.js +4 -1
  6. package/dist/api-errors.d.ts +27 -0
  7. package/dist/api-errors.js +37 -0
  8. package/dist/api.d.ts +5 -5
  9. package/dist/api.js +40 -47
  10. package/dist/audit.d.ts +5 -1
  11. package/dist/audit.js +13 -0
  12. package/dist/autolearn.d.ts +1 -1
  13. package/dist/autolearn.js +7 -5
  14. package/dist/capture-contract.d.ts +47 -0
  15. package/dist/capture-contract.js +49 -0
  16. package/dist/capture-error.js +2 -1
  17. package/dist/capture.d.ts +0 -13
  18. package/dist/capture.js +5 -66
  19. package/dist/cli/output.d.ts +3 -0
  20. package/dist/cli/output.js +7 -0
  21. package/dist/cli/projects.d.ts +4 -0
  22. package/dist/cli/projects.js +90 -0
  23. package/dist/cli/shared.js +23 -13
  24. package/dist/cli/sleep.js +5 -3
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +486 -397
  27. package/dist/client.js +9 -0
  28. package/dist/codex-patch.js +1 -1
  29. package/dist/compaction-record.d.ts +1 -1
  30. package/dist/compaction-record.js +3 -2
  31. package/dist/config.d.ts +5 -0
  32. package/dist/config.js +17 -0
  33. package/dist/connectors/github/dlq.js +5 -2
  34. package/dist/connectors/github/octokit-client.js +4 -2
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/consolidate.d.ts +10 -0
  38. package/dist/consolidate.js +48 -35
  39. package/dist/customer-notes.js +14 -13
  40. package/dist/dag.js +7 -4
  41. package/dist/dashboard.js +1 -1
  42. package/dist/db.d.ts +12 -0
  43. package/dist/db.js +62 -1
  44. package/dist/decisions.js +9 -8
  45. package/dist/dedupe.js +1 -1
  46. package/dist/doctor.js +28 -0
  47. package/dist/dormant.d.ts +2 -2
  48. package/dist/embedding-provider.js +3 -3
  49. package/dist/embeddings.d.ts +4 -4
  50. package/dist/embeddings.js +72 -16
  51. package/dist/extract.js +19 -18
  52. package/dist/http-retry.d.ts +21 -0
  53. package/dist/http-retry.js +50 -0
  54. package/dist/http-util.d.ts +8 -0
  55. package/dist/http-util.js +10 -0
  56. package/dist/importers.d.ts +2 -0
  57. package/dist/importers.js +16 -5
  58. package/dist/incidents.js +11 -10
  59. package/dist/judgment.js +10 -17
  60. package/dist/log.d.ts +25 -0
  61. package/dist/log.js +48 -0
  62. package/dist/mcp/server.js +52 -24
  63. package/dist/mcp/tool-args.d.ts +21 -0
  64. package/dist/mcp/tool-args.js +80 -0
  65. package/dist/memory.js +3 -2
  66. package/dist/overlap-index.d.ts +7 -0
  67. package/dist/overlap-index.js +38 -0
  68. package/dist/pilot-arm.d.ts +9 -0
  69. package/dist/pilot-arm.js +47 -0
  70. package/dist/policies.js +12 -11
  71. package/dist/predictions.js +9 -8
  72. package/dist/processes.js +14 -13
  73. package/dist/project-briefs.js +16 -15
  74. package/dist/project-identity.d.ts +1 -1
  75. package/dist/project-identity.js +25 -1
  76. package/dist/project-merge.d.ts +52 -0
  77. package/dist/project-merge.js +168 -0
  78. package/dist/raw-archive.js +7 -6
  79. package/dist/recall-scope.d.ts +5 -4
  80. package/dist/recall-scope.js +7 -5
  81. package/dist/refine-llm.js +3 -2
  82. package/dist/reject-flow.js +6 -9
  83. package/dist/rejection.d.ts +2 -1
  84. package/dist/rejection.js +2 -1
  85. package/dist/rerankers/clef.d.ts +29 -0
  86. package/dist/rerankers/clef.js +182 -0
  87. package/dist/rerankers/index.js +3 -0
  88. package/dist/rerankers/jev.d.ts +11 -0
  89. package/dist/rerankers/jev.js +10 -5
  90. package/dist/rerankers/types.d.ts +16 -0
  91. package/dist/search.js +14 -2
  92. package/dist/secret-detect.d.ts +13 -1
  93. package/dist/secret-detect.js +33 -1
  94. package/dist/server.d.ts +9 -2
  95. package/dist/server.js +188 -411
  96. package/dist/session-digest.js +2 -1
  97. package/dist/shared.js +7 -6
  98. package/dist/skills.js +15 -14
  99. package/dist/store.js +10 -10
  100. package/dist/token-ledger.d.ts +4 -2
  101. package/dist/token-ledger.js +2 -2
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  105. package/extensions/openclaw-plugin/package.json +1 -1
  106. package/openclaw.plugin.json +1 -1
  107. package/package.json +5 -2
  108. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  109. package/dist/connectors/slack/ratelimit.js +0 -18
@@ -0,0 +1,9 @@
1
+ import { type DatabaseSyncLike } from './db.js';
2
+ export type PilotArm = 'hippo' | 'holdout';
3
+ /** Deterministic split: the same session and rate always land in the same arm. */
4
+ export declare function hashArm(sessionId: string, rateBp: number): PilotArm;
5
+ /** The session's first stored arm, or null; never writes. No tenant filter: a session has one arm. */
6
+ export declare function readPilotArm(db: DatabaseSyncLike, sessionId: string): PilotArm | null;
7
+ /** The stored arm, else the hash arm written once; on any error the hash arm comes back unrecorded. */
8
+ export declare function ensurePilotArm(db: DatabaseSyncLike, tenantId: string, sessionId: string, rateBp: number, now?: string): PilotArm;
9
+ //# sourceMappingURL=pilot-arm.d.ts.map
@@ -0,0 +1,47 @@
1
+ // Pilot arm: one token_ledger row per session names its arm, `hippo` or `holdout`, so a pilot can compare them.
2
+ // `items` holds the holdout rate in basis points; readers outside this repo depend on these rows, so their shape is fixed.
3
+ import { createHash } from 'node:crypto';
4
+ import { execWithBusyRetry, HOOK_DB_WAIT_MS } from './db.js';
5
+ import { recordTokenUse } from './token-ledger.js';
6
+ /** Deterministic split: the same session and rate always land in the same arm. */
7
+ export function hashArm(sessionId, rateBp) {
8
+ const bucket = parseInt(createHash('sha256').update(sessionId).digest('hex').slice(0, 8), 16) % 10000;
9
+ return bucket < rateBp ? 'holdout' : 'hippo';
10
+ }
11
+ /** The session's first stored arm, or null; never writes. No tenant filter: a session has one arm. */
12
+ export function readPilotArm(db, sessionId) {
13
+ // SAFETY: the SELECT names exactly this one column.
14
+ const row = db.prepare(`SELECT block_hash FROM token_ledger WHERE session_id = ? AND surface = 'pilot' AND event = 'arm' ORDER BY id LIMIT 1`).get(sessionId);
15
+ return row?.block_hash === 'holdout' || row?.block_hash === 'hippo' ? row.block_hash : null;
16
+ }
17
+ /** The stored arm, else the hash arm written once; on any error the hash arm comes back unrecorded. */
18
+ // The hook's lock-wait bound holds only on a handle opened with `busyWaitMs: HOOK_DB_WAIT_MS`, as hook commands are.
19
+ export function ensurePilotArm(db, tenantId, sessionId, rateBp, now) {
20
+ const hashed = hashArm(sessionId, rateBp);
21
+ let began = false;
22
+ try {
23
+ // A stored row is the common case after the first prompt, so it must not take the write lock.
24
+ const existing = readPilotArm(db, sessionId);
25
+ if (existing !== null)
26
+ return existing;
27
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE', HOOK_DB_WAIT_MS);
28
+ began = true;
29
+ const stored = readPilotArm(db, sessionId);
30
+ if (stored === null) {
31
+ recordTokenUse(db, { tenantId, sessionId, surface: 'pilot', event: 'arm', items: rateBp, tokens: 0, hash: hashed, now });
32
+ }
33
+ db.exec('COMMIT');
34
+ return stored ?? hashed;
35
+ }
36
+ catch {
37
+ // A prompt hook must not fail on pilot bookkeeping; concurrent callers still agree on the hash arm.
38
+ if (began) {
39
+ try {
40
+ db.exec('ROLLBACK');
41
+ }
42
+ catch { /* keep the hash arm */ }
43
+ }
44
+ return hashed;
45
+ }
46
+ }
47
+ //# sourceMappingURL=pilot-arm.js.map
package/dist/policies.js CHANGED
@@ -36,6 +36,7 @@
36
36
  * Dual-write atomicity: `savePolicy` writes the memory + policies row (and, on
37
37
  * supersede, the predecessor's UPDATE) inside writeEntry's SAVEPOINT.
38
38
  */
39
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
39
40
  import { openHippoDb, closeHippoDb } from './db.js';
40
41
  import { writeEntry } from './store.js';
41
42
  import { assertTenantId } from './tenant.js';
@@ -62,7 +63,7 @@ export const VALID_POLICY_STATES = new Set([
62
63
  export function normalizePolicyDate(input, label = 'date') {
63
64
  const d = new Date(input);
64
65
  if (Number.isNaN(d.getTime())) {
65
- throw new Error(`policy: invalid ${label} "${input}" (expected an ISO-8601 date or datetime)`);
66
+ throw new BadRequestError(`policy: invalid ${label} "${input}" (expected an ISO-8601 date or datetime)`);
66
67
  }
67
68
  return d.toISOString();
68
69
  }
@@ -76,7 +77,7 @@ export function validatePolicyDates(validFromRaw, validToRaw, nowIso) {
76
77
  ? normalizePolicyDate(validToRaw, 'valid_to')
77
78
  : null;
78
79
  if (validTo !== null && validTo <= validFrom) {
79
- throw new Error(`policy: valid_to (${validTo}) must be strictly after valid_from (${validFrom})`);
80
+ throw new BadRequestError(`policy: valid_to (${validTo}) must be strictly after valid_from (${validFrom})`);
80
81
  }
81
82
  return { validFrom, validTo };
82
83
  }
@@ -123,10 +124,10 @@ function buildPolicyContent(policyName, policyText, validFrom, validTo) {
123
124
  export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
124
125
  assertTenantId('savePolicy', tenantId);
125
126
  if (!opts.policyName || opts.policyName.trim().length === 0) {
126
- throw new Error('savePolicy: policyName is required');
127
+ throw new BadRequestError('savePolicy: policyName is required');
127
128
  }
128
129
  if (!opts.policyText || opts.policyText.trim().length === 0) {
129
- throw new Error('savePolicy: policyText is required');
130
+ throw new BadRequestError('savePolicy: policyText is required');
130
131
  }
131
132
  const now = new Date().toISOString();
132
133
  // valid_from defaults to the precise creation instant (the honest effective
@@ -163,10 +164,10 @@ export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
163
164
  // shape for the matching row, or undefined when no policy/tenant pair matches.
164
165
  const pred = db.prepare(`SELECT status, version FROM policies WHERE id = ? AND tenant_id = ?`).get(opts.supersedesPolicyId, tenantId);
165
166
  if (!pred) {
166
- throw new Error(`savePolicy: policy ${opts.supersedesPolicyId} to supersede not found for tenant ${tenantId}`);
167
+ throw new NotFoundError(`savePolicy: policy ${opts.supersedesPolicyId} to supersede not found for tenant ${tenantId}`);
167
168
  }
168
169
  if (pred.status !== 'active') {
169
- throw new Error(`savePolicy: policy ${opts.supersedesPolicyId} is not active (status='${pred.status}'); only active policies can be superseded.`);
170
+ throw new ConflictError(`savePolicy: policy ${opts.supersedesPolicyId} is not active (status='${pred.status}'); only active policies can be superseded.`);
170
171
  }
171
172
  version = pred.version + 1;
172
173
  }
@@ -184,7 +185,7 @@ export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
184
185
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
185
186
  `).run(policyId, now, opts.supersedesPolicyId, tenantId, policyId);
186
187
  if (sup.changes === 0) {
187
- throw new Error(`savePolicy: policy ${opts.supersedesPolicyId} could not be superseded (no longer active or self-reference).`);
188
+ throw new ConflictError(`savePolicy: policy ${opts.supersedesPolicyId} could not be superseded (no longer active or self-reference).`);
188
189
  }
189
190
  appendAuditEvent(db, {
190
191
  tenantId,
@@ -248,9 +249,9 @@ export function closePolicy(hippoRoot, tenantId, id, actor = 'cli') {
248
249
  // shape, or undefined when the id/tenant pair doesn't exist.
249
250
  const existing = db.prepare(`SELECT status FROM policies WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
250
251
  if (!existing) {
251
- throw new Error(`closePolicy: policy ${id} not found for tenant ${tenantId}`);
252
+ throw new NotFoundError(`closePolicy: policy ${id} not found for tenant ${tenantId}`);
252
253
  }
253
- throw new Error(`closePolicy: policy ${id} is not active (status='${existing.status}'); only active policies can be closed.`);
254
+ throw new ConflictError(`closePolicy: policy ${id} is not active (status='${existing.status}'); only active policies can be closed.`);
254
255
  }
255
256
  // SAFETY: SELECT ${POLICY_COLS} projects exactly the PolicyRow columns;
256
257
  // .get() returns that row for the just-updated id, or undefined only in an
@@ -258,7 +259,7 @@ export function closePolicy(hippoRoot, tenantId, id, actor = 'cli') {
258
259
  const row = db.prepare(`SELECT ${POLICY_COLS} FROM policies WHERE id = ? AND tenant_id = ?`)
259
260
  .get(id, tenantId);
260
261
  if (!row)
261
- throw new Error(`closePolicy: policy ${id} not found after UPDATE`);
262
+ throw new NotFoundError(`closePolicy: policy ${id} not found after UPDATE`);
262
263
  appendAuditEvent(db, {
263
264
  tenantId,
264
265
  actor,
@@ -315,7 +316,7 @@ export function loadPolicies(hippoRoot, tenantId, opts = {}) {
315
316
  let rows;
316
317
  if (opts.status) {
317
318
  if (!VALID_POLICY_STATES.has(opts.status)) {
318
- throw new Error(`loadPolicies: status must be one of ${Array.from(VALID_POLICY_STATES).join('|')}; got ${opts.status}`);
319
+ throw new BadRequestError(`loadPolicies: status must be one of ${Array.from(VALID_POLICY_STATES).join('|')}; got ${opts.status}`);
319
320
  }
320
321
  // SAFETY: SELECT ${POLICY_COLS} projects exactly the PolicyRow columns;
321
322
  // .all() returns rows in that shape regardless of the status filter applied.
@@ -24,6 +24,7 @@
24
24
  * (estimate_value, actual_value) at query time. J3 is a follow-up episode;
25
25
  * this module ships the data layer.
26
26
  */
27
+ import { BadRequestError, NotFoundError } from './api-errors.js';
27
28
  import { openHippoDb, closeHippoDb } from './db.js';
28
29
  import { writeEntry } from './store.js';
29
30
  import { assertTenantId } from './tenant.js';
@@ -72,9 +73,9 @@ function rowToPrediction(row) {
72
73
  export function savePrediction(hippoRoot, tenantId, opts, actor = 'cli') {
73
74
  assertTenantId('savePrediction', tenantId);
74
75
  if (!opts.classTag)
75
- throw new Error('savePrediction: classTag is required');
76
+ throw new BadRequestError('savePrediction: classTag is required');
76
77
  if (!opts.claimText)
77
- throw new Error('savePrediction: claimText is required');
78
+ throw new BadRequestError('savePrediction: claimText is required');
78
79
  const now = new Date().toISOString();
79
80
  const mem = createMemory(opts.claimText, {
80
81
  tags: ['prediction', opts.classTag],
@@ -143,7 +144,7 @@ export function savePrediction(hippoRoot, tenantId, opts, actor = 'cli') {
143
144
  export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
144
145
  assertTenantId('closePrediction', tenantId);
145
146
  if (!VALID_CLOSURE_STATES.has(opts.closureState)) {
146
- throw new Error(`closePrediction: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
147
+ throw new BadRequestError(`closePrediction: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
147
148
  }
148
149
  const now = new Date().toISOString();
149
150
  const db = openHippoDb(hippoRoot);
@@ -171,9 +172,9 @@ export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
171
172
  SELECT closure_state FROM predictions WHERE id = ? AND tenant_id = ?
172
173
  `).get(id, tenantId);
173
174
  if (!existing) {
174
- throw new Error(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
175
+ throw new NotFoundError(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
175
176
  }
176
- throw new Error(`closePrediction: prediction ${id} is already closed (state='${existing.closure_state}'); ` +
177
+ throw new BadRequestError(`closePrediction: prediction ${id} is already closed (state='${existing.closure_state}'); ` +
177
178
  `cannot re-close. Open predictions only.`);
178
179
  }
179
180
  // SAFETY: row's shape matches the columns named in the SELECT above.
@@ -184,7 +185,7 @@ export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
184
185
  FROM predictions WHERE id = ? AND tenant_id = ?
185
186
  `).get(id, tenantId);
186
187
  if (!row) {
187
- throw new Error(`closePrediction: prediction ${id} not found after UPDATE`);
188
+ throw new NotFoundError(`closePrediction: prediction ${id} not found after UPDATE`);
188
189
  }
189
190
  appendAuditEvent(db, {
190
191
  tenantId,
@@ -239,7 +240,7 @@ export function loadPredictionsByClass(hippoRoot, tenantId, classTag, opts = {})
239
240
  let rows;
240
241
  if (opts.closureState) {
241
242
  if (!VALID_CLOSURE_STATES.has(opts.closureState)) {
242
- throw new Error(`loadPredictionsByClass: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
243
+ throw new BadRequestError(`loadPredictionsByClass: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
243
244
  }
244
245
  // SAFETY: rows' shape matches the columns named in the SELECT above.
245
246
  rows = db.prepare(`
@@ -296,7 +297,7 @@ export function computePredictionBaserate(hippoRoot, tenantId, classTag, actor =
296
297
  emitAudit = true) {
297
298
  assertTenantId('computePredictionBaserate', tenantId);
298
299
  if (!classTag)
299
- throw new Error('computePredictionBaserate: classTag is required');
300
+ throw new BadRequestError('computePredictionBaserate: classTag is required');
300
301
  const db = openHippoDb(hippoRoot);
301
302
  try {
302
303
  // SAFETY: rows' shape matches the two columns named in the SELECT above.
package/dist/processes.js CHANGED
@@ -30,6 +30,7 @@
30
30
  * 'write_entry' via the afterWrite hook, so a failure in any step rolls all of
31
31
  * them back. Pattern matches saveDecision (decisions.ts).
32
32
  */
33
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
33
34
  import { openHippoDb, closeHippoDb } from './db.js';
34
35
  import { writeEntry } from './store.js';
35
36
  import { assertTenantId } from './tenant.js';
@@ -58,23 +59,23 @@ export const MAX_PROCESS_STEP_LEN = 2000;
58
59
  */
59
60
  export function validateProcessSteps(steps) {
60
61
  if (!Array.isArray(steps)) {
61
- throw new Error('saveProcess: steps must be an array of strings');
62
+ throw new BadRequestError('saveProcess: steps must be an array of strings');
62
63
  }
63
64
  if (steps.length > MAX_PROCESS_STEPS) {
64
- throw new Error(`saveProcess: steps exceeds the ${MAX_PROCESS_STEPS}-step cap (got ${steps.length})`);
65
+ throw new BadRequestError(`saveProcess: steps exceeds the ${MAX_PROCESS_STEPS}-step cap (got ${steps.length})`);
65
66
  }
66
67
  const out = [];
67
68
  for (let i = 0; i < steps.length; i++) {
68
69
  const raw = steps[i];
69
70
  if (!isString(raw)) {
70
- throw new Error(`saveProcess: step ${i + 1} is not a string`);
71
+ throw new BadRequestError(`saveProcess: step ${i + 1} is not a string`);
71
72
  }
72
73
  const trimmed = raw.trim();
73
74
  if (trimmed.length === 0) {
74
- throw new Error(`saveProcess: step ${i + 1} is empty`);
75
+ throw new BadRequestError(`saveProcess: step ${i + 1} is empty`);
75
76
  }
76
77
  if (trimmed.length > MAX_PROCESS_STEP_LEN) {
77
- throw new Error(`saveProcess: step ${i + 1} exceeds the ${MAX_PROCESS_STEP_LEN}-char cap`);
78
+ throw new BadRequestError(`saveProcess: step ${i + 1} exceeds the ${MAX_PROCESS_STEP_LEN}-char cap`);
78
79
  }
79
80
  out.push(trimmed);
80
81
  }
@@ -143,7 +144,7 @@ function buildProcessContent(processName, steps, description) {
143
144
  export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
144
145
  assertTenantId('saveProcess', tenantId);
145
146
  if (!opts.processName || opts.processName.trim().length === 0) {
146
- throw new Error('saveProcess: processName is required');
147
+ throw new BadRequestError('saveProcess: processName is required');
147
148
  }
148
149
  const steps = validateProcessSteps(opts.steps);
149
150
  const isSupersede = opts.supersedesProcessId !== undefined;
@@ -177,10 +178,10 @@ export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
177
178
  // the two selected columns 1:1.
178
179
  const pred = db.prepare(`SELECT status, version FROM processes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesProcessId, tenantId);
179
180
  if (!pred) {
180
- throw new Error(`saveProcess: process ${opts.supersedesProcessId} to supersede not found for tenant ${tenantId}`);
181
+ throw new NotFoundError(`saveProcess: process ${opts.supersedesProcessId} to supersede not found for tenant ${tenantId}`);
181
182
  }
182
183
  if (pred.status !== 'active') {
183
- throw new Error(`saveProcess: process ${opts.supersedesProcessId} is not active (status='${pred.status}'); only active processes can be superseded.`);
184
+ throw new ConflictError(`saveProcess: process ${opts.supersedesProcessId} is not active (status='${pred.status}'); only active processes can be superseded.`);
184
185
  }
185
186
  version = pred.version + 1;
186
187
  }
@@ -198,7 +199,7 @@ export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
198
199
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
199
200
  `).run(processId, now, opts.supersedesProcessId, tenantId, processId);
200
201
  if (sup.changes === 0) {
201
- throw new Error(`saveProcess: process ${opts.supersedesProcessId} could not be superseded (no longer active or self-reference).`);
202
+ throw new ConflictError(`saveProcess: process ${opts.supersedesProcessId} could not be superseded (no longer active or self-reference).`);
202
203
  }
203
204
  appendAuditEvent(db, {
204
205
  tenantId,
@@ -262,16 +263,16 @@ export function closeProcess(hippoRoot, tenantId, id, actor = 'cli') {
262
263
  // single selected column.
263
264
  const existing = db.prepare(`SELECT status FROM processes WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
264
265
  if (!existing) {
265
- throw new Error(`closeProcess: process ${id} not found for tenant ${tenantId}`);
266
+ throw new NotFoundError(`closeProcess: process ${id} not found for tenant ${tenantId}`);
266
267
  }
267
- throw new Error(`closeProcess: process ${id} is not active (status='${existing.status}'); only active processes can be closed.`);
268
+ throw new ConflictError(`closeProcess: process ${id} is not active (status='${existing.status}'); only active processes can be closed.`);
268
269
  }
269
270
  // SAFETY: SELECT ${PROCESS_COLS} enumerates every ProcessRow field
270
271
  // 1:1 (see PROCESS_COLS above).
271
272
  const row = db.prepare(`SELECT ${PROCESS_COLS} FROM processes WHERE id = ? AND tenant_id = ?`)
272
273
  .get(id, tenantId);
273
274
  if (!row)
274
- throw new Error(`closeProcess: process ${id} not found after UPDATE`);
275
+ throw new NotFoundError(`closeProcess: process ${id} not found after UPDATE`);
275
276
  appendAuditEvent(db, {
276
277
  tenantId,
277
278
  actor,
@@ -318,7 +319,7 @@ export function loadProcesses(hippoRoot, tenantId, opts = {}) {
318
319
  let rows;
319
320
  if (opts.status) {
320
321
  if (!VALID_PROCESS_STATES.has(opts.status)) {
321
- throw new Error(`loadProcesses: status must be one of ${Array.from(VALID_PROCESS_STATES).join('|')}; got ${opts.status}`);
322
+ throw new BadRequestError(`loadProcesses: status must be one of ${Array.from(VALID_PROCESS_STATES).join('|')}; got ${opts.status}`);
322
323
  }
323
324
  // SAFETY: SELECT ${PROCESS_COLS} enumerates every ProcessRow field
324
325
  // 1:1 (see PROCESS_COLS above).
@@ -21,6 +21,7 @@
21
21
  * Lifecycle: active -> superseded (a newer version replaces it) or active ->
22
22
  * closed (retired).
23
23
  */
24
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
24
25
  import { openHippoDb, closeHippoDb } from './db.js';
25
26
  import { writeEntry } from './store.js';
26
27
  import { assertTenantId } from './tenant.js';
@@ -56,21 +57,21 @@ export const MAX_RECEIPT_HEADLINE_LEN = 200;
56
57
  function validateBriefFields(repo, summary, changeSummary) {
57
58
  const normalizedRepo = (repo ?? '').trim();
58
59
  if (normalizedRepo.length === 0)
59
- throw new Error('saveProjectBrief: repo is required');
60
+ throw new BadRequestError('saveProjectBrief: repo is required');
60
61
  if (/[\r\n]/.test(normalizedRepo)) {
61
- throw new Error('saveProjectBrief: repo must be a single line (no newlines)');
62
+ throw new BadRequestError('saveProjectBrief: repo must be a single line (no newlines)');
62
63
  }
63
64
  if (normalizedRepo.length > MAX_REPO_LEN) {
64
- throw new Error(`saveProjectBrief: repo exceeds the ${MAX_REPO_LEN}-char cap`);
65
+ throw new BadRequestError(`saveProjectBrief: repo exceeds the ${MAX_REPO_LEN}-char cap`);
65
66
  }
66
67
  if (!summary || summary.trim().length === 0) {
67
- throw new Error('saveProjectBrief: summary is required');
68
+ throw new BadRequestError('saveProjectBrief: summary is required');
68
69
  }
69
70
  if (summary.length > MAX_BRIEF_SUMMARY_LEN) {
70
- throw new Error(`saveProjectBrief: summary exceeds the ${MAX_BRIEF_SUMMARY_LEN}-char cap`);
71
+ throw new BadRequestError(`saveProjectBrief: summary exceeds the ${MAX_BRIEF_SUMMARY_LEN}-char cap`);
71
72
  }
72
73
  if (changeSummary !== undefined && changeSummary.length > MAX_CHANGE_SUMMARY_LEN) {
73
- throw new Error(`saveProjectBrief: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
74
+ throw new BadRequestError(`saveProjectBrief: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
74
75
  }
75
76
  return { repo: normalizedRepo };
76
77
  }
@@ -145,10 +146,10 @@ export function saveProjectBrief(hippoRoot, tenantId, opts, actor = 'cli') {
145
146
  // shape for the matching row, or undefined when no brief/tenant pair matches.
146
147
  const pred = db.prepare(`SELECT status, version FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(opts.supersedesBriefId, tenantId);
147
148
  if (!pred) {
148
- throw new Error(`saveProjectBrief: brief ${opts.supersedesBriefId} to supersede not found for tenant ${tenantId}`);
149
+ throw new NotFoundError(`saveProjectBrief: brief ${opts.supersedesBriefId} to supersede not found for tenant ${tenantId}`);
149
150
  }
150
151
  if (pred.status !== 'active') {
151
- throw new Error(`saveProjectBrief: brief ${opts.supersedesBriefId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
152
+ throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
152
153
  }
153
154
  version = pred.version + 1;
154
155
  }
@@ -166,7 +167,7 @@ export function saveProjectBrief(hippoRoot, tenantId, opts, actor = 'cli') {
166
167
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
167
168
  `).run(briefId, now, opts.supersedesBriefId, tenantId, briefId);
168
169
  if (sup.changes === 0) {
169
- throw new Error(`saveProjectBrief: brief ${opts.supersedesBriefId} could not be superseded (no longer active or self-reference).`);
170
+ throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} could not be superseded (no longer active or self-reference).`);
170
171
  }
171
172
  appendAuditEvent(db, {
172
173
  tenantId,
@@ -234,9 +235,9 @@ export function closeProjectBrief(hippoRoot, tenantId, id, actor = 'cli') {
234
235
  // shape, or undefined when the id/tenant pair doesn't exist.
235
236
  const existing = db.prepare(`SELECT status FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
236
237
  if (!existing) {
237
- throw new Error(`closeProjectBrief: brief ${id} not found for tenant ${tenantId}`);
238
+ throw new NotFoundError(`closeProjectBrief: brief ${id} not found for tenant ${tenantId}`);
238
239
  }
239
- throw new Error(`closeProjectBrief: brief ${id} is not active (status='${existing.status}'); only active briefs can be closed.`);
240
+ throw new ConflictError(`closeProjectBrief: brief ${id} is not active (status='${existing.status}'); only active briefs can be closed.`);
240
241
  }
241
242
  // SAFETY: SELECT ${BRIEF_COLS} projects exactly the ProjectBriefRow
242
243
  // columns; .get() returns that row for the just-updated id, or undefined
@@ -244,7 +245,7 @@ export function closeProjectBrief(hippoRoot, tenantId, id, actor = 'cli') {
244
245
  const row = db.prepare(`SELECT ${BRIEF_COLS} FROM project_briefs WHERE id = ? AND tenant_id = ?`)
245
246
  .get(id, tenantId);
246
247
  if (!row)
247
- throw new Error(`closeProjectBrief: brief ${id} not found after UPDATE`);
248
+ throw new NotFoundError(`closeProjectBrief: brief ${id} not found after UPDATE`);
248
249
  appendAuditEvent(db, {
249
250
  tenantId,
250
251
  actor,
@@ -298,7 +299,7 @@ export function loadProjectBriefs(hippoRoot, tenantId, opts = {}) {
298
299
  assertTenantId('loadProjectBriefs', tenantId);
299
300
  const limit = opts.limit ?? 100;
300
301
  if (opts.status && !VALID_BRIEF_STATES.has(opts.status)) {
301
- throw new Error(`loadProjectBriefs: status must be one of ${Array.from(VALID_BRIEF_STATES).join('|')}; got ${opts.status}`);
302
+ throw new BadRequestError(`loadProjectBriefs: status must be one of ${Array.from(VALID_BRIEF_STATES).join('|')}; got ${opts.status}`);
302
303
  }
303
304
  const db = openHippoDb(hippoRoot);
304
305
  try {
@@ -385,7 +386,7 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
385
386
  assertTenantId('assembleBriefFromReceipts', tenantId);
386
387
  const normalizedRepo = (repo ?? '').trim();
387
388
  if (normalizedRepo.length === 0) {
388
- throw new Error('assembleBriefFromReceipts: repo is required');
389
+ throw new BadRequestError('assembleBriefFromReceipts: repo is required');
389
390
  }
390
391
  const tag = `path:${normalizedRepo.toLowerCase()}`;
391
392
  const likeParam = `%"${escapeLike(tag)}"%`;
@@ -478,7 +479,7 @@ export function refreshBrief(hippoRoot, tenantId, repo, actor = 'cli') {
478
479
  assertTenantId('refreshBrief', tenantId);
479
480
  const normalizedRepo = (repo ?? '').trim();
480
481
  if (normalizedRepo.length === 0)
481
- throw new Error('refreshBrief: repo is required');
482
+ throw new BadRequestError('refreshBrief: repo is required');
482
483
  const { markdown, receiptCount } = assembleBriefFromReceipts(hippoRoot, tenantId, normalizedRepo);
483
484
  const active = loadActiveBriefForRepo(hippoRoot, tenantId, normalizedRepo);
484
485
  return saveProjectBrief(hippoRoot, tenantId, {
@@ -19,7 +19,7 @@
19
19
  export interface ProjectIdentity {
20
20
  /** Realpath-resolved root directory of the project (the start dir when not in a project). */
21
21
  root: string;
22
- /** Lowercased basename of the project root; empty string when not in a project. */
22
+ /** Lowercased basename of the project root, or of its repo for a linked worktree with no `.hippo` (a store keeps the name its rows carry); '' outside a project. */
23
23
  name: string;
24
24
  /** True when the directory resolves to the user home working set. */
25
25
  isHome: boolean;
@@ -66,7 +66,8 @@ export function resolveProjectIdentity(cwd, opts) {
66
66
  let identity;
67
67
  const root = hippoRoot ?? gitRoot;
68
68
  if (root !== null) {
69
- identity = { root, name: path.basename(root).toLowerCase(), isHome: false };
69
+ const name = (hippoRoot === null ? linkedWorktreeRepoName(root, home) : null) ?? path.basename(root).toLowerCase();
70
+ identity = { root, name, isHome: false };
70
71
  }
71
72
  else if (reachedHome || isUnder(start, home)) {
72
73
  identity = { root: home, name: '', isHome: true };
@@ -80,6 +81,29 @@ export function resolveProjectIdentity(cwd, opts) {
80
81
  identityCache.set(startInput, identity);
81
82
  return identity;
82
83
  }
84
+ /** The repo name a linked worktree shares with its main checkout (`repo.git` or `repo/.bare` for a bare repo), so one repo is one project; null for any other checkout. */
85
+ function linkedWorktreeRepoName(gitRoot, home) {
86
+ const marker = path.join(gitRoot, '.git');
87
+ if (!fs.existsSync(marker) || isDirectoryAt(marker))
88
+ return null;
89
+ try {
90
+ const gitDir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(marker, 'utf8'))?.[1]?.trim();
91
+ if (!gitDir)
92
+ return null;
93
+ const linkDir = path.resolve(gitRoot, gitDir);
94
+ const commondir = path.join(linkDir, 'commondir');
95
+ if (!fs.existsSync(commondir))
96
+ return null; // a submodule's or a separate git dir's own checkout
97
+ const common = realpathOrResolve(path.resolve(linkDir, fs.readFileSync(commondir, 'utf8').trim()));
98
+ const repo = ['.git', '.bare'].includes(path.basename(common)) ? path.dirname(common) : common;
99
+ if (samePath(repo, home))
100
+ return null; // a dotfiles repo at home must not make its worktrees user-global
101
+ return path.basename(repo).replace(/\.git$/i, '').toLowerCase() || null;
102
+ }
103
+ catch {
104
+ return null; // an unreadable link is still a checkout, named after its own folder
105
+ }
106
+ }
83
107
  /** Climb from start toward the root; home (never a project) and every stop dir end the walk unchecked. */
84
108
  function walkProjectMarkers(start, home, stopDirs) {
85
109
  let hippoRoot = null;
@@ -0,0 +1,52 @@
1
+ import type { DatabaseSyncLike } from './db.js';
2
+ export interface ProjectSummary {
3
+ /** '' is user-global, null is unknown; neither can be merged. */
4
+ readonly origin: string | null;
5
+ readonly live: number;
6
+ readonly imported: number;
7
+ readonly newest: string;
8
+ /** Imported notes whose text another project name also holds: evidence of a duplicate import, never a merge target. */
9
+ readonly copiesElsewhere: number;
10
+ }
11
+ export interface MergeResult {
12
+ readonly from: string;
13
+ readonly into: string;
14
+ /** Imported note copies moved to dormant storage; the next sync under `into` imports the notes still on disk. */
15
+ readonly setAside: readonly string[];
16
+ readonly restamped: readonly string[];
17
+ readonly dormantRestamped: readonly string[];
18
+ readonly compactions: number;
19
+ readonly backup: string | null;
20
+ }
21
+ export interface RepairResult {
22
+ readonly toProject: ReadonlyArray<{
23
+ readonly id: string;
24
+ readonly origin: string;
25
+ }>;
26
+ /** Parents in two projects: the merged text blends them, so it goes dormant and the parents re-merge per project. */
27
+ readonly setAside: readonly string[];
28
+ /** No parent left to say which project, or pinned: left as they are, since hiding them would rest on no evidence. */
29
+ readonly untraced: readonly string[];
30
+ readonly backup: string | null;
31
+ }
32
+ /** Live rows per project name, newest write first, with how many of its imported notes are copies held elsewhere. */
33
+ export declare function listProjects(db: DatabaseSyncLike, tenantId: string): ProjectSummary[];
34
+ /** Copies the database before a repair writes, so the audit ids plus this file are the way back. */
35
+ export declare function backupStore(db: DatabaseSyncLike, hippoRoot: string, label: string, now?: Date): string;
36
+ /** Refuses user-global and unknown: they are not projects, and folding them would leak or hide every row. */
37
+ export declare function validateMergeNames(from: string, into: string): string | null;
38
+ /** Folds project `from` into `into` for one tenant in one transaction; a dry run rolls back and writes nothing, mirrors included. */
39
+ export declare function mergeProjects(db: DatabaseSyncLike, hippoRoot: string, opts: {
40
+ tenantId: string;
41
+ from: string;
42
+ into: string;
43
+ dryRun: boolean;
44
+ }): MergeResult;
45
+ /** Reads only, so doctor can call it on a read-only handle: what the repair would do to each user-global merged row. */
46
+ export declare function planUserGlobalRepair(db: DatabaseSyncLike, tenantId: string): Omit<RepairResult, 'backup'>;
47
+ /** Re-tags sleep's merged rows saved as user-global before the fix, by the projects of their parents. */
48
+ export declare function repairUserGlobalMerges(db: DatabaseSyncLike, hippoRoot: string, opts: {
49
+ tenantId: string;
50
+ dryRun: boolean;
51
+ }): RepairResult;
52
+ //# sourceMappingURL=project-merge.d.ts.map