@evo-dev/core 0.0.1-alpha.19 → 0.0.1-alpha.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evo-dev/core",
3
- "version": "0.0.1-alpha.19",
3
+ "version": "0.0.1-alpha.20",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,18 +1,23 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { createStableId, normalizeTimestamp, sha256Hex } from "../../../utils/index.ts";
2
3
  import { readEvolutionReviewSnapshot } from "../../candidates/index.ts";
3
4
  import type { EvolutionReviewSnapshot, SegmentEvolutionTriggerRecord } from "../../schema.ts";
4
5
  import { listSegmentEvolutionTriggers } from "../../triggers/index.ts";
6
+ import { resolveSessionMemoryPaths } from "./paths.ts";
5
7
  import {
6
8
  listSessionEvidenceSegments,
9
+ listSessionMemoryStates,
7
10
  readSessionEvidenceSegment,
11
+ rewriteSessionRawEvents,
8
12
  writeSessionEvidenceSegmentAtomic,
9
13
  } from "./storage.ts";
10
- import type { SessionEvidenceSegmentV1 } from "./types.ts";
14
+ import type { SessionEvidenceSegmentV1, SessionMemoryRawEventV1 } from "./types.ts";
11
15
 
12
16
  const DEFAULT_RETENTION_LIMIT = 20;
13
- const MAX_RETENTION_LIMIT = 20;
17
+ const MAX_RETENTION_LIMIT = 10_000;
18
+ const DEFAULT_LEGACY_RETENTION_DAYS = 30;
14
19
 
15
- export type HistoricalSessionEvidenceRetentionProtectionReason =
20
+ export type SessionEvidenceRetentionProtectionReason =
16
21
  | "trigger-missing"
17
22
  | "trigger-not-terminal"
18
23
  | "segment-review-pending"
@@ -20,7 +25,10 @@ export type HistoricalSessionEvidenceRetentionProtectionReason =
20
25
  | "retention-invalid"
21
26
  | "segment-changed";
22
27
 
