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.
- package/README.md +24 -1
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/agent-memories/legacy.js +4 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +5 -5
- package/dist/api.js +40 -47
- package/dist/audit.d.ts +5 -1
- package/dist/audit.js +13 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/cli/output.d.ts +3 -0
- package/dist/cli/output.js +7 -0
- package/dist/cli/projects.d.ts +4 -0
- package/dist/cli/projects.js +90 -0
- package/dist/cli/shared.js +23 -13
- package/dist/cli/sleep.js +5 -3
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +486 -397
- package/dist/client.js +9 -0
- package/dist/codex-patch.js +1 -1
- package/dist/compaction-record.d.ts +1 -1
- package/dist/compaction-record.js +3 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/github/dlq.js +5 -2
- package/dist/connectors/github/octokit-client.js +4 -2
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +48 -35
- package/dist/customer-notes.js +14 -13
- package/dist/dag.js +7 -4
- package/dist/dashboard.js +1 -1
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +9 -8
- package/dist/dedupe.js +1 -1
- package/dist/doctor.js +28 -0
- package/dist/dormant.d.ts +2 -2
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/extract.js +19 -18
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +8 -0
- package/dist/http-util.js +10 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +11 -10
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +52 -24
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.js +3 -2
- package/dist/overlap-index.d.ts +7 -0
- package/dist/overlap-index.js +38 -0
- package/dist/pilot-arm.d.ts +9 -0
- package/dist/pilot-arm.js +47 -0
- package/dist/policies.js +12 -11
- package/dist/predictions.js +9 -8
- package/dist/processes.js +14 -13
- package/dist/project-briefs.js +16 -15
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/project-merge.d.ts +52 -0
- package/dist/project-merge.js +168 -0
- package/dist/raw-archive.js +7 -6
- package/dist/recall-scope.d.ts +5 -4
- package/dist/recall-scope.js +7 -5
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/rerankers/clef.d.ts +29 -0
- package/dist/rerankers/clef.js +182 -0
- package/dist/rerankers/index.js +3 -0
- package/dist/rerankers/jev.d.ts +11 -0
- package/dist/rerankers/jev.js +10 -5
- package/dist/rerankers/types.d.ts +16 -0
- package/dist/search.js +14 -2
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +9 -2
- package/dist/server.js +188 -411
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +15 -14
- package/dist/store.js +10 -10
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- 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
|
|
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
|
|
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
|
|
127
|
+
throw new BadRequestError('savePolicy: policyName is required');
|
|
127
128
|
}
|
|
128
129
|
if (!opts.policyText || opts.policyText.trim().length === 0) {
|
|
129
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
252
|
+
throw new NotFoundError(`closePolicy: policy ${id} not found for tenant ${tenantId}`);
|
|
252
253
|
}
|
|
253
|
-
throw new
|
|
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
|
|
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
|
|
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.
|
package/dist/predictions.js
CHANGED
|
@@ -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
|
|
76
|
+
throw new BadRequestError('savePrediction: classTag is required');
|
|
76
77
|
if (!opts.claimText)
|
|
77
|
-
throw new
|
|
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
|
|
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
|
|
175
|
+
throw new NotFoundError(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
|
|
175
176
|
}
|
|
176
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
75
|
+
throw new BadRequestError(`saveProcess: step ${i + 1} is empty`);
|
|
75
76
|
}
|
|
76
77
|
if (trimmed.length > MAX_PROCESS_STEP_LEN) {
|
|
77
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
266
|
+
throw new NotFoundError(`closeProcess: process ${id} not found for tenant ${tenantId}`);
|
|
266
267
|
}
|
|
267
|
-
throw new
|
|
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
|
|
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
|
|
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).
|
package/dist/project-briefs.js
CHANGED
|
@@ -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
|
|
60
|
+
throw new BadRequestError('saveProjectBrief: repo is required');
|
|
60
61
|
if (/[\r\n]/.test(normalizedRepo)) {
|
|
61
|
-
throw new
|
|
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
|
|
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
|
|
68
|
+
throw new BadRequestError('saveProjectBrief: summary is required');
|
|
68
69
|
}
|
|
69
70
|
if (summary.length > MAX_BRIEF_SUMMARY_LEN) {
|
|
70
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
238
|
+
throw new NotFoundError(`closeProjectBrief: brief ${id} not found for tenant ${tenantId}`);
|
|
238
239
|
}
|
|
239
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/project-identity.js
CHANGED
|
@@ -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
|
-
|
|
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
|