hippo-memory 1.63.2 → 1.64.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 +10 -4
- package/dist/agent-memories/claude-code.js +3 -3
- package/dist/ambient-store.d.ts +2 -0
- package/dist/ambient-store.js +4 -3
- package/dist/api/assemble.d.ts +2 -0
- package/dist/api/assemble.js +11 -7
- package/dist/api/auth.d.ts +15 -11
- package/dist/api/auth.js +116 -22
- package/dist/api/context-select.d.ts +5 -3
- package/dist/api/context-select.js +6 -11
- package/dist/api/context-types.d.ts +2 -1
- package/dist/api/context.d.ts +1 -1
- package/dist/api/context.js +79 -55
- package/dist/api/dormant.d.ts +5 -5
- package/dist/api/dormant.js +13 -9
- package/dist/api/drill-down.d.ts +2 -0
- package/dist/api/drill-down.js +12 -7
- package/dist/api/forget.d.ts +1 -1
- package/dist/api/forget.js +8 -4
- package/dist/api/learn.js +5 -3
- package/dist/api/outcome.d.ts +1 -1
- package/dist/api/outcome.js +2 -1
- package/dist/api/promote.js +8 -5
- package/dist/api/recall-types.d.ts +4 -2
- package/dist/api/recall.js +51 -36
- package/dist/api/remember.d.ts +7 -0
- package/dist/api/remember.js +26 -2
- package/dist/api/sleep-run.d.ts +28 -0
- package/dist/api/sleep-run.js +267 -0
- package/dist/api/sleep.d.ts +5 -45
- package/dist/api/sleep.js +5 -252
- package/dist/api/types.d.ts +5 -0
- package/dist/api/types.js +4 -0
- package/dist/audit-prune.js +1 -1
- package/dist/audit.d.ts +1 -3
- package/dist/audit.js +7 -103
- package/dist/auth.d.ts +48 -4
- package/dist/auth.js +111 -42
- package/dist/autolearn.js +2 -2
- package/dist/capture/caller-session.d.ts +16 -0
- package/dist/capture/caller-session.js +58 -0
- package/dist/capture/command.d.ts +19 -1
- package/dist/capture/command.js +28 -21
- package/dist/capture/compact-resume-caller.d.ts +11 -0
- package/dist/capture/compact-resume-caller.js +35 -0
- package/dist/capture/compact.d.ts +0 -14
- package/dist/capture/compact.js +7 -160
- package/dist/capture/compaction-items-caller.d.ts +16 -0
- package/dist/capture/compaction-items-caller.js +32 -0
- package/dist/capture/extract.d.ts +2 -14
- package/dist/capture/extract.js +135 -346
- package/dist/capture/failure-caller.d.ts +21 -0
- package/dist/capture/failure-caller.js +74 -0
- package/dist/capture/failure-reading.d.ts +44 -0
- package/dist/capture/failure-reading.js +78 -0
- package/dist/capture/pre-compact-caller.d.ts +16 -0
- package/dist/capture/pre-compact-caller.js +39 -0
- package/dist/capture/session-end-caller.d.ts +21 -0
- package/dist/capture/session-end-caller.js +30 -0
- package/dist/capture/session-texts.d.ts +14 -0
- package/dist/capture/session-texts.js +42 -0
- package/dist/capture/transcript.d.ts +6 -1
- package/dist/capture/transcript.js +20 -16
- package/dist/capture/working-state.d.ts +21 -0
- package/dist/capture/working-state.js +154 -0
- package/dist/capture-error.d.ts +8 -22
- package/dist/capture-error.js +9 -61
- package/dist/cli/audit.js +25 -0
- package/dist/cli/auth.js +4 -3
- package/dist/cli/context.js +13 -158
- package/dist/cli/continuity.js +2 -1
- package/dist/cli/init.js +5 -3
- package/dist/cli/last-sleep.d.ts +6 -0
- package/dist/cli/last-sleep.js +68 -0
- package/dist/cli/quality-repair-once.d.ts +3 -0
- package/dist/cli/quality-repair-once.js +36 -0
- package/dist/cli/recall.js +2 -1
- package/dist/cli/remember.d.ts +2 -0
- package/dist/cli/remember.js +4 -1
- package/dist/cli/session-hooks.d.ts +0 -3
- package/dist/cli/session-hooks.js +12 -49
- package/dist/cli/setup.js +10 -2
- package/dist/cli/shared.d.ts +7 -14
- package/dist/cli/shared.js +19 -78
- package/dist/cli/sleep.js +6 -3
- package/dist/cli/usage.js +18 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -2
- package/dist/client.js +1 -1
- package/dist/compaction-record.d.ts +19 -1
- package/dist/compaction-record.js +61 -132
- package/dist/compaction-spool.d.ts +45 -0
- package/dist/compaction-spool.js +454 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +49 -0
- package/dist/connectors/github/deletion.d.ts +1 -1
- package/dist/connectors/github/deletion.js +5 -17
- package/dist/consolidate/conflicts.js +4 -0
- package/dist/consolidate/flush-units.d.ts +9 -0
- package/dist/consolidate/flush-units.js +63 -0
- package/dist/consolidate/llm-passes.js +5 -4
- package/dist/consolidate/merge.js +5 -1
- package/dist/consolidate/run.d.ts +2 -1
- package/dist/consolidate/sleep.d.ts +2 -0
- package/dist/consolidate/sleep.js +82 -15
- package/dist/context-render.d.ts +14 -0
- package/dist/context-render.js +28 -0
- package/dist/dag.js +6 -0
- package/dist/dashboard-actions.js +3 -2
- package/dist/dashboard.js +4 -3
- package/dist/db/backup.d.ts +6 -0
- package/dist/db/backup.js +29 -0
- package/dist/db/busy.d.ts +9 -0
- package/dist/db/busy.js +35 -0
- package/dist/db/continuity.d.ts +1 -0
- package/dist/db/continuity.js +31 -4
- package/dist/db/meta.d.ts +2 -0
- package/dist/db/meta.js +7 -0
- package/dist/db/migrate.js +2 -3
- package/dist/db/migrations/index.d.ts +1 -1
- package/dist/db/migrations/index.js +5 -1
- package/dist/db/migrations/v39.js +2 -7
- package/dist/db/migrations/v53.d.ts +3 -0
- package/dist/db/migrations/v53.js +16 -0
- package/dist/db/migrations/v54.d.ts +3 -0
- package/dist/db/migrations/v54.js +25 -0
- package/dist/db/open.d.ts +11 -0
- package/dist/db/open.js +27 -0
- package/dist/db/tables.d.ts +2 -0
- package/dist/db/tables.js +6 -2
- package/dist/db.d.ts +2 -2
- package/dist/db.js +2 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +31 -15
- package/dist/dormant.d.ts +16 -20
- package/dist/dormant.js +24 -30
- package/dist/entry/json-hooks.d.ts +3 -0
- package/dist/entry/json-hooks.js +4 -0
- package/dist/entry/project-identity.d.ts +2 -0
- package/dist/entry/project-identity.js +3 -0
- package/dist/entry/session-text.d.ts +7 -0
- package/dist/entry/session-text.js +8 -0
- package/dist/extract.js +6 -0
- package/dist/failure-log.d.ts +10 -2
- package/dist/failure-log.js +19 -2
- package/dist/gated-write.js +1 -1
- package/dist/graph/delta.d.ts +44 -0
- package/dist/graph/delta.js +109 -0
- package/dist/graph/types.d.ts +5 -0
- package/dist/graph/write.d.ts +20 -20
- package/dist/graph/write.js +103 -36
- package/dist/graph-extract.d.ts +43 -9
- package/dist/graph-extract.js +140 -157
- package/dist/graph-recall.d.ts +1 -0
- package/dist/graph-recall.js +2 -2
- package/dist/graph-view.d.ts +1 -0
- package/dist/graph-view.js +25 -2
- package/dist/half-life-migration.js +5 -1
- package/dist/handoff-evidence.d.ts +5 -0
- package/dist/handoff-evidence.js +30 -0
- package/dist/home-path.d.ts +5 -0
- package/dist/home-path.js +24 -0
- package/dist/hooks/json-hooks.d.ts +13 -2
- package/dist/hooks/json-hooks.js +246 -117
- package/dist/hooks/opencode.js +1 -1
- package/dist/hooks/shared.d.ts +2 -0
- package/dist/hooks/shared.js +6 -1
- package/dist/http-util.d.ts +16 -7
- package/dist/http-util.js +65 -20
- package/dist/importers/vault.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +7 -0
- package/dist/ledger-db.d.ts +10 -0
- package/dist/ledger-db.js +42 -0
- package/dist/mcp/admin-tools.d.ts +2 -2
- package/dist/mcp/admin-tools.js +47 -5
- package/dist/mcp/memory-tools.js +7 -2
- package/dist/mcp/protocol.d.ts +6 -0
- package/dist/mcp/protocol.js +4 -2
- package/dist/mcp/recall-tools.js +19 -6
- package/dist/mcp/request.js +4 -0
- package/dist/mcp/session-state.d.ts +4 -0
- package/dist/mcp/session-state.js +15 -6
- package/dist/mcp/shared-gate.d.ts +4 -0
- package/dist/mcp/shared-gate.js +22 -0
- package/dist/mcp/tools.js +1 -0
- package/dist/memory-quality.d.ts +35 -0
- package/dist/memory-quality.js +191 -0
- package/dist/memory.d.ts +4 -4
- package/dist/physics-state.js +1 -1
- package/dist/pilot-arm.d.ts +14 -3
- package/dist/pilot-arm.js +32 -10
- package/dist/postinstall.js +2 -2
- package/dist/project-briefs.js +4 -4
- package/dist/project-identity.d.ts +14 -10
- package/dist/project-identity.js +29 -11
- package/dist/project-merge.d.ts +0 -2
- package/dist/project-merge.js +9 -13
- package/dist/prompt-hook.d.ts +65 -0
- package/dist/prompt-hook.js +213 -0
- package/dist/prompt-recall.js +1 -1
- package/dist/quality-repair.d.ts +27 -0
- package/dist/quality-repair.js +220 -0
- package/dist/rate-limit.d.ts +1 -0
- package/dist/rate-limit.js +5 -3
- package/dist/raw-archive.d.ts +1 -1
- package/dist/raw-archive.js +6 -16
- package/dist/recall-scope.d.ts +28 -5
- package/dist/recall-scope.js +52 -7
- package/dist/recall-trace.js +1 -1
- package/dist/reject-flow.d.ts +2 -0
- package/dist/reject-flow.js +20 -6
- package/dist/secret-detect.js +7 -8
- package/dist/server/auth.d.ts +8 -6
- package/dist/server/auth.js +85 -38
- package/dist/server/client-ip.d.ts +2 -0
- package/dist/server/client-ip.js +16 -1
- package/dist/server/mcp-http.d.ts +9 -3
- package/dist/server/mcp-http.js +56 -13
- package/dist/server/request.d.ts +1 -1
- package/dist/server/request.js +5 -5
- package/dist/server/routes/admin.js +12 -6
- package/dist/server/routes/memories.js +9 -1
- package/dist/server/routes/recall.js +14 -2
- package/dist/server/types.d.ts +42 -5
- package/dist/server/validation.d.ts +5 -2
- package/dist/server/validation.js +19 -2
- package/dist/server.d.ts +21 -4
- package/dist/server.js +207 -49
- package/dist/session-digest.d.ts +0 -1
- package/dist/session-digest.js +3 -12
- package/dist/session-owners.d.ts +7 -0
- package/dist/session-owners.js +54 -0
- package/dist/share-scrub.d.ts +3 -0
- package/dist/share-scrub.js +8 -0
- package/dist/shared.d.ts +1 -0
- package/dist/shared.js +26 -18
- package/dist/store/candidates.d.ts +14 -3
- package/dist/store/candidates.js +48 -26
- package/dist/store/conflicts.d.ts +15 -0
- package/dist/store/conflicts.js +68 -44
- package/dist/store/delete-and-batch.d.ts +25 -1
- package/dist/store/delete-and-batch.js +121 -69
- package/dist/store/entry-reads.d.ts +5 -6
- package/dist/store/entry-reads.js +24 -12
- package/dist/store/entry-row.d.ts +7 -10
- package/dist/store/entry-row.js +40 -18
- package/dist/store/entry-writes.d.ts +3 -3
- package/dist/store/entry-writes.js +9 -23
- package/dist/store/handoffs.d.ts +9 -6
- package/dist/store/handoffs.js +72 -59
- package/dist/store/index-and-stats.js +2 -2
- package/dist/store/open.d.ts +3 -1
- package/dist/store/open.js +3 -3
- package/dist/store/search-rows.d.ts +8 -5
- package/dist/store/search-rows.js +15 -18
- package/dist/store/sessions.d.ts +25 -6
- package/dist/store/sessions.js +61 -19
- package/dist/store/summaries.d.ts +1 -1
- package/dist/store/summaries.js +3 -18
- package/dist/store/tenant-lookup.d.ts +7 -3
- package/dist/store/tenant-lookup.js +9 -9
- package/dist/store-port.d.ts +13 -0
- package/dist/store-port.js +20 -0
- package/dist/version.d.ts +5 -1
- package/dist/version.js +5 -1
- package/dist/working-memory.js +1 -1
- package/dist/write-budget.d.ts +15 -0
- package/dist/write-budget.js +15 -0
- 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 +8 -4
package/dist/store/handoffs.d.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { type SessionHandoff, type HandoffOutcome, type HandoffEvidence } from '../handoff.js';
|
|
2
2
|
import type { TaskSnapshot } from './rows.js';
|
|
3
|
+
import { type ContinuityKey } from './sessions.js';
|
|
3
4
|
/** Column list shared by every session_handoffs SELECT; store-cards.ts reuses it for the card handoff lookup. */
|
|
4
5
|
export declare 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";
|
|
5
6
|
/**
|
|
6
7
|
* Save a session handoff record. Returns the persisted handoff.
|
|
7
8
|
*/
|
|
8
|
-
export declare function saveSessionHandoff(hippoRoot: string, tenantId: string, handoff: Omit<SessionHandoff, 'updatedAt'
|
|
9
|
-
|
|
10
|
-
export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, sessionId?: string, opts?: {
|
|
9
|
+
export declare function saveSessionHandoff(hippoRoot: string, tenantId: string, handoff: Omit<SessionHandoff, 'updatedAt'>, key?: ContinuityKey): SessionHandoff;
|
|
10
|
+
interface HandoffFilter {
|
|
11
11
|
unfinishedOnly?: boolean;
|
|
12
12
|
maxAgeMs?: number;
|
|
13
13
|
scopeFilter?: 'default-deny';
|
|
14
14
|
excludeSessionId?: string;
|
|
15
|
-
}
|
|
15
|
+
}
|
|
16
|
+
/** Load the most recent handoff, optionally filtered by session ID. */
|
|
17
|
+
export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, sessionId?: string, opts?: HandoffFilter, key?: ContinuityKey): SessionHandoff | null;
|
|
16
18
|
/**
|
|
17
19
|
* Load a specific handoff by its row ID.
|
|
18
20
|
*/
|
|
@@ -21,6 +23,7 @@ export declare function loadHandoffById(hippoRoot: string, tenantId: string, id:
|
|
|
21
23
|
export declare function stampHandoffOutcome(hippoRoot: string, tenantId: string, sessionId: string, outcome: HandoffOutcome): number;
|
|
22
24
|
/** Auto-write a handoff at session-end from the session's active snapshot, else from `derived`, its transcript state.
|
|
23
25
|
* @param evidence best-effort git state; outcome comes from the newest session_complete event.
|
|
24
|
-
* @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript
|
|
25
|
-
export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null): SessionHandoff | null;
|
|
26
|
+
* @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript or already holds `derived`. */
|
|
27
|
+
export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null, key?: ContinuityKey): SessionHandoff | null;
|
|
28
|
+
export {};
|
|
26
29
|
//# sourceMappingURL=handoffs.d.ts.map
|
package/dist/store/handoffs.js
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
import { closeHippoDb, openHippoDb } from '../db.js';
|
|
2
2
|
import { rowToSessionHandoff, isHandoffOutcome, } from '../handoff.js';
|
|
3
|
-
import {
|
|
3
|
+
import { scopeAdmitSql } from '../recall-scope.js';
|
|
4
4
|
import { assertTenantId } from '../tenant.js';
|
|
5
5
|
import { openStore } from './open.js';
|
|
6
|
-
import { loadActiveTaskSnapshot } from './sessions.js';
|
|
6
|
+
import { continuityStamp, continuityWhere, loadActiveTaskSnapshot } from './sessions.js';
|
|
7
7
|
/** Column list shared by every session_handoffs SELECT; store-cards.ts reuses it for the card handoff lookup. */
|
|
8
8
|
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';
|
|
9
9
|
/**
|
|
10
10
|
* Save a session handoff record. Returns the persisted handoff.
|
|
11
11
|
*/
|
|
12
|
-
export function saveSessionHandoff(hippoRoot, tenantId, handoff) {
|
|
12
|
+
export function saveSessionHandoff(hippoRoot, tenantId, handoff, key) {
|
|
13
13
|
assertTenantId('saveSessionHandoff', tenantId);
|
|
14
|
+
const stamp = key ? continuityStamp(key) : null;
|
|
14
15
|
const db = openStore(hippoRoot);
|
|
15
16
|
const now = new Date().toISOString();
|
|
16
17
|
// Scope is stored as given; read-side default-deny in api.recall + cmdRecall
|
|
17
18
|
// continuity excludes slack:private:* and 'unknown:legacy'.
|
|
18
19
|
try {
|
|
19
20
|
const result = db.prepare(`
|
|
20
|
-
INSERT INTO session_handoffs(session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, tenant_id, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id)
|
|
21
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
22
|
-
`).run(handoff.sessionId, handoff.repoRoot ?? null, handoff.taskId ?? null, handoff.summary, handoff.nextAction ?? null, JSON.stringify(handoff.artifacts ?? []), handoff.scope ?? null, tenantId, now, JSON.stringify(handoff.constraints ?? []), handoff.evidence ? JSON.stringify(handoff.evidence) : null, handoff.outcome ?? null, handoff.targetRuntime ?? null, handoff.cardId ?? null);
|
|
21
|
+
INSERT INTO session_handoffs(session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, tenant_id, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id, owner_subject, origin_project)
|
|
22
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
23
|
+
`).run(handoff.sessionId, handoff.repoRoot ?? null, handoff.taskId ?? null, handoff.summary, handoff.nextAction ?? null, JSON.stringify(handoff.artifacts ?? []), handoff.scope ?? null, tenantId, now, JSON.stringify(handoff.constraints ?? []), handoff.evidence ? JSON.stringify(handoff.evidence) : null, handoff.outcome ?? null, handoff.targetRuntime ?? null, handoff.cardId ?? null, stamp?.owner ?? null, stamp?.origin ?? null);
|
|
23
24
|
const id = Number(result.lastInsertRowid ?? 0);
|
|
24
25
|
// SAFETY: row's shape matches HANDOFF_COLUMNS.
|
|
25
|
-
const row = db.prepare(`
|
|
26
|
-
SELECT ${HANDOFF_COLUMNS}
|
|
27
|
-
FROM session_handoffs
|
|
28
|
-
WHERE id = ?
|
|
29
|
-
`).get(id);
|
|
26
|
+
const row = db.prepare(`SELECT ${HANDOFF_COLUMNS} FROM session_handoffs WHERE id = ?`).get(id);
|
|
30
27
|
if (!row) {
|
|
31
28
|
throw new Error('Failed to reload saved session handoff');
|
|
32
29
|
}
|
|
@@ -36,38 +33,48 @@ export function saveSessionHandoff(hippoRoot, tenantId, handoff) {
|
|
|
36
33
|
closeHippoDb(db);
|
|
37
34
|
}
|
|
38
35
|
}
|
|
36
|
+
function handoffConditions(tenantId, sessionId, opts, key) {
|
|
37
|
+
const owned = key ? continuityWhere(key) : null;
|
|
38
|
+
const conditions = ['tenant_id = ?'];
|
|
39
|
+
const params = [tenantId];
|
|
40
|
+
if (owned) {
|
|
41
|
+
conditions.push(owned.sql);
|
|
42
|
+
params.push(...owned.params);
|
|
43
|
+
}
|
|
44
|
+
if (sessionId) {
|
|
45
|
+
conditions.push('session_id = ?');
|
|
46
|
+
params.push(sessionId);
|
|
47
|
+
}
|
|
48
|
+
if (opts.excludeSessionId) {
|
|
49
|
+
conditions.push('session_id != ?');
|
|
50
|
+
params.push(opts.excludeSessionId);
|
|
51
|
+
}
|
|
52
|
+
if (opts.unfinishedOnly) {
|
|
53
|
+
// Restrict to each session's newest revision first: stampHandoffOutcome
|
|
54
|
+
// only stamps the newest row, so an older null-outcome revision must not resurrect.
|
|
55
|
+
// Keyed, the newest is the owner's own, so another owner's newer revision cannot hide it.
|
|
56
|
+
conditions.push(`id IN (SELECT MAX(id) FROM session_handoffs WHERE tenant_id = ?${owned ? ` AND ${owned.sql}` : ''} GROUP BY session_id)`);
|
|
57
|
+
params.push(tenantId, ...(owned?.params ?? []));
|
|
58
|
+
conditions.push(`(outcome IS NULL OR outcome IN ('partial','failure'))`);
|
|
59
|
+
}
|
|
60
|
+
if (opts.maxAgeMs != null) {
|
|
61
|
+
conditions.push('created_at >= ?');
|
|
62
|
+
params.push(new Date(Date.now() - opts.maxAgeMs).toISOString());
|
|
63
|
+
}
|
|
64
|
+
if (opts.scopeFilter === 'default-deny') {
|
|
65
|
+
// Admit scope before LIMIT 1, else a newer denied row hides an older eligible one.
|
|
66
|
+
const deny = scopeAdmitSql('');
|
|
67
|
+
conditions.push(deny.sql);
|
|
68
|
+
params.push(...deny.params);
|
|
69
|
+
}
|
|
70
|
+
return { conditions, params };
|
|
71
|
+
}
|
|
39
72
|
/** Load the most recent handoff, optionally filtered by session ID. */
|
|
40
|
-
export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}) {
|
|
73
|
+
export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}, key) {
|
|
41
74
|
assertTenantId('loadLatestHandoff', tenantId);
|
|
42
75
|
const db = openStore(hippoRoot);
|
|
43
76
|
try {
|
|
44
|
-
const conditions
|
|
45
|
-
const params = [tenantId];
|
|
46
|
-
if (sessionId) {
|
|
47
|
-
conditions.push('session_id = ?');
|
|
48
|
-
params.push(sessionId);
|
|
49
|
-
}
|
|
50
|
-
if (opts.excludeSessionId) {
|
|
51
|
-
conditions.push('session_id != ?');
|
|
52
|
-
params.push(opts.excludeSessionId);
|
|
53
|
-
}
|
|
54
|
-
if (opts.unfinishedOnly) {
|
|
55
|
-
// Restrict to each session's newest revision first: stampHandoffOutcome
|
|
56
|
-
// only stamps the newest row, so an older null-outcome revision must not resurrect.
|
|
57
|
-
conditions.push(`id IN (SELECT MAX(id) FROM session_handoffs WHERE tenant_id = ? GROUP BY session_id)`);
|
|
58
|
-
params.push(tenantId);
|
|
59
|
-
conditions.push(`(outcome IS NULL OR outcome IN ('partial','failure'))`);
|
|
60
|
-
}
|
|
61
|
-
if (opts.maxAgeMs != null) {
|
|
62
|
-
conditions.push('created_at >= ?');
|
|
63
|
-
params.push(new Date(Date.now() - opts.maxAgeMs).toISOString());
|
|
64
|
-
}
|
|
65
|
-
if (opts.scopeFilter === 'default-deny') {
|
|
66
|
-
// Admit scope before LIMIT 1, else a newer denied row hides an older eligible one.
|
|
67
|
-
const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
|
|
68
|
-
conditions.push(`(scope IS NULL OR (scope NOT IN (${placeholders}) AND scope NOT LIKE '%:private:%'))`);
|
|
69
|
-
params.push(...RECALL_DEFAULT_DENY_SCOPES);
|
|
70
|
-
}
|
|
77
|
+
const { conditions, params } = handoffConditions(tenantId, sessionId, opts, key);
|
|
71
78
|
// SAFETY: row's shape matches HANDOFF_COLUMNS.
|
|
72
79
|
const row = db.prepare(`
|
|
73
80
|
SELECT ${HANDOFF_COLUMNS}
|
|
@@ -123,11 +130,13 @@ export function stampHandoffOutcome(hippoRoot, tenantId, sessionId, outcome) {
|
|
|
123
130
|
}
|
|
124
131
|
/** Auto-write a handoff at session-end from the session's active snapshot, else from `derived`, its transcript state.
|
|
125
132
|
* @param evidence best-effort git state; outcome comes from the newest session_complete event.
|
|
126
|
-
* @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript
|
|
127
|
-
export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null) {
|
|
133
|
+
* @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript or already holds `derived`. */
|
|
134
|
+
export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null, key) {
|
|
128
135
|
assertTenantId('writeSessionEndHandoff', tenantId);
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
if (key)
|
|
137
|
+
continuityStamp(key); // a partial key is a caller bug, so fail loud rather than write nothing
|
|
138
|
+
const active = loadActiveTaskSnapshot(hippoRoot, tenantId, key);
|
|
139
|
+
const existing = loadLatestHandoff(hippoRoot, tenantId, sessionId, {}, key);
|
|
131
140
|
let snapshot;
|
|
132
141
|
let handoffEvidence = evidence;
|
|
133
142
|
if (active && active.session_id === sessionId) {
|
|
@@ -140,24 +149,13 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
|
|
|
140
149
|
// Only an earlier transcript read gives way; `hippo handoff create` and unmarked older handoffs keep winning.
|
|
141
150
|
if (!derived || (existing && existing.evidence?.derivedFrom !== 'transcript'))
|
|
142
151
|
return null;
|
|
152
|
+
// A retried session end reads the same transcript, so it must not add a second revision.
|
|
153
|
+
if (existing && existing.taskId === derived.task && existing.summary === derived.summary && existing.nextAction === derived.next_step)
|
|
154
|
+
return null;
|
|
143
155
|
snapshot = { ...derived, scope: null };
|
|
144
156
|
handoffEvidence = { ...evidence, derivedFrom: 'transcript' };
|
|
145
157
|
}
|
|
146
|
-
const
|
|
147
|
-
let outcome = null;
|
|
148
|
-
try {
|
|
149
|
-
// SAFETY: row's shape matches the single `content` column below.
|
|
150
|
-
const completeEvent = db.prepare(`
|
|
151
|
-
SELECT content FROM session_events
|
|
152
|
-
WHERE tenant_id = ? AND session_id = ? AND event_type = 'session_complete'
|
|
153
|
-
ORDER BY created_at DESC, id DESC LIMIT 1
|
|
154
|
-
`).get(tenantId, sessionId);
|
|
155
|
-
if (isHandoffOutcome(completeEvent?.content))
|
|
156
|
-
outcome = completeEvent.content;
|
|
157
|
-
}
|
|
158
|
-
finally {
|
|
159
|
-
closeHippoDb(db);
|
|
160
|
-
}
|
|
158
|
+
const outcome = sessionOutcome(hippoRoot, tenantId, sessionId);
|
|
161
159
|
// A same-task refresh carries forward envelope fields nobody cleared; a scope
|
|
162
160
|
// mismatch must not leak private metadata into an unscoped envelope.
|
|
163
161
|
const carryForward = existing != null && existing.taskId === snapshot.task
|
|
@@ -176,6 +174,21 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
|
|
|
176
174
|
constraints: carryForward ? existing.constraints : undefined,
|
|
177
175
|
targetRuntime: carryForward ? existing.targetRuntime : undefined,
|
|
178
176
|
cardId: carryForward ? existing.cardId : undefined,
|
|
179
|
-
});
|
|
177
|
+
}, key);
|
|
178
|
+
}
|
|
179
|
+
function sessionOutcome(hippoRoot, tenantId, sessionId) {
|
|
180
|
+
const db = openHippoDb(hippoRoot);
|
|
181
|
+
try {
|
|
182
|
+
// SAFETY: row's shape matches the single `content` column below.
|
|
183
|
+
const content = db.prepare(`
|
|
184
|
+
SELECT content FROM session_events
|
|
185
|
+
WHERE tenant_id = ? AND session_id = ? AND event_type = 'session_complete'
|
|
186
|
+
ORDER BY created_at DESC, id DESC LIMIT 1
|
|
187
|
+
`).get(tenantId, sessionId)?.content;
|
|
188
|
+
return isHandoffOutcome(content) ? content : null;
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
closeHippoDb(db);
|
|
192
|
+
}
|
|
180
193
|
}
|
|
181
194
|
//# sourceMappingURL=handoffs.js.map
|
|
@@ -24,7 +24,7 @@ export function loadIndex(hippoRoot) {
|
|
|
24
24
|
export function saveIndex(hippoRoot, index) {
|
|
25
25
|
const db = openStore(hippoRoot);
|
|
26
26
|
try {
|
|
27
|
-
db.exec('BEGIN');
|
|
27
|
+
db.exec('BEGIN IMMEDIATE');
|
|
28
28
|
try {
|
|
29
29
|
setMeta(db, 'last_retrieval_ids', JSON.stringify(index.last_retrieval_ids ?? []));
|
|
30
30
|
setMeta(db, 'last_trace_id', index.last_trace_id ?? '');
|
|
@@ -52,7 +52,7 @@ export function rebuildIndex(hippoRoot) {
|
|
|
52
52
|
const existingIds = new Set(db.prepare(`SELECT id FROM memories`).all().map((row) => row.id));
|
|
53
53
|
const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot).filter((entry) => !existingIds.has(entry.id));
|
|
54
54
|
if (legacyEntries.length > 0) {
|
|
55
|
-
db.exec('BEGIN');
|
|
55
|
+
db.exec('BEGIN IMMEDIATE');
|
|
56
56
|
try {
|
|
57
57
|
// Guard with per-row skip, like bootstrapLegacyStore: a stale markdown mirror could resurrect a
|
|
58
58
|
// rejected value here. Refusal audit is written inline because nothing rolls back on a skip.
|
package/dist/store/open.d.ts
CHANGED
|
@@ -6,7 +6,9 @@ export declare function getHippoRoot(cwd?: string, opts?: ResolveProjectIdentity
|
|
|
6
6
|
export declare function isInitialized(hippoRoot: string): boolean;
|
|
7
7
|
export declare function initStore(hippoRoot: string): void;
|
|
8
8
|
/** One open connection with init done on it, for callers who used to pay for `initStore` + a second `openHippoDb`. */
|
|
9
|
-
export declare function openStore(hippoRoot: string
|
|
9
|
+
export declare function openStore(hippoRoot: string, opts?: {
|
|
10
|
+
busyWaitMs?: number;
|
|
11
|
+
}): DatabaseSyncLike;
|
|
10
12
|
/** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
|
|
11
13
|
export declare const HALF_LIFE_BASE_META_KEY = "default_half_life_base";
|
|
12
14
|
/** `meta` key set once no memory of a decision, incident or other object sits on the old flat 90 days. */
|
package/dist/store/open.js
CHANGED
|
@@ -24,9 +24,9 @@ export function initStore(hippoRoot) {
|
|
|
24
24
|
closeHippoDb(openStore(hippoRoot));
|
|
25
25
|
}
|
|
26
26
|
/** One open connection with init done on it, for callers who used to pay for `initStore` + a second `openHippoDb`. */
|
|
27
|
-
export function openStore(hippoRoot) {
|
|
27
|
+
export function openStore(hippoRoot, opts) {
|
|
28
28
|
ensureMirrorDirectories(hippoRoot);
|
|
29
|
-
const db = openHippoDb(hippoRoot);
|
|
29
|
+
const db = openHippoDb(hippoRoot, opts);
|
|
30
30
|
try {
|
|
31
31
|
const bootstrapped = bootstrapLegacyStore(db, hippoRoot);
|
|
32
32
|
if (bootstrapped) {
|
|
@@ -74,7 +74,7 @@ function bootstrapLegacyStore(db, hippoRoot) {
|
|
|
74
74
|
const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot);
|
|
75
75
|
if (legacyEntries.length === 0)
|
|
76
76
|
return false;
|
|
77
|
-
db.exec('BEGIN');
|
|
77
|
+
db.exec('BEGIN IMMEDIATE');
|
|
78
78
|
try {
|
|
79
79
|
importLegacyEntries(db, hippoRoot, legacyEntries);
|
|
80
80
|
importLegacyIndexAndStats(db, hippoRoot);
|
|
@@ -15,17 +15,19 @@ import { type DatabaseSyncLike } from '../db.js';
|
|
|
15
15
|
* surface (not re-exported from `src/index.ts`). Subject to change. */
|
|
16
16
|
export type RecallScopeFilter = {
|
|
17
17
|
mode: 'default-deny';
|
|
18
|
+
ownScope?: string;
|
|
18
19
|
} | {
|
|
19
20
|
mode: 'exact';
|
|
20
21
|
value: string;
|
|
21
22
|
} | {
|
|
22
23
|
mode: 'default-deny-or-exact';
|
|
23
24
|
value: string;
|
|
25
|
+
ownScope?: string;
|
|
24
26
|
};
|
|
25
27
|
/** One project name, or every name a project's rows carry. */
|
|
26
28
|
export type OriginFilter = string | readonly string[];
|
|
27
|
-
/** Scope rule for recall: none requested is default-deny; 'exact' narrows to the request; 'additive' adds it to the default set. */
|
|
28
|
-
export declare function recallScopeFilter(requestedScope: string | undefined, mode: 'exact' | 'additive'): RecallScopeFilter;
|
|
29
|
+
/** Scope rule for recall: none requested is default-deny; 'exact' narrows to the request; 'additive' adds it to the default set, unless it is personal, which only `ownScope` opens. */
|
|
30
|
+
export declare function recallScopeFilter(requestedScope: string | undefined, mode: 'exact' | 'additive', ownScope?: string): RecallScopeFilter;
|
|
29
31
|
/**
|
|
30
32
|
* Load likely search candidates directly from SQLite.
|
|
31
33
|
* Uses FTS5 when available, falls back to LIKE matching, then full-store fallback.
|
|
@@ -38,7 +40,7 @@ export declare function loadSearchEntries(hippoRoot: string, query: string, limi
|
|
|
38
40
|
* Recall-mode loader. Pushes the recall-side scope predicate into SQL so
|
|
39
41
|
* `unknown:legacy` cannot leak via any consumer that hasn't remembered to re-filter.
|
|
40
42
|
*
|
|
41
|
-
* - `requestedScope` undefined / '': default-deny on `unknown:legacy
|
|
43
|
+
* - `requestedScope` undefined / '': default-deny on `unknown:legacy`, admitting `ownScope`, the caller's personal scope.
|
|
42
44
|
* - `requestedScope` non-empty string: exact match on `m.scope = requestedScope`.
|
|
43
45
|
*
|
|
44
46
|
* Private scopes: SQL applies a conservative `NOT LIKE '%:private:%'` before the LIMIT window so private
|
|
@@ -51,8 +53,8 @@ export declare function loadSearchEntries(hippoRoot: string, query: string, limi
|
|
|
51
53
|
*
|
|
52
54
|
* `tenantId` is optional because searchBothHybrid's is; undefined means no tenant filter.
|
|
53
55
|
*/
|
|
54
|
-
export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter): MemoryEntry[];
|
|
55
|
-
export declare function loadRecallSearchEntriesFromDb(db: DatabaseSyncLike, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter): MemoryEntry[];
|
|
56
|
+
export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter, ownScope?: string): MemoryEntry[];
|
|
57
|
+
export declare function loadRecallSearchEntriesFromDb(db: DatabaseSyncLike, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter, ownScope?: string): MemoryEntry[];
|
|
56
58
|
/** Which rows the vector arm of hybrid search may add: the same tenant, scope and superseded rules as the lexical load. */
|
|
57
59
|
export interface VectorCandidateSpec {
|
|
58
60
|
tenantId?: string;
|
|
@@ -60,6 +62,7 @@ export interface VectorCandidateSpec {
|
|
|
60
62
|
includeSuperseded: boolean;
|
|
61
63
|
/** How many nearest rows to add; default 50. */
|
|
62
64
|
limit?: number;
|
|
65
|
+
origin?: OriginFilter;
|
|
63
66
|
}
|
|
64
67
|
/** The rows nearest `queryVector` that pass `spec`, nearest first. */
|
|
65
68
|
export declare function loadVectorCandidateEntries(hippoRoot: string, queryVector: readonly number[], spec: VectorCandidateSpec): MemoryEntry[];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { envForceLikePath } from '../env.js';
|
|
2
2
|
import { isFtsAvailable, closeHippoDb } from '../db.js';
|
|
3
3
|
import { tokenize } from '../tokenize.js';
|
|
4
|
-
import {
|
|
4
|
+
import { isPersonalScope, scopeAdmitSql } from '../recall-scope.js';
|
|
5
5
|
import { RAREST_TERM_COUNT, rarestPromptTerms } from '../prompt-recall.js';
|
|
6
6
|
import { log } from '../log.js';
|
|
7
7
|
import { originInSql } from '../project-identity.js';
|
|
@@ -15,14 +15,11 @@ function recallScopeClause(col, scopeFilter) {
|
|
|
15
15
|
return { sql: '', params: [] };
|
|
16
16
|
if (scopeFilter.mode === 'exact')
|
|
17
17
|
return { sql: ` AND ${col}scope = ?`, params: [scopeFilter.value] };
|
|
18
|
-
|
|
19
|
-
// window cut so private rows cannot starve admitted ones; the anchored JS regex stays the exact post-filter.
|
|
20
|
-
const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
|
|
21
|
-
const admitted = `${col}scope IS NULL OR (${col}scope NOT IN (${placeholders}) AND ${col}scope NOT LIKE '%:private:%')`;
|
|
18
|
+
const admit = scopeAdmitSql(col, scopeFilter.ownScope);
|
|
22
19
|
if (scopeFilter.mode === 'default-deny')
|
|
23
|
-
return { sql: ` AND
|
|
20
|
+
return { sql: ` AND ${admit.sql}`, params: admit.params };
|
|
24
21
|
// The trailing arm keeps a deliberately requested scope loadable, private or quarantined included.
|
|
25
|
-
return { sql: ` AND (${
|
|
22
|
+
return { sql: ` AND (${admit.sql} OR ${col}scope = ?)`, params: [...admit.params, scopeFilter.value] };
|
|
26
23
|
}
|
|
27
24
|
// In SQL, not after the window cut, so other projects' matches cannot crowd the project's own rows out of the LIMIT.
|
|
28
25
|
function withProject(scope, col, origin) {
|
|
@@ -32,11 +29,11 @@ function withProject(scope, col, origin) {
|
|
|
32
29
|
const originProjects = [origin].flat();
|
|
33
30
|
return { sql: `${scope.sql} AND (${col}origin_project = '' OR ${originInSql(originProjects, `${col}origin_project`)})`, params: [...scope.params, ...originProjects] };
|
|
34
31
|
}
|
|
35
|
-
/** Scope rule for recall: none requested is default-deny; 'exact' narrows to the request; 'additive' adds it to the default set. */
|
|
36
|
-
export function recallScopeFilter(requestedScope, mode) {
|
|
37
|
-
if (!requestedScope)
|
|
38
|
-
return { mode: 'default-deny' };
|
|
39
|
-
return mode === 'additive' ? { mode: 'default-deny-or-exact', value: requestedScope } : { mode: 'exact', value: requestedScope };
|
|
32
|
+
/** Scope rule for recall: none requested is default-deny; 'exact' narrows to the request; 'additive' adds it to the default set, unless it is personal, which only `ownScope` opens. */
|
|
33
|
+
export function recallScopeFilter(requestedScope, mode, ownScope) {
|
|
34
|
+
if (!requestedScope || (mode === 'additive' && isPersonalScope(requestedScope)))
|
|
35
|
+
return { mode: 'default-deny', ownScope };
|
|
36
|
+
return mode === 'additive' ? { mode: 'default-deny-or-exact', value: requestedScope, ownScope } : { mode: 'exact', value: requestedScope };
|
|
40
37
|
}
|
|
41
38
|
const FTS_QUERY_SYNTAX_RE = /fts5: syntax error|unterminated string/i;
|
|
42
39
|
function loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSuperseded = true, originProjects) {
|
|
@@ -158,7 +155,7 @@ export function loadSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDI
|
|
|
158
155
|
* Recall-mode loader. Pushes the recall-side scope predicate into SQL so
|
|
159
156
|
* `unknown:legacy` cannot leak via any consumer that hasn't remembered to re-filter.
|
|
160
157
|
*
|
|
161
|
-
* - `requestedScope` undefined / '': default-deny on `unknown:legacy
|
|
158
|
+
* - `requestedScope` undefined / '': default-deny on `unknown:legacy`, admitting `ownScope`, the caller's personal scope.
|
|
162
159
|
* - `requestedScope` non-empty string: exact match on `m.scope = requestedScope`.
|
|
163
160
|
*
|
|
164
161
|
* Private scopes: SQL applies a conservative `NOT LIKE '%:private:%'` before the LIMIT window so private
|
|
@@ -171,10 +168,10 @@ export function loadSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDI
|
|
|
171
168
|
*
|
|
172
169
|
* `tenantId` is optional because searchBothHybrid's is; undefined means no tenant filter.
|
|
173
170
|
*/
|
|
174
|
-
export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProjects) {
|
|
171
|
+
export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProjects, ownScope) {
|
|
175
172
|
const db = openStore(hippoRoot);
|
|
176
173
|
try {
|
|
177
|
-
return loadRecallSearchEntriesFromDb(db, query, limit, tenantId, requestedScope, explicitScopeMode, includeSuperseded, originProjects);
|
|
174
|
+
return loadRecallSearchEntriesFromDb(db, query, limit, tenantId, requestedScope, explicitScopeMode, includeSuperseded, originProjects, ownScope);
|
|
178
175
|
}
|
|
179
176
|
finally {
|
|
180
177
|
closeHippoDb(db);
|
|
@@ -182,12 +179,12 @@ export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH
|
|
|
182
179
|
}
|
|
183
180
|
// Split out so callers with an already-open db (the prompt-recall path) skip
|
|
184
181
|
// the initStore+open/close cycle per store per call.
|
|
185
|
-
export function loadRecallSearchEntriesFromDb(db, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProjects) {
|
|
186
|
-
return loadSearchRows(db, query, limit, tenantId, recallScopeFilter(requestedScope, explicitScopeMode), includeSuperseded, originProjects).map(rowToEntry);
|
|
182
|
+
export function loadRecallSearchEntriesFromDb(db, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProjects, ownScope) {
|
|
183
|
+
return loadSearchRows(db, query, limit, tenantId, recallScopeFilter(requestedScope, explicitScopeMode, ownScope), includeSuperseded, originProjects).map(rowToEntry);
|
|
187
184
|
}
|
|
188
185
|
/** The rows nearest `queryVector` that pass `spec`, nearest first. */
|
|
189
186
|
export function loadVectorCandidateEntries(hippoRoot, queryVector, spec) {
|
|
190
|
-
const scope = recallScopeClause('m.', spec.scope);
|
|
187
|
+
const scope = withProject(recallScopeClause('m.', spec.scope), 'm.', spec.origin);
|
|
191
188
|
const tenant = spec.tenantId !== undefined ? ' AND m.tenant_id = ?' : '';
|
|
192
189
|
const current = spec.includeSuperseded ? '' : ' AND m.superseded_by IS NULL';
|
|
193
190
|
const params = [...(spec.tenantId !== undefined ? [spec.tenantId] : []), ...scope.params];
|
package/dist/store/sessions.d.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import { type TaskSnapshot, type SessionEvent } from './rows.js';
|
|
2
|
-
|
|
2
|
+
/** A shared-store caller's task state bucket: its owner and every name its project's rows carry. */
|
|
3
|
+
export interface ContinuityKey {
|
|
4
|
+
readonly owner: string;
|
|
5
|
+
readonly project: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
export interface ContinuityFilter {
|
|
8
|
+
readonly sql: string;
|
|
9
|
+
readonly params: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
export interface ContinuityStamp {
|
|
12
|
+
readonly owner: string;
|
|
13
|
+
readonly origin: string;
|
|
14
|
+
}
|
|
15
|
+
/** All or nothing: a key missing its owner or every project name matches no row, never the tenant's newest. */
|
|
16
|
+
export declare function continuityWhere(key: ContinuityKey): ContinuityFilter;
|
|
17
|
+
/** The columns a keyed write stamps; a partial key throws, since a row it wrote would match no reader. */
|
|
18
|
+
export declare function continuityStamp(key: ContinuityKey): ContinuityStamp;
|
|
19
|
+
type SnapshotInput = {
|
|
3
20
|
task: string;
|
|
4
21
|
summary: string;
|
|
5
22
|
next_step: string;
|
|
6
23
|
source?: string;
|
|
7
24
|
session_id?: string | null;
|
|
8
25
|
scope?: string | null;
|
|
9
|
-
}
|
|
10
|
-
export declare function
|
|
26
|
+
};
|
|
27
|
+
export declare function saveActiveTaskSnapshot(hippoRoot: string, tenantId: string, snapshot: SnapshotInput, key?: ContinuityKey): TaskSnapshot;
|
|
28
|
+
export declare function loadActiveTaskSnapshot(hippoRoot: string, tenantId: string, key?: ContinuityKey): TaskSnapshot | null;
|
|
11
29
|
/**
|
|
12
30
|
* Default freshness bound for AMBIENT active-task-snapshot reads: 72h, chosen over 48h so
|
|
13
31
|
* a Friday-evening orphan still offers continuity on Monday morning.
|
|
@@ -41,18 +59,18 @@ export declare const SNAPSHOT_AMBIENT_MAX_AGE_MS: number;
|
|
|
41
59
|
export declare function loadFreshActiveTaskSnapshot(hippoRoot: string, tenantId: string, opts?: {
|
|
42
60
|
maxAgeMs?: number;
|
|
43
61
|
sessionId?: string | null;
|
|
44
|
-
}): TaskSnapshot | null;
|
|
62
|
+
}, key?: ContinuityKey): TaskSnapshot | null;
|
|
45
63
|
export declare function clearActiveTaskSnapshot(hippoRoot: string, tenantId: string, clearedStatus?: string): boolean;
|
|
46
64
|
/**
|
|
47
65
|
* Close the `active` task snapshot(s) owned by `sessionId`, for the
|
|
48
66
|
* session-end death path.
|
|
49
|
-
* Only one `active` row exists per tenant in practice (supersession happens
|
|
67
|
+
* Only one `active` row exists per tenant (per owner and project when keyed) in practice (supersession happens
|
|
50
68
|
* at save), but the WHERE clause scopes on `session_id` too — not just
|
|
51
69
|
* `status='active' AND tenant_id=?` — so an ending session can never close a
|
|
52
70
|
* different, newer session's active snapshot. Returns the number of rows
|
|
53
71
|
* closed (0 when no active row is owned by `sessionId`).
|
|
54
72
|
*/
|
|
55
|
-
export declare function closeTaskSnapshotsForSession(hippoRoot: string, tenantId: string, sessionId: string, status?: string): number;
|
|
73
|
+
export declare function closeTaskSnapshotsForSession(hippoRoot: string, tenantId: string, sessionId: string, status?: string, key?: ContinuityKey): number;
|
|
56
74
|
export declare function appendSessionEvent(hippoRoot: string, tenantId: string, event: {
|
|
57
75
|
session_id: string;
|
|
58
76
|
event_type: string;
|
|
@@ -79,4 +97,5 @@ export declare function findPromotableSessions(hippoRoot: string, tenantId: stri
|
|
|
79
97
|
* already exists.
|
|
80
98
|
*/
|
|
81
99
|
export declare function traceExistsForSession(hippoRoot: string, tenantId: string, session_id: string): boolean;
|
|
100
|
+
export {};
|
|
82
101
|
//# sourceMappingURL=sessions.d.ts.map
|
package/dist/store/sessions.js
CHANGED
|
@@ -1,22 +1,55 @@
|
|
|
1
|
+
import { isSharedStore } from '../config.js';
|
|
1
2
|
import { closeHippoDb } from '../db.js';
|
|
3
|
+
import { raiseMinBinary } from '../db/meta.js';
|
|
4
|
+
import { originInSql } from '../project-identity.js';
|
|
2
5
|
import { assertTenantId } from '../tenant.js';
|
|
3
6
|
import { redactSecretsStrict } from '../secret-detect.js';
|
|
7
|
+
import { TASK_OWNER_MIN_BINARY } from '../version.js';
|
|
4
8
|
import { rowToTaskSnapshot, rowToSessionEvent, } from './rows.js';
|
|
5
9
|
import { writeActiveTaskMirror, removeActiveTaskMirror, writeRecentSessionMirror } from './mirrors.js';
|
|
6
10
|
import { openStore } from './open.js';
|
|
7
|
-
|
|
11
|
+
/** All or nothing: a key missing its owner or every project name matches no row, never the tenant's newest. */
|
|
12
|
+
export function continuityWhere(key) {
|
|
13
|
+
const names = key.project.filter((n) => n !== '');
|
|
14
|
+
if (key.owner === '' || names.length === 0)
|
|
15
|
+
return { sql: '0', params: [] };
|
|
16
|
+
return { sql: `owner_subject = ? AND ${originInSql(names)}`, params: [key.owner, ...names] };
|
|
17
|
+
}
|
|
18
|
+
/** The columns a keyed write stamps; a partial key throws, since a row it wrote would match no reader. */
|
|
19
|
+
export function continuityStamp(key) {
|
|
20
|
+
const origin = key.project.find((n) => n !== '');
|
|
21
|
+
if (key.owner === '' || origin === undefined)
|
|
22
|
+
throw new Error('continuity key needs an owner and a project');
|
|
23
|
+
return { owner: key.owner, origin };
|
|
24
|
+
}
|
|
25
|
+
// An unkeyed write acts on the tenant's newest or every active row, which on a shared store belong to other owners.
|
|
26
|
+
function assertKeyedOnSharedStore(fn, hippoRoot, key) {
|
|
27
|
+
if (!key && isSharedStore(hippoRoot))
|
|
28
|
+
throw new Error(`${fn}: a shared store keeps one task snapshot per owner and project, so this write needs their continuity key`);
|
|
29
|
+
}
|
|
30
|
+
function insertSnapshot(db, tenantId, snapshot, now, stamp) {
|
|
31
|
+
const result = db.prepare(`
|
|
32
|
+
INSERT INTO task_snapshots(task, summary, next_step, status, source, session_id, scope, tenant_id, created_at, updated_at, owner_subject, origin_project)
|
|
33
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
|
|
34
|
+
`).run(redactSecretsStrict(snapshot.task), redactSecretsStrict(snapshot.summary), redactSecretsStrict(snapshot.next_step), snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now, stamp?.owner ?? null, stamp?.origin ?? null);
|
|
35
|
+
return Number(result.lastInsertRowid ?? 0);
|
|
36
|
+
}
|
|
37
|
+
export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot, key) {
|
|
8
38
|
assertTenantId('saveActiveTaskSnapshot', tenantId);
|
|
39
|
+
assertKeyedOnSharedStore('saveActiveTaskSnapshot', hippoRoot, key);
|
|
40
|
+
const stamp = key ? continuityStamp(key) : null;
|
|
41
|
+
const owned = key ? continuityWhere(key) : null;
|
|
9
42
|
const db = openStore(hippoRoot);
|
|
10
43
|
const now = new Date().toISOString();
|
|
11
44
|
try {
|
|
12
|
-
db.exec('BEGIN');
|
|
13
|
-
db.prepare(`UPDATE task_snapshots SET status = 'superseded', updated_at = ? WHERE status = 'active' AND tenant_id =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
45
|
+
db.exec('BEGIN IMMEDIATE');
|
|
46
|
+
db.prepare(`UPDATE task_snapshots SET status = 'superseded', updated_at = ? WHERE status = 'active' AND tenant_id = ?${owned ? ` AND ${owned.sql}` : ''}`)
|
|
47
|
+
.run(now, tenantId, ...(owned?.params ?? []));
|
|
48
|
+
const id = insertSnapshot(db, tenantId, snapshot, now, stamp);
|
|
49
|
+
// An older binary's tenant-wide supersede would close other owners' rows, so the first owner row shuts it out.
|
|
50
|
+
if (stamp)
|
|
51
|
+
raiseMinBinary(db, TASK_OWNER_MIN_BINARY);
|
|
18
52
|
db.exec('COMMIT');
|
|
19
|
-
const id = Number(result.lastInsertRowid ?? 0);
|
|
20
53
|
// SAFETY: row's shape matches the ten columns named in the SELECT above.
|
|
21
54
|
const row = db.prepare(`
|
|
22
55
|
SELECT id, task, summary, next_step, status, source, session_id, scope, created_at, updated_at
|
|
@@ -27,7 +60,9 @@ export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
|
|
|
27
60
|
throw new Error('Failed to reload saved active task snapshot');
|
|
28
61
|
}
|
|
29
62
|
const loaded = rowToTaskSnapshot(row);
|
|
30
|
-
|
|
63
|
+
// The mirror is one file per store, so only the local, unkeyed path writes it.
|
|
64
|
+
if (!key)
|
|
65
|
+
writeActiveTaskMirror(hippoRoot, tenantId, loaded);
|
|
31
66
|
return loaded;
|
|
32
67
|
}
|
|
33
68
|
catch (error) {
|
|
@@ -43,24 +78,27 @@ export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
|
|
|
43
78
|
closeHippoDb(db);
|
|
44
79
|
}
|
|
45
80
|
}
|
|
46
|
-
export function loadActiveTaskSnapshot(hippoRoot, tenantId) {
|
|
81
|
+
export function loadActiveTaskSnapshot(hippoRoot, tenantId, key) {
|
|
47
82
|
assertTenantId('loadActiveTaskSnapshot', tenantId);
|
|
83
|
+
const owned = key ? continuityWhere(key) : null;
|
|
48
84
|
const db = openStore(hippoRoot);
|
|
49
85
|
try {
|
|
50
86
|
// SAFETY: row's shape matches the ten columns named in the SELECT above.
|
|
51
87
|
const row = db.prepare(`
|
|
52
88
|
SELECT id, task, summary, next_step, status, source, session_id, scope, created_at, updated_at
|
|
53
89
|
FROM task_snapshots
|
|
54
|
-
WHERE status = 'active' AND tenant_id = ?
|
|
90
|
+
WHERE status = 'active' AND tenant_id = ?${owned ? ` AND ${owned.sql}` : ''}
|
|
55
91
|
ORDER BY updated_at DESC, id DESC
|
|
56
92
|
LIMIT 1
|
|
57
|
-
`).get(tenantId);
|
|
93
|
+
`).get(tenantId, ...(owned?.params ?? []));
|
|
58
94
|
if (!row) {
|
|
59
|
-
|
|
95
|
+
if (!key)
|
|
96
|
+
removeActiveTaskMirror(hippoRoot, tenantId);
|
|
60
97
|
return null;
|
|
61
98
|
}
|
|
62
99
|
const loaded = rowToTaskSnapshot(row);
|
|
63
|
-
|
|
100
|
+
if (!key)
|
|
101
|
+
writeActiveTaskMirror(hippoRoot, tenantId, loaded);
|
|
64
102
|
return loaded;
|
|
65
103
|
}
|
|
66
104
|
finally {
|
|
@@ -103,8 +141,8 @@ function isNonEmptySessionId(value) {
|
|
|
103
141
|
*
|
|
104
142
|
* No SQL change — age derives from the existing `updated_at` column.
|
|
105
143
|
*/
|
|
106
|
-
export function loadFreshActiveTaskSnapshot(hippoRoot, tenantId, opts = {}) {
|
|
107
|
-
const snapshot = loadActiveTaskSnapshot(hippoRoot, tenantId);
|
|
144
|
+
export function loadFreshActiveTaskSnapshot(hippoRoot, tenantId, opts = {}, key) {
|
|
145
|
+
const snapshot = loadActiveTaskSnapshot(hippoRoot, tenantId, key);
|
|
108
146
|
if (!snapshot)
|
|
109
147
|
return null;
|
|
110
148
|
const callerSessionId = opts.sessionId;
|
|
@@ -119,6 +157,7 @@ export function loadFreshActiveTaskSnapshot(hippoRoot, tenantId, opts = {}) {
|
|
|
119
157
|
}
|
|
120
158
|
export function clearActiveTaskSnapshot(hippoRoot, tenantId, clearedStatus = 'cleared') {
|
|
121
159
|
assertTenantId('clearActiveTaskSnapshot', tenantId);
|
|
160
|
+
assertKeyedOnSharedStore('clearActiveTaskSnapshot', hippoRoot, undefined);
|
|
122
161
|
const db = openStore(hippoRoot);
|
|
123
162
|
const now = new Date().toISOString();
|
|
124
163
|
try {
|
|
@@ -139,18 +178,21 @@ export function clearActiveTaskSnapshot(hippoRoot, tenantId, clearedStatus = 'cl
|
|
|
139
178
|
/**
|
|
140
179
|
* Close the `active` task snapshot(s) owned by `sessionId`, for the
|
|
141
180
|
* session-end death path.
|
|
142
|
-
* Only one `active` row exists per tenant in practice (supersession happens
|
|
181
|
+
* Only one `active` row exists per tenant (per owner and project when keyed) in practice (supersession happens
|
|
143
182
|
* at save), but the WHERE clause scopes on `session_id` too — not just
|
|
144
183
|
* `status='active' AND tenant_id=?` — so an ending session can never close a
|
|
145
184
|
* different, newer session's active snapshot. Returns the number of rows
|
|
146
185
|
* closed (0 when no active row is owned by `sessionId`).
|
|
147
186
|
*/
|
|
148
|
-
export function closeTaskSnapshotsForSession(hippoRoot, tenantId, sessionId, status = 'session-ended') {
|
|
187
|
+
export function closeTaskSnapshotsForSession(hippoRoot, tenantId, sessionId, status = 'session-ended', key) {
|
|
149
188
|
assertTenantId('closeTaskSnapshotsForSession', tenantId);
|
|
189
|
+
if (key)
|
|
190
|
+
continuityStamp(key); // a partial key is a caller bug, so fail loud rather than close nothing
|
|
191
|
+
const owned = key ? continuityWhere(key) : null;
|
|
150
192
|
const db = openStore(hippoRoot);
|
|
151
193
|
const now = new Date().toISOString();
|
|
152
194
|
try {
|
|
153
|
-
const result = db.prepare(`UPDATE task_snapshots SET status = ?, updated_at = ? WHERE status = 'active' AND tenant_id = ? AND session_id =
|
|
195
|
+
const result = db.prepare(`UPDATE task_snapshots SET status = ?, updated_at = ? WHERE status = 'active' AND tenant_id = ? AND session_id = ?${owned ? ` AND ${owned.sql}` : ''}`).run(status, now, tenantId, sessionId, ...(owned?.params ?? []));
|
|
154
196
|
return Number(result.changes ?? 0);
|
|
155
197
|
}
|
|
156
198
|
finally {
|
|
@@ -65,7 +65,7 @@ export interface RebuildPatch {
|
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
67
|
* Apply a rebuild result to a dirty summary. Atomic: one
|
|
68
|
-
* prepared UPDATE statement plus syncFtsRow inside one
|
|
68
|
+
* prepared UPDATE statement plus syncFtsRow inside one write scope.
|
|
69
69
|
* WHERE includes `AND summary_dirty = 1` so concurrent sleep's race-loser
|
|
70
70
|
* becomes a no-op (no rebuild_count bump, no audit row).
|
|
71
71
|
*
|