23
- export interface PurgeExpiredHistoricalSessionEvidenceResult {
28
+ export type HistoricalSessionEvidenceRetentionProtectionReason =
29
+ SessionEvidenceRetentionProtectionReason;
30
+
31
+ export interface PurgeExpiredSessionEvidenceResult {
24
32
  scanned: number;
25
33
  due: number;
26
34
  purged: number;
@@ -28,17 +36,51 @@ export interface PurgeExpiredHistoricalSessionEvidenceResult {
28
36
  protected: Array<{
29
37
  projectKey: string;
30
38
  segmentId: string;
31
- reason: HistoricalSessionEvidenceRetentionProtectionReason;
39
+ reason: SessionEvidenceRetentionProtectionReason;
32
40
  }>;
33
41
  }
34
42
 
35
- export interface HistoricalSessionEvidenceRetentionInspection {
43
+ export type PurgeExpiredHistoricalSessionEvidenceResult = PurgeExpiredSessionEvidenceResult;
44
+
45
+ export interface SessionEvidenceRetentionInspection {
36
46
  available: number;
37
47
  purged: number;
38
48
  overdue: number;
39
49
  purgeEligible: number;
40
50
  protected: number;
41
- protectionReasons: Partial<Record<HistoricalSessionEvidenceRetentionProtectionReason, number>>;
51
+ legacyWithoutRetention: number;
52
+ legacyOverdue: number;
53
+ legacyRawBytes: number;
54
+ legacyOverdueRawBytes: number;
55
+ expiredCapturedEventPayloads: number;
56
+ expiredCapturedEventRawBytes: number;
57
+ protectionReasons: Partial<Record<SessionEvidenceRetentionProtectionReason, number>>;
58
+ }
59
+
60
+ export type HistoricalSessionEvidenceRetentionInspection = SessionEvidenceRetentionInspection;
61
+
62
+ export interface LegacySessionEvidenceRetentionMigrationResult {
63
+ migrated: number;
64
+ expiredCapturedEventPayloads: number;
65
+ expiredCapturedEventRawBytes: number;
66
+ purge: PurgeExpiredSessionEvidenceResult;
67
+ }
68
+
69
+ interface RetentionContext {
70
+ reviewSnapshots: Map<string, Promise<EvolutionReviewSnapshot>>;
71
+ triggerMaps: Map<string, Promise<Map<string, SegmentEvolutionTriggerRecord[]>>>;
72
+ }
73
+
74
+ export async function inspectSessionEvidenceRetention(input: {
75
+ homeDir: string;
76
+ projectKey?: string;
77
+ now?: string | Date;
78
+ }): Promise<SessionEvidenceRetentionInspection> {
79
+ const result = await inspectRetention(input, () => true);
80
+ const expiredEvents = await expireCapturedRawEvents({ ...input, apply: false });
81
+ result.expiredCapturedEventPayloads = expiredEvents.payloads;
82
+ result.expiredCapturedEventRawBytes = expiredEvents.rawBytes;
83
+ return result;
42
84
  }
43
85
 
44
86
  export async function inspectHistoricalSessionEvidenceRetention(input: {
@@ -46,94 +88,112 @@ export async function inspectHistoricalSessionEvidenceRetention(input: {
46
88
  projectKey?: string;
47
89
  now?: string | Date;
48
90
  }): Promise<HistoricalSessionEvidenceRetentionInspection> {
91
+ return inspectRetention(input, (segment) => segment.origin?.kind === "historical-import");
92
+ }
93
+
94
+ async function inspectRetention(
95
+ input: { homeDir: string; projectKey?: string; now?: string | Date },
96
+ include: (segment: SessionEvidenceSegmentV1) => boolean,
97
+ ): Promise<SessionEvidenceRetentionInspection> {
49
98
  const now = normalizeTimestamp(input.now);
50
- const historical = (
99
+ const segments = (
51
100
  await listSessionEvidenceSegments({
52
101
  homeDir: input.homeDir,
53
102
  projectKey: input.projectKey,
54
103
  })
55
- ).filter(
56
- (segment) => segment.origin?.kind === "historical-import" && segment.retention !== undefined,
57
- );
58
- const result: HistoricalSessionEvidenceRetentionInspection = {
59
- available: 0,
60
- purged: 0,
61
- overdue: 0,
62
- purgeEligible: 0,
63
- protected: 0,
64
- protectionReasons: {},
65
- };
66
- const reviewSnapshots = new Map<string, Promise<EvolutionReviewSnapshot>>();
67
- for (const segment of historical) {
68
- if (segment.retention?.rawState === "purged") {
104
+ ).filter(include);
105
+ const retentionDays = await loadLegacyRetentionDays(input.homeDir, input.projectKey);
106
+ const result = emptyInspection();
107
+ const context = createRetentionContext();
108
+
109
+ for (const segment of segments) {
110
+ if (segment.retention === undefined) {
111
+ const rawBytes = segment.rawExcerpt.stored ? segment.rawExcerpt.byteLength : 0;
112
+ result.legacyWithoutRetention += 1;
113
+ result.legacyRawBytes += rawBytes;
114
+ const expiresAt = legacyExpiresAt(segment, retentionDays);
115
+ if (Date.parse(expiresAt) <= Date.parse(now)) {
116
+ result.legacyOverdue += 1;
117
+ result.legacyOverdueRawBytes += rawBytes;
118
+ const protection = await findRetentionProtection({
119
+ homeDir: input.homeDir,
120
+ segment,
121
+ context,
122
+ });
123
+ if (protection === null) result.purgeEligible += 1;
124
+ else addProtection(result, protection);
125
+ }
126
+ continue;
127
+ }
128
+ if (segment.retention.rawState === "purged") {
69
129
  result.purged += 1;
70
130
  continue;
71
131
  }
72
132
  result.available += 1;
73
133
  if (!hasValidAvailableRetention(segment)) {
74
134
  result.overdue += 1;
75
- result.protected += 1;
76
- result.protectionReasons["retention-invalid"] =
77
- (result.protectionReasons["retention-invalid"] ?? 0) + 1;
78
- continue;
79
- }
80
- if (
81
- segment.retention === undefined ||
82
- Date.parse(segment.retention.expiresAt) > Date.parse(now)
83
- ) {
135
+ addProtection(result, "retention-invalid");
84
136
  continue;
85
137
  }
138
+ if (Date.parse(segment.retention.expiresAt) > Date.parse(now)) continue;
86
139
  result.overdue += 1;
87
140
  const protection = await findRetentionProtection({
88
141
  homeDir: input.homeDir,
89
142
  segment,
90
- reviewSnapshots,
143
+ context,
91
144
  });
92
- if (protection === null) {
93
- result.purgeEligible += 1;
94
- continue;
95
- }
96
- result.protected += 1;
97
- result.protectionReasons[protection] = (result.protectionReasons[protection] ?? 0) + 1;
145
+ if (protection === null) result.purgeEligible += 1;
146
+ else addProtection(result, protection);
98
147
  }
99
148
  return result;
100
149
  }
101
150
 
151
+ export async function purgeExpiredSessionEvidence(input: {
152
+ homeDir: string;
153
+ projectKey?: string;
154
+ now?: string | Date;
155
+ limit?: number;
156
+ }): Promise<PurgeExpiredSessionEvidenceResult> {
157
+ return purgeRetention(input, () => true);
158
+ }
159
+
102
160
  export async function purgeExpiredHistoricalSessionEvidence(input: {
103
161
  homeDir: string;
104
162
  projectKey?: string;
105
163
  now?: string | Date;
106
164
  limit?: number;
107
165
  }): Promise<PurgeExpiredHistoricalSessionEvidenceResult> {
166
+ return purgeRetention(input, (segment) => segment.origin?.kind === "historical-import");
167
+ }
168
+
169
+ async function purgeRetention(
170
+ input: { homeDir: string; projectKey?: string; now?: string | Date; limit?: number },
171
+ include: (segment: SessionEvidenceSegmentV1) => boolean,
172
+ ): Promise<PurgeExpiredSessionEvidenceResult> {
108
173
  const now = normalizeTimestamp(input.now);
109
174
  const limit = normalizeLimit(input.limit);
110
- const historical = (
175
+ const segments = (
111
176
  await listSessionEvidenceSegments({
112
177
  homeDir: input.homeDir,
113
178
  projectKey: input.projectKey,
114
179
  })
115
180
  )
116
- .filter(
117
- (segment) => segment.origin?.kind === "historical-import" && segment.retention !== undefined,
118
- )
181
+ .filter((segment) => include(segment) && segment.retention !== undefined)
119
182
  .sort(
120
183
  (left, right) =>
121
184
  (left.retention?.expiresAt ?? "").localeCompare(right.retention?.expiresAt ?? "") ||
122
185
  left.id.localeCompare(right.id),
123
186
  );
124
- const result: PurgeExpiredHistoricalSessionEvidenceResult = {
187
+ const result: PurgeExpiredSessionEvidenceResult = {
125
188
  scanned: 0,
126
189
  due: 0,
127
190
  purged: 0,
128
- alreadyPurged: 0,
191
+ alreadyPurged: segments.filter((segment) => segment.retention?.rawState === "purged").length,
129
192
  protected: [],
130
193
  };
131
- const reviewSnapshots = new Map<string, Promise<EvolutionReviewSnapshot>>();
194
+ const context = createRetentionContext();
132
195
 
133
- result.alreadyPurged = historical.filter(
134
- (segment) => segment.retention?.rawState === "purged",
135
- ).length;
136
- for (const segment of historical) {
196
+ for (const segment of segments) {
137
197
  if (result.scanned >= limit) break;
138
198
  if (segment.retention === undefined || segment.retention.rawState === "purged") continue;
139
199
  result.scanned += 1;
@@ -152,7 +212,7 @@ export async function purgeExpiredHistoricalSessionEvidence(input: {
152
212
  const protection = await findRetentionProtection({
153
213
  homeDir: input.homeDir,
154
214
  segment,
155
- reviewSnapshots,
215
+ context,
156
216
  });
157
217
  if (protection !== null) {
158
218
  result.protected.push({
@@ -169,16 +229,13 @@ export async function purgeExpiredHistoricalSessionEvidence(input: {
169
229
  sessionKey: segment.sessionKey,
170
230
  segmentId: segment.id,
171
231
  });
172
- const currentTrigger = await findSegmentTrigger(input.homeDir, current);
173
232
  if (
174
233
  !hasValidAvailableRetention(current) ||
175
234
  current.retention?.rawState !== "available" ||
176
235
  current.retention.expiresAt !== segment.retention.expiresAt ||
177
236
  !current.rawExcerpt.stored ||
178
237
  current.rawExcerpt.sha256 !== segment.rawExcerpt.sha256 ||
179
- current.lifecycle.reviewState !== segment.lifecycle.reviewState ||
180
- currentTrigger === null ||
181
- !isTerminalTrigger(currentTrigger)
238
+ current.lifecycle.reviewState !== segment.lifecycle.reviewState
182
239
  ) {
183
240
  result.protected.push({
184
241
  projectKey: segment.projectKey,
@@ -187,22 +244,23 @@ export async function purgeExpiredHistoricalSessionEvidence(input: {
187
244
  });
188
245
  continue;
189
246
  }
190
- const freshSnapshot = await readEvolutionReviewSnapshot({
247
+ const freshProtection = await findRetentionProtection({
191
248
  homeDir: input.homeDir,
192
- projectKey: current.projectKey,
249
+ segment: current,
250
+ context: createRetentionContext(),
193
251
  });
194
- if (hasPendingDerivedReview(freshSnapshot, current)) {
252
+ if (freshProtection !== null) {
195
253
  result.protected.push({
196
- projectKey: segment.projectKey,
197
- segmentId: segment.id,
198
- reason: "derived-review-pending",
254
+ projectKey: current.projectKey,
255
+ segmentId: current.id,
256
+ reason: freshProtection,
199
257
  });
200
258
  continue;
201
259
  }
202
260
 
203
261
  await writeSessionEvidenceSegmentAtomic({
204
262
  homeDir: input.homeDir,
205
- segment: createPurgedHistoricalSegment(current, now),
263
+ segment: createPurgedSegment(current, now),
206
264
  });
207
265
  result.purged += 1;
208
266
  }
@@ -210,11 +268,102 @@ export async function purgeExpiredHistoricalSessionEvidence(input: {
210
268
  return result;
211
269
  }
212
270
 
271
+ export async function migrateLegacySessionEvidenceRetention(input: {
272
+ homeDir: string;
273
+ projectKey?: string;
274
+ now?: string | Date;
275
+ }): Promise<LegacySessionEvidenceRetentionMigrationResult> {
276
+ const now = normalizeTimestamp(input.now);
277
+ const retentionDays = await loadLegacyRetentionDays(input.homeDir, input.projectKey);
278
+ const segments = await listSessionEvidenceSegments({
279
+ homeDir: input.homeDir,
280
+ projectKey: input.projectKey,
281
+ });
282
+ let migrated = 0;
283
+ const context = createRetentionContext();
284
+ for (const segment of segments) {
285
+ if (segment.retention !== undefined || !segment.rawExcerpt.stored) continue;
286
+ const policyDays = retentionDays.get(sessionKey(segment)) ?? DEFAULT_LEGACY_RETENTION_DAYS;
287
+ if (Date.parse(addDays(segment.createdAt, policyDays)) > Date.parse(now)) continue;
288
+ const protection = await findRetentionProtection({
289
+ homeDir: input.homeDir,
290
+ segment,
291
+ context,
292
+ });
293
+ if (protection !== null) continue;
294
+ const current = await readSessionEvidenceSegment({
295
+ homeDir: input.homeDir,
296
+ projectKey: segment.projectKey,
297
+ sessionKey: segment.sessionKey,
298
+ segmentId: segment.id,
299
+ });
300
+ if (current.retention !== undefined || JSON.stringify(current) !== JSON.stringify(segment)) {
301
+ continue;
302
+ }
303
+ await writeSessionEvidenceSegmentAtomic({
304
+ homeDir: input.homeDir,
305
+ segment: {
306
+ ...current,
307
+ retention: {
308
+ policyDays,
309
+ expiresAt: addDays(current.createdAt, policyDays),
310
+ rawState: "available",
311
+ rawPurgedAt: null,
312
+ originalRawSha256: current.rawExcerpt.sha256,
313
+ },
314
+ },
315
+ });
316
+ migrated += 1;
317
+ }
318
+ const expiredEvents = await expireCapturedRawEvents({
319
+ homeDir: input.homeDir,
320
+ projectKey: input.projectKey,
321
+ now,
322
+ apply: true,
323
+ });
324
+ return {
325
+ migrated,
326
+ expiredCapturedEventPayloads: expiredEvents.payloads,
327
+ expiredCapturedEventRawBytes: expiredEvents.rawBytes,
328
+ purge: await purgeExpiredSessionEvidence({
329
+ homeDir: input.homeDir,
330
+ projectKey: input.projectKey,
331
+ now,
332
+ limit: Math.max(DEFAULT_RETENTION_LIMIT, segments.length),
333
+ }),
334
+ };
335
+ }
336
+
337
+ function emptyInspection(): SessionEvidenceRetentionInspection {
338
+ return {
339
+ available: 0,
340
+ purged: 0,
341
+ overdue: 0,
342
+ purgeEligible: 0,
343
+ protected: 0,
344
+ legacyWithoutRetention: 0,
345
+ legacyOverdue: 0,
346
+ legacyRawBytes: 0,
347
+ legacyOverdueRawBytes: 0,
348
+ expiredCapturedEventPayloads: 0,
349
+ expiredCapturedEventRawBytes: 0,
350
+ protectionReasons: {},
351
+ };
352
+ }
353
+
354
+ function addProtection(
355
+ result: SessionEvidenceRetentionInspection,
356
+ reason: SessionEvidenceRetentionProtectionReason,
357
+ ): void {
358
+ result.protected += 1;
359
+ result.protectionReasons[reason] = (result.protectionReasons[reason] ?? 0) + 1;
360
+ }
361
+
213
362
  function normalizeLimit(value: number | undefined): number {
214
363
  const limit = value ?? DEFAULT_RETENTION_LIMIT;
215
364
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RETENTION_LIMIT) {
216
365
  throw new Error(
217
- `Historical Session Evidence retention limit must be between 1 and ${MAX_RETENTION_LIMIT}.`,
366
+ `Session Evidence retention limit must be between 1 and ${MAX_RETENTION_LIMIT}.`,
218
367
  );
219
368
  }
220
369
  return limit;
@@ -238,25 +387,28 @@ function hasValidAvailableRetention(segment: SessionEvidenceSegmentV1): boolean
238
387
  async function findRetentionProtection(input: {
239
388
  homeDir: string;
240
389
  segment: SessionEvidenceSegmentV1;
241
- reviewSnapshots: Map<string, Promise<EvolutionReviewSnapshot>>;
242
- }): Promise<HistoricalSessionEvidenceRetentionProtectionReason | null> {
390
+ context: RetentionContext;
391
+ }): Promise<SessionEvidenceRetentionProtectionReason | null> {
243
392
  if (
244
393
  input.segment.lifecycle.reviewState === "unreviewed" ||
245
394
  input.segment.lifecycle.reviewState === "deferred"
246
395
  ) {
247
396
  return "segment-review-pending";
248
397
  }
249
- const trigger = await findSegmentTrigger(input.homeDir, input.segment);
250
- if (trigger === null) return "trigger-missing";
398
+ const trigger = await findSegmentTrigger(input.homeDir, input.segment, input.context);
399
+ if (trigger === null) {
400
+ if (input.segment.reason === "session-memory-threshold") return null;
401
+ return "trigger-missing";
402
+ }
251
403
  if (!isTerminalTrigger(trigger)) return "trigger-not-terminal";
252
404
 
253
- let snapshot = input.reviewSnapshots.get(input.segment.projectKey);
405
+ let snapshot = input.context.reviewSnapshots.get(input.segment.projectKey);
254
406
  if (snapshot === undefined) {
255
407
  snapshot = readEvolutionReviewSnapshot({
256
408
  homeDir: input.homeDir,
257
409
  projectKey: input.segment.projectKey,
258
410
  });
259
- input.reviewSnapshots.set(input.segment.projectKey, snapshot);
411
+ input.context.reviewSnapshots.set(input.segment.projectKey, snapshot);
260
412
  }
261
413
  return hasPendingDerivedReview(await snapshot, input.segment) ? "derived-review-pending" : null;
262
414
  }
@@ -264,15 +416,23 @@ async function findRetentionProtection(input: {
264
416
  async function findSegmentTrigger(
265
417
  homeDir: string,
266
418
  segment: SessionEvidenceSegmentV1,
419
+ context: RetentionContext,
267
420
  ): Promise<SegmentEvolutionTriggerRecord | null> {
268
- const matches = (
269
- await listSegmentEvolutionTriggers({
270
- homeDir,
271
- projectKey: segment.projectKey,
272
- })
273
- ).filter(
274
- (trigger) => trigger.segmentId === segment.id && trigger.sessionKey === segment.sessionKey,
275
- );
421
+ let triggerMap = context.triggerMaps.get(segment.projectKey);
422
+ if (triggerMap === undefined) {
423
+ triggerMap = listSegmentEvolutionTriggers({ homeDir, projectKey: segment.projectKey }).then(
424
+ (triggers) => {
425
+ const map = new Map<string, SegmentEvolutionTriggerRecord[]>();
426
+ for (const trigger of triggers) {
427
+ const key = `${trigger.sessionKey}\0${trigger.segmentId}`;
428
+ map.set(key, [...(map.get(key) ?? []), trigger]);
429
+ }
430
+ return map;
431
+ },
432
+ );
433
+ context.triggerMaps.set(segment.projectKey, triggerMap);
434
+ }
435
+ const matches = (await triggerMap).get(`${segment.sessionKey}\0${segment.id}`) ?? [];
276
436
  return matches.length === 1 ? (matches[0] ?? null) : null;
277
437
  }
278
438
 
@@ -326,13 +486,13 @@ function hasPendingDerivedReview(
326
486
  );
327
487
  }
328
488
 
329
- function createPurgedHistoricalSegment(
489
+ function createPurgedSegment(
330
490
  segment: SessionEvidenceSegmentV1,
331
491
  now: string,
332
492
  ): SessionEvidenceSegmentV1 {
333
493
  const retention = segment.retention;
334
494
  if (retention === undefined || retention.rawState !== "available") {
335
- throw new Error("Historical Session Evidence raw body is not available for purge.");
495
+ throw new Error("Session Evidence raw body is not available for purge.");
336
496
  }
337
497
  return {
338
498
  ...segment,
@@ -360,3 +520,124 @@ function createPurgedHistoricalSegment(
360
520
  },
361
521
  };
362
522
  }
523
+
524
+ function createRetentionContext(): RetentionContext {
525
+ return {
526
+ reviewSnapshots: new Map(),
527
+ triggerMaps: new Map(),
528
+ };
529
+ }
530
+
531
+ async function loadLegacyRetentionDays(
532
+ homeDir: string,
533
+ projectKey?: string,
534
+ ): Promise<Map<string, number>> {
535
+ const states = await listSessionMemoryStates({ homeDir, projectKey });
536
+ return new Map(
537
+ states.map((state) => [`${state.projectKey}\0${state.sessionKey}`, state.policy.retentionDays]),
538
+ );
539
+ }
540
+
541
+ function legacyExpiresAt(
542
+ segment: SessionEvidenceSegmentV1,
543
+ retentionDays: Map<string, number>,
544
+ ): string {
545
+ return addDays(
546
+ segment.createdAt,
547
+ retentionDays.get(sessionKey(segment)) ?? DEFAULT_LEGACY_RETENTION_DAYS,
548
+ );
549
+ }
550
+
551
+ function sessionKey(segment: SessionEvidenceSegmentV1): string {
552
+ return `${segment.projectKey}\0${segment.sessionKey}`;
553
+ }
554
+
555
+ function addDays(value: string, days: number): string {
556
+ const timestamp = Date.parse(value);
557
+ if (!Number.isFinite(timestamp)) throw new Error("Invalid Session Evidence retention timestamp.");
558
+ return new Date(timestamp + days * 24 * 60 * 60 * 1000).toISOString();
559
+ }
560
+
561
+ async function expireCapturedRawEvents(input: {
562
+ homeDir: string;
563
+ projectKey?: string;
564
+ now?: string | Date;
565
+ apply: boolean;
566
+ }): Promise<{ payloads: number; rawBytes: number }> {
567
+ const now = Date.parse(normalizeTimestamp(input.now));
568
+ const states = await listSessionMemoryStates({
569
+ homeDir: input.homeDir,
570
+ projectKey: input.projectKey,
571
+ });
572
+ let payloads = 0;
573
+ let rawBytes = 0;
574
+ for (const state of states) {
575
+ const stateUpdatedAt = Date.parse(state.updatedAt);
576
+ if (Number.isFinite(stateUpdatedAt) && now - stateUpdatedAt < 5 * 60 * 1000) continue;
577
+ const paths = resolveSessionMemoryPaths({
578
+ homeDir: input.homeDir,
579
+ projectKey: state.projectKey,
580
+ sessionKey: state.sessionKey,
581
+ });
582
+ const cursor = await readCursor(paths.cursorPath);
583
+ const capturedLine = cursor?.lastCapturedLine ?? 0;
584
+ if (capturedLine <= 0) continue;
585
+ let text: string;
586
+ try {
587
+ text = await readFile(paths.eventsPath, "utf8");
588
+ } catch {
589
+ continue;
590
+ }
591
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
592
+ let changed = false;
593
+ let filePayloads = 0;
594
+ let fileRawBytes = 0;
595
+ const nextLines = lines.map((line, index) => {
596
+ if (index >= capturedLine) return line;
597
+ let event: SessionMemoryRawEventV1;
598
+ try {
599
+ event = JSON.parse(line) as SessionMemoryRawEventV1;
600
+ } catch {
601
+ return line;
602
+ }
603
+ const expiresAt =
604
+ Date.parse(event.receivedAt) + state.policy.retentionDays * 24 * 60 * 60 * 1000;
605
+ if (
606
+ !Number.isFinite(expiresAt) ||
607
+ expiresAt > now ||
608
+ event.rawPayloadExpired === true ||
609
+ event.rawPayloadJson === "{}"
610
+ ) {
611
+ return line;
612
+ }
613
+ filePayloads += 1;
614
+ fileRawBytes += Buffer.byteLength(event.rawPayloadJson, "utf8");
615
+ if (!input.apply) return line;
616
+ changed = true;
617
+ return JSON.stringify({
618
+ ...event,
619
+ rawPayloadJson: "{}",
620
+ rawPayloadTruncated: false,
621
+ rawPayloadExpired: true,
622
+ } satisfies SessionMemoryRawEventV1);
623
+ });
624
+ if (!input.apply) {
625
+ payloads += filePayloads;
626
+ rawBytes += fileRawBytes;
627
+ continue;
628
+ }
629
+ if (changed && (await rewriteSessionRawEvents(paths.eventsPath, nextLines, text))) {
630
+ payloads += filePayloads;
631
+ rawBytes += fileRawBytes;
632
+ }
633
+ }
634
+ return { payloads, rawBytes };
635
+ }
636
+
637
+ async function readCursor(path: string): Promise<{ lastCapturedLine: number | null } | null> {
638
+ try {
639
+ return JSON.parse(await readFile(path, "utf8")) as { lastCapturedLine: number | null };
640
+ } catch {
641
+ return null;
642
+ }
643
+ }
@@ -100,6 +100,7 @@ export async function createSegment(input: {
100
100
  String(toLine),
101
101
  input.signals.at(-1)?.eventId ?? input.now,
102
102
  ]);
103
+ const rawSha256 = sha256Hex(truncated.content);
103
104
  return {
104
105
  schemaVersion: 1,
105
106
  kind: "session-evidence-segment",
@@ -112,6 +113,13 @@ export async function createSegment(input: {
112
113
  createdAt: input.now,
113
114
  reason: input.reason,
114
115
  strength: input.strength,
116
+ retention: {
117
+ policyDays: input.state.policy.retentionDays,
118
+ expiresAt: addDays(input.now, input.state.policy.retentionDays),
119
+ rawState: "available",
120
+ rawPurgedAt: null,
121
+ originalRawSha256: rawSha256,
122
+ },
115
123
  source: {
116
124
  traceRefId: input.cursor.traceRefId,
117
125
  sourcePath: input.paths.eventsPath,
@@ -129,7 +137,7 @@ export async function createSegment(input: {
129
137
  content: truncated.content,
130
138
  truncated: raw.truncated || truncated.truncated,
131
139
  byteLength: Buffer.byteLength(truncated.content, "utf8"),
132
- sha256: sha256Hex(truncated.content),
140
+ sha256: rawSha256,
133
141
  },
134
142
  normalized: {
135
143
  summary: createSegmentSummary(input.reason, input.strength, signalSummaries),
@@ -161,6 +169,12 @@ export async function createSegment(input: {
161
169
  };
162
170
  }
163
171
 
172
+ function addDays(value: string, days: number): string {
173
+ const timestamp = Date.parse(value);
174
+ if (!Number.isFinite(timestamp)) throw new Error("Invalid Session Evidence retention timestamp.");
175
+ return new Date(timestamp + days * 24 * 60 * 60 * 1000).toISOString();
176
+ }
177
+
164
178
  export function estimateTokenCount(rawPayloadJson: string, summary: string): number {
165
179
  return Math.max(1, Math.ceil((rawPayloadJson.length + summary.length) / 4));
166
180
  }