@cleocode/core 2026.3.71 → 2026.3.72
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/dist/cleo.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +163 -1
- package/dist/index.js.map +4 -4
- package/dist/sessions/snapshot.d.ts +125 -0
- package/dist/sessions/snapshot.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/cleo.ts +12 -0
- package/src/index.ts +10 -0
- package/src/sessions/snapshot.ts +341 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session snapshot serialization and restoration.
|
|
3
|
+
*
|
|
4
|
+
* Provides `serializeSession()` and `restoreSession()` for full session
|
|
5
|
+
* state capture and hydration. Designed for CleoOS agent session persistence:
|
|
6
|
+
* when an agent dies, CleoOS serializes the session snapshot, and when a new
|
|
7
|
+
* agent connects, it restores the snapshot to resume seamlessly.
|
|
8
|
+
*
|
|
9
|
+
* The snapshot captures everything needed to resume work:
|
|
10
|
+
* - Full session object (scope, taskWork, notes, stats)
|
|
11
|
+
* - Handoff data (completed tasks, decisions, next suggested)
|
|
12
|
+
* - Brain context (recent observations linked to this session)
|
|
13
|
+
* - Active task state (current focus, blockers)
|
|
14
|
+
*
|
|
15
|
+
* @module sessions/snapshot
|
|
16
|
+
*/
|
|
17
|
+
import type { Session } from '@cleocode/contracts';
|
|
18
|
+
import type { DataAccessor } from '../store/data-accessor.js';
|
|
19
|
+
import { type HandoffData } from './handoff.js';
|
|
20
|
+
/** Version of the snapshot schema. Increment on breaking changes. */
|
|
21
|
+
export declare const SNAPSHOT_VERSION = 1;
|
|
22
|
+
/** A decision recorded during the session. */
|
|
23
|
+
export interface SnapshotDecision {
|
|
24
|
+
/** Decision text. */
|
|
25
|
+
decision: string;
|
|
26
|
+
/** Rationale for the decision. */
|
|
27
|
+
rationale: string;
|
|
28
|
+
/** Task ID context. */
|
|
29
|
+
taskId: string;
|
|
30
|
+
/** When the decision was recorded. */
|
|
31
|
+
recordedAt: string;
|
|
32
|
+
}
|
|
33
|
+
/** Brain observation linked to this session. */
|
|
34
|
+
export interface SnapshotObservation {
|
|
35
|
+
/** Observation ID. */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Observation text. */
|
|
38
|
+
text: string;
|
|
39
|
+
/** Observation type (discovery, change, feature, etc.). */
|
|
40
|
+
type: string;
|
|
41
|
+
/** When the observation was created. */
|
|
42
|
+
createdAt: string;
|
|
43
|
+
}
|
|
44
|
+
/** Active task context at snapshot time. */
|
|
45
|
+
export interface SnapshotTaskContext {
|
|
46
|
+
/** Task ID currently in focus. */
|
|
47
|
+
taskId: string;
|
|
48
|
+
/** Task title. */
|
|
49
|
+
title: string;
|
|
50
|
+
/** Task status. */
|
|
51
|
+
status: string;
|
|
52
|
+
/** Task priority. */
|
|
53
|
+
priority: string;
|
|
54
|
+
/** Task description (truncated to save space). */
|
|
55
|
+
description: string;
|
|
56
|
+
/** Acceptance criteria if any. */
|
|
57
|
+
acceptance?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Complete session snapshot — everything needed to resume.
|
|
61
|
+
*
|
|
62
|
+
* This is the serialization format. It is JSON-safe and can be stored
|
|
63
|
+
* in a file, database column, or transmitted over the network.
|
|
64
|
+
*/
|
|
65
|
+
export interface SessionSnapshot {
|
|
66
|
+
/** Schema version for forward compatibility. */
|
|
67
|
+
version: number;
|
|
68
|
+
/** When the snapshot was created. */
|
|
69
|
+
capturedAt: string;
|
|
70
|
+
/** The full session object. */
|
|
71
|
+
session: Session;
|
|
72
|
+
/** Computed handoff data. */
|
|
73
|
+
handoff: HandoffData;
|
|
74
|
+
/** Decisions recorded in this session. */
|
|
75
|
+
decisions: SnapshotDecision[];
|
|
76
|
+
/** Recent brain observations linked to this session. */
|
|
77
|
+
observations: SnapshotObservation[];
|
|
78
|
+
/** Current task context (if a task is focused). */
|
|
79
|
+
activeTask: SnapshotTaskContext | null;
|
|
80
|
+
/** Session duration in minutes at snapshot time. */
|
|
81
|
+
durationMinutes: number;
|
|
82
|
+
}
|
|
83
|
+
/** Options for serializing a session. */
|
|
84
|
+
export interface SerializeOptions {
|
|
85
|
+
/** Session ID to serialize. If omitted, uses the active session. */
|
|
86
|
+
sessionId?: string;
|
|
87
|
+
/** Maximum number of brain observations to include. Default: 10. */
|
|
88
|
+
maxObservations?: number;
|
|
89
|
+
/** Maximum description length for active task. Default: 500. */
|
|
90
|
+
maxDescriptionLength?: number;
|
|
91
|
+
}
|
|
92
|
+
/** Options for restoring a session. */
|
|
93
|
+
export interface RestoreOptions {
|
|
94
|
+
/** Agent identifier for the new agent taking over. */
|
|
95
|
+
agent?: string;
|
|
96
|
+
/** Whether to resume the session (set status to active). Default: true. */
|
|
97
|
+
activate?: boolean;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Serialize a session into a complete snapshot.
|
|
101
|
+
*
|
|
102
|
+
* Captures the full session state including handoff data, decisions,
|
|
103
|
+
* brain observations, and active task context. The result is a
|
|
104
|
+
* JSON-serializable object that can be stored and later restored.
|
|
105
|
+
*
|
|
106
|
+
* @param projectRoot - Project root directory
|
|
107
|
+
* @param options - Serialization options
|
|
108
|
+
* @returns Complete session snapshot
|
|
109
|
+
*/
|
|
110
|
+
export declare function serializeSession(projectRoot: string, options?: SerializeOptions, accessor?: DataAccessor): Promise<SessionSnapshot>;
|
|
111
|
+
/**
|
|
112
|
+
* Restore a session from a snapshot.
|
|
113
|
+
*
|
|
114
|
+
* Hydrates a session from a previously serialized snapshot. The session
|
|
115
|
+
* is re-inserted into the sessions store and optionally activated.
|
|
116
|
+
* Brain observations from the snapshot are NOT re-inserted (they already
|
|
117
|
+
* exist in brain.db) — only the session state is restored.
|
|
118
|
+
*
|
|
119
|
+
* @param projectRoot - Project root directory
|
|
120
|
+
* @param snapshot - The snapshot to restore from
|
|
121
|
+
* @param options - Restoration options
|
|
122
|
+
* @returns The restored session
|
|
123
|
+
*/
|
|
124
|
+
export declare function restoreSession(projectRoot: string, snapshot: SessionSnapshot, options?: RestoreOptions, accessor?: DataAccessor): Promise<Session>;
|
|
125
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../../src/sessions/snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAG9D,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAMhE,qEAAqE;AACrE,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC,8CAA8C;AAC9C,MAAM,WAAW,gBAAgB;IAC/B,qBAAqB;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,gDAAgD;AAChD,MAAM,WAAW,mBAAmB;IAClC,sBAAsB;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB;IAClC,kCAAkC;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,mBAAmB;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,kCAAkC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,+BAA+B;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,6BAA6B;IAC7B,OAAO,EAAE,WAAW,CAAC;IACrB,0CAA0C;IAC1C,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,wDAAwD;IACxD,YAAY,EAAE,mBAAmB,EAAE,CAAC;IACpC,mDAAmD;IACnD,UAAU,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACvC,oDAAoD;IACpD,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,yCAAyC;AACzC,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gEAAgE;IAChE,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,uCAAuC;AACvC,MAAM,WAAW,cAAc;IAC7B,sDAAsD;IACtD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAMD;;;;;;;;;;GAUG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,GAAE,gBAAqB,EAC9B,QAAQ,CAAC,EAAE,YAAY,GACtB,OAAO,CAAC,eAAe,CAAC,CAoG1B;AAMD;;;;;;;;;;;;GAYG;AACH,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,eAAe,EACzB,OAAO,GAAE,cAAmB,EAC5B,QAAQ,CAAC,EAAE,YAAY,GACtB,OAAO,CAAC,OAAO,CAAC,CAkFlB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/core",
|
|
3
|
-
"version": "2026.3.
|
|
3
|
+
"version": "2026.3.72",
|
|
4
4
|
"description": "CLEO core business logic kernel — tasks, sessions, memory, orchestration, lifecycle, with bundled SQLite store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"write-file-atomic": "^6.0.0",
|
|
38
38
|
"yaml": "^2.8.2",
|
|
39
39
|
"zod": "^3.25.76",
|
|
40
|
-
"@cleocode/
|
|
41
|
-
"@cleocode/
|
|
42
|
-
"@cleocode/
|
|
43
|
-
"@cleocode/
|
|
40
|
+
"@cleocode/adapters": "2026.3.72",
|
|
41
|
+
"@cleocode/agents": "2026.3.72",
|
|
42
|
+
"@cleocode/contracts": "2026.3.72",
|
|
43
|
+
"@cleocode/skills": "2026.3.72"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=24.0.0"
|
package/src/cleo.ts
CHANGED
|
@@ -129,6 +129,8 @@ import {
|
|
|
129
129
|
startSession,
|
|
130
130
|
suspendSession,
|
|
131
131
|
} from './sessions/index.js';
|
|
132
|
+
// Session snapshots (Phase 3: persistence)
|
|
133
|
+
import { restoreSession, serializeSession } from './sessions/snapshot.js';
|
|
132
134
|
// Sticky
|
|
133
135
|
import {
|
|
134
136
|
addSticky,
|
|
@@ -289,6 +291,16 @@ export class Cleo {
|
|
|
289
291
|
contextDrift: (p) => getContextDrift(root, p),
|
|
290
292
|
decisionLog: (p) => getDecisionLog(root, { sessionId: p?.sessionId, taskId: p?.taskId }),
|
|
291
293
|
lastHandoff: (scope) => getLastHandoff(root, scope),
|
|
294
|
+
serialize: (p) =>
|
|
295
|
+
serializeSession(root, {
|
|
296
|
+
sessionId: p?.sessionId,
|
|
297
|
+
maxObservations: p?.maxObservations,
|
|
298
|
+
}),
|
|
299
|
+
restore: (snapshot, p) =>
|
|
300
|
+
restoreSession(root, snapshot as import('./sessions/snapshot.js').SessionSnapshot, {
|
|
301
|
+
agent: p?.agent,
|
|
302
|
+
activate: p?.activate,
|
|
303
|
+
}),
|
|
292
304
|
};
|
|
293
305
|
}
|
|
294
306
|
|
package/src/index.ts
CHANGED
|
@@ -299,6 +299,16 @@ export {
|
|
|
299
299
|
sessionStatus,
|
|
300
300
|
startSession,
|
|
301
301
|
} from './sessions/index.js';
|
|
302
|
+
export type {
|
|
303
|
+
RestoreOptions,
|
|
304
|
+
SerializeOptions,
|
|
305
|
+
SessionSnapshot,
|
|
306
|
+
SnapshotDecision,
|
|
307
|
+
SnapshotObservation,
|
|
308
|
+
SnapshotTaskContext,
|
|
309
|
+
} from './sessions/snapshot.js';
|
|
310
|
+
// Session snapshots (Phase 3: persistence)
|
|
311
|
+
export { restoreSession, serializeSession } from './sessions/snapshot.js';
|
|
302
312
|
export { getMigrationStatus as getSystemMigrationStatus } from './system/migrate.js';
|
|
303
313
|
export { checkStorageMigration } from './system/storage-preflight.js';
|
|
304
314
|
// Task work
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session snapshot serialization and restoration.
|
|
3
|
+
*
|
|
4
|
+
* Provides `serializeSession()` and `restoreSession()` for full session
|
|
5
|
+
* state capture and hydration. Designed for CleoOS agent session persistence:
|
|
6
|
+
* when an agent dies, CleoOS serializes the session snapshot, and when a new
|
|
7
|
+
* agent connects, it restores the snapshot to resume seamlessly.
|
|
8
|
+
*
|
|
9
|
+
* The snapshot captures everything needed to resume work:
|
|
10
|
+
* - Full session object (scope, taskWork, notes, stats)
|
|
11
|
+
* - Handoff data (completed tasks, decisions, next suggested)
|
|
12
|
+
* - Brain context (recent observations linked to this session)
|
|
13
|
+
* - Active task state (current focus, blockers)
|
|
14
|
+
*
|
|
15
|
+
* @module sessions/snapshot
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Session } from '@cleocode/contracts';
|
|
19
|
+
import { ExitCode } from '@cleocode/contracts';
|
|
20
|
+
import { CleoError } from '../errors.js';
|
|
21
|
+
import type { DataAccessor } from '../store/data-accessor.js';
|
|
22
|
+
import { getAccessor } from '../store/data-accessor.js';
|
|
23
|
+
import { getDecisionLog } from './decisions.js';
|
|
24
|
+
import { computeHandoff, type HandoffData } from './handoff.js';
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Snapshot types
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/** Version of the snapshot schema. Increment on breaking changes. */
|
|
31
|
+
export const SNAPSHOT_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
/** A decision recorded during the session. */
|
|
34
|
+
export interface SnapshotDecision {
|
|
35
|
+
/** Decision text. */
|
|
36
|
+
decision: string;
|
|
37
|
+
/** Rationale for the decision. */
|
|
38
|
+
rationale: string;
|
|
39
|
+
/** Task ID context. */
|
|
40
|
+
taskId: string;
|
|
41
|
+
/** When the decision was recorded. */
|
|
42
|
+
recordedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Brain observation linked to this session. */
|
|
46
|
+
export interface SnapshotObservation {
|
|
47
|
+
/** Observation ID. */
|
|
48
|
+
id: string;
|
|
49
|
+
/** Observation text. */
|
|
50
|
+
text: string;
|
|
51
|
+
/** Observation type (discovery, change, feature, etc.). */
|
|
52
|
+
type: string;
|
|
53
|
+
/** When the observation was created. */
|
|
54
|
+
createdAt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Active task context at snapshot time. */
|
|
58
|
+
export interface SnapshotTaskContext {
|
|
59
|
+
/** Task ID currently in focus. */
|
|
60
|
+
taskId: string;
|
|
61
|
+
/** Task title. */
|
|
62
|
+
title: string;
|
|
63
|
+
/** Task status. */
|
|
64
|
+
status: string;
|
|
65
|
+
/** Task priority. */
|
|
66
|
+
priority: string;
|
|
67
|
+
/** Task description (truncated to save space). */
|
|
68
|
+
description: string;
|
|
69
|
+
/** Acceptance criteria if any. */
|
|
70
|
+
acceptance?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Complete session snapshot — everything needed to resume.
|
|
75
|
+
*
|
|
76
|
+
* This is the serialization format. It is JSON-safe and can be stored
|
|
77
|
+
* in a file, database column, or transmitted over the network.
|
|
78
|
+
*/
|
|
79
|
+
export interface SessionSnapshot {
|
|
80
|
+
/** Schema version for forward compatibility. */
|
|
81
|
+
version: number;
|
|
82
|
+
/** When the snapshot was created. */
|
|
83
|
+
capturedAt: string;
|
|
84
|
+
/** The full session object. */
|
|
85
|
+
session: Session;
|
|
86
|
+
/** Computed handoff data. */
|
|
87
|
+
handoff: HandoffData;
|
|
88
|
+
/** Decisions recorded in this session. */
|
|
89
|
+
decisions: SnapshotDecision[];
|
|
90
|
+
/** Recent brain observations linked to this session. */
|
|
91
|
+
observations: SnapshotObservation[];
|
|
92
|
+
/** Current task context (if a task is focused). */
|
|
93
|
+
activeTask: SnapshotTaskContext | null;
|
|
94
|
+
/** Session duration in minutes at snapshot time. */
|
|
95
|
+
durationMinutes: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Options for serializing a session. */
|
|
99
|
+
export interface SerializeOptions {
|
|
100
|
+
/** Session ID to serialize. If omitted, uses the active session. */
|
|
101
|
+
sessionId?: string;
|
|
102
|
+
/** Maximum number of brain observations to include. Default: 10. */
|
|
103
|
+
maxObservations?: number;
|
|
104
|
+
/** Maximum description length for active task. Default: 500. */
|
|
105
|
+
maxDescriptionLength?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Options for restoring a session. */
|
|
109
|
+
export interface RestoreOptions {
|
|
110
|
+
/** Agent identifier for the new agent taking over. */
|
|
111
|
+
agent?: string;
|
|
112
|
+
/** Whether to resume the session (set status to active). Default: true. */
|
|
113
|
+
activate?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ============================================================================
|
|
117
|
+
// Serialize
|
|
118
|
+
// ============================================================================
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Serialize a session into a complete snapshot.
|
|
122
|
+
*
|
|
123
|
+
* Captures the full session state including handoff data, decisions,
|
|
124
|
+
* brain observations, and active task context. The result is a
|
|
125
|
+
* JSON-serializable object that can be stored and later restored.
|
|
126
|
+
*
|
|
127
|
+
* @param projectRoot - Project root directory
|
|
128
|
+
* @param options - Serialization options
|
|
129
|
+
* @returns Complete session snapshot
|
|
130
|
+
*/
|
|
131
|
+
export async function serializeSession(
|
|
132
|
+
projectRoot: string,
|
|
133
|
+
options: SerializeOptions = {},
|
|
134
|
+
accessor?: DataAccessor,
|
|
135
|
+
): Promise<SessionSnapshot> {
|
|
136
|
+
const acc = accessor ?? (await getAccessor(projectRoot));
|
|
137
|
+
const maxObs = options.maxObservations ?? 10;
|
|
138
|
+
const maxDescLen = options.maxDescriptionLength ?? 500;
|
|
139
|
+
|
|
140
|
+
// Find the session
|
|
141
|
+
const sessions = await acc.loadSessions();
|
|
142
|
+
let session: Session | undefined;
|
|
143
|
+
|
|
144
|
+
if (options.sessionId) {
|
|
145
|
+
session = sessions.find((s) => s.id === options.sessionId);
|
|
146
|
+
} else {
|
|
147
|
+
// Find the active session
|
|
148
|
+
session = sessions
|
|
149
|
+
.filter((s) => s.status === 'active')
|
|
150
|
+
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())[0];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!session) {
|
|
154
|
+
throw new CleoError(
|
|
155
|
+
ExitCode.SESSION_NOT_FOUND,
|
|
156
|
+
options.sessionId
|
|
157
|
+
? `Session '${options.sessionId}' not found`
|
|
158
|
+
: 'No active session to serialize',
|
|
159
|
+
{ fix: "Use 'cleo session list' to see available sessions" },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Compute handoff data
|
|
164
|
+
const handoff = await computeHandoff(projectRoot, { sessionId: session.id });
|
|
165
|
+
|
|
166
|
+
// Get decisions
|
|
167
|
+
const decisionLog = await getDecisionLog(projectRoot, { sessionId: session.id });
|
|
168
|
+
const decisions: SnapshotDecision[] = decisionLog.map((d) => ({
|
|
169
|
+
decision: d.decision,
|
|
170
|
+
rationale: d.rationale,
|
|
171
|
+
taskId: d.taskId,
|
|
172
|
+
recordedAt: d.timestamp ?? new Date().toISOString(),
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
// Get brain observations linked to this session (best-effort)
|
|
176
|
+
let observations: SnapshotObservation[] = [];
|
|
177
|
+
try {
|
|
178
|
+
const { searchBrainCompact } = await import('../memory/brain-retrieval.js');
|
|
179
|
+
const results = await searchBrainCompact(projectRoot, {
|
|
180
|
+
query: session.id,
|
|
181
|
+
limit: maxObs,
|
|
182
|
+
tables: ['observations'],
|
|
183
|
+
});
|
|
184
|
+
if (Array.isArray(results)) {
|
|
185
|
+
observations = results.map(
|
|
186
|
+
(r: { id: string; text?: string; type?: string; createdAt?: string }) => ({
|
|
187
|
+
id: r.id,
|
|
188
|
+
text: r.text ?? '',
|
|
189
|
+
type: r.type ?? 'discovery',
|
|
190
|
+
createdAt: r.createdAt ?? '',
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Brain search is best-effort — snapshot works without it
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Get active task context
|
|
199
|
+
let activeTask: SnapshotTaskContext | null = null;
|
|
200
|
+
if (session.taskWork?.taskId) {
|
|
201
|
+
try {
|
|
202
|
+
const { tasks } = await acc.queryTasks({});
|
|
203
|
+
const task = tasks.find((t) => t.id === session.taskWork?.taskId);
|
|
204
|
+
if (task) {
|
|
205
|
+
const desc = task.description ?? '';
|
|
206
|
+
activeTask = {
|
|
207
|
+
taskId: task.id,
|
|
208
|
+
title: task.title,
|
|
209
|
+
status: task.status,
|
|
210
|
+
priority: task.priority ?? 'medium',
|
|
211
|
+
description: desc.length > maxDescLen ? desc.slice(0, maxDescLen) + '...' : desc,
|
|
212
|
+
acceptance: Array.isArray(task.acceptance) ? task.acceptance.join('\n') : (task.acceptance ?? undefined),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// Task lookup is best-effort
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Compute duration
|
|
221
|
+
const startTime = new Date(session.startedAt).getTime();
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
const durationMinutes = Math.round((now - startTime) / 60_000);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
version: SNAPSHOT_VERSION,
|
|
227
|
+
capturedAt: new Date().toISOString(),
|
|
228
|
+
session,
|
|
229
|
+
handoff,
|
|
230
|
+
decisions,
|
|
231
|
+
observations,
|
|
232
|
+
activeTask,
|
|
233
|
+
durationMinutes,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ============================================================================
|
|
238
|
+
// Restore
|
|
239
|
+
// ============================================================================
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Restore a session from a snapshot.
|
|
243
|
+
*
|
|
244
|
+
* Hydrates a session from a previously serialized snapshot. The session
|
|
245
|
+
* is re-inserted into the sessions store and optionally activated.
|
|
246
|
+
* Brain observations from the snapshot are NOT re-inserted (they already
|
|
247
|
+
* exist in brain.db) — only the session state is restored.
|
|
248
|
+
*
|
|
249
|
+
* @param projectRoot - Project root directory
|
|
250
|
+
* @param snapshot - The snapshot to restore from
|
|
251
|
+
* @param options - Restoration options
|
|
252
|
+
* @returns The restored session
|
|
253
|
+
*/
|
|
254
|
+
export async function restoreSession(
|
|
255
|
+
projectRoot: string,
|
|
256
|
+
snapshot: SessionSnapshot,
|
|
257
|
+
options: RestoreOptions = {},
|
|
258
|
+
accessor?: DataAccessor,
|
|
259
|
+
): Promise<Session> {
|
|
260
|
+
// Validate snapshot version
|
|
261
|
+
if (snapshot.version > SNAPSHOT_VERSION) {
|
|
262
|
+
throw new CleoError(
|
|
263
|
+
ExitCode.VALIDATION_ERROR,
|
|
264
|
+
`Snapshot version ${snapshot.version} is newer than supported version ${SNAPSHOT_VERSION}`,
|
|
265
|
+
{ fix: 'Upgrade @cleocode/core to a newer version that supports this snapshot format' },
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const acc = accessor ?? (await getAccessor(projectRoot));
|
|
270
|
+
const activate = options.activate ?? true;
|
|
271
|
+
|
|
272
|
+
// Check for active session conflict
|
|
273
|
+
if (activate) {
|
|
274
|
+
const sessions = await acc.loadSessions();
|
|
275
|
+
const scope = snapshot.session.scope;
|
|
276
|
+
const activeConflict = sessions.find(
|
|
277
|
+
(s) =>
|
|
278
|
+
s.status === 'active' &&
|
|
279
|
+
s.scope.type === scope.type &&
|
|
280
|
+
s.scope.epicId === scope.epicId &&
|
|
281
|
+
s.id !== snapshot.session.id,
|
|
282
|
+
);
|
|
283
|
+
if (activeConflict) {
|
|
284
|
+
throw new CleoError(
|
|
285
|
+
ExitCode.SCOPE_CONFLICT,
|
|
286
|
+
`Active session '${activeConflict.id}' already exists for scope ${scope.type}${scope.epicId ? ':' + scope.epicId : ''}`,
|
|
287
|
+
{
|
|
288
|
+
fix: `End the active session first with 'cleo session end' or restore without activating`,
|
|
289
|
+
alternatives: [
|
|
290
|
+
{ action: 'End conflicting session', command: 'cleo session end' },
|
|
291
|
+
{ action: 'Restore without activating', command: 'Restore with activate: false' },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Reconstruct the session
|
|
299
|
+
const restoredSession: Session = {
|
|
300
|
+
...snapshot.session,
|
|
301
|
+
status: activate ? 'active' : snapshot.session.status,
|
|
302
|
+
notes: [
|
|
303
|
+
...(snapshot.session.notes ?? []),
|
|
304
|
+
`Restored from snapshot at ${new Date().toISOString()} (captured ${snapshot.capturedAt}, duration ${snapshot.durationMinutes}m)`,
|
|
305
|
+
],
|
|
306
|
+
resumeCount: (snapshot.session.resumeCount ?? 0) + 1,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Update agent if a new one is taking over
|
|
310
|
+
if (options.agent) {
|
|
311
|
+
restoredSession.agent = options.agent;
|
|
312
|
+
restoredSession.notes = [
|
|
313
|
+
...(restoredSession.notes ?? []),
|
|
314
|
+
`Agent handoff: ${snapshot.session.agent ?? 'unknown'} → ${options.agent}`,
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Store the handoff data for context
|
|
319
|
+
restoredSession.handoffJson = JSON.stringify(snapshot.handoff);
|
|
320
|
+
|
|
321
|
+
// Persist
|
|
322
|
+
await acc.upsertSingleSession(restoredSession);
|
|
323
|
+
|
|
324
|
+
// Dispatch hook (best-effort)
|
|
325
|
+
try {
|
|
326
|
+
const { hooks } = await import('../hooks/registry.js');
|
|
327
|
+
await hooks.dispatch('onSessionStart', projectRoot, {
|
|
328
|
+
timestamp: new Date().toISOString(),
|
|
329
|
+
sessionId: restoredSession.id,
|
|
330
|
+
name: restoredSession.name,
|
|
331
|
+
scope: restoredSession.scope,
|
|
332
|
+
agent: restoredSession.agent,
|
|
333
|
+
restored: true,
|
|
334
|
+
snapshotCapturedAt: snapshot.capturedAt,
|
|
335
|
+
});
|
|
336
|
+
} catch {
|
|
337
|
+
// Hooks are best-effort
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return restoredSession;
|
|
341
|
+
}
|