@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/dist/config/index.js +59 -8
- package/dist/index.js +803 -230
- package/package.json +1 -1
- package/src/evolution/evidence/session-memory/retention.ts +359 -78
- package/src/evolution/evidence/session-memory/segment.ts +15 -1
- package/src/evolution/evidence/session-memory/storage.ts +37 -6
- package/src/evolution/evidence/session-memory/types.ts +5 -2
- package/src/evolution/evidence/session-memory/updater.ts +23 -15
- package/src/evolution/knowledge/changes.ts +6 -0
- package/src/evolution/knowledge/freshness.ts +17 -57
- package/src/evolution/knowledge/index.ts +204 -12
- package/src/evolution/knowledge/support.ts +135 -0
- package/src/hooks/index.ts +124 -20
package/package.json
CHANGED
|
@@ -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 =
|
|
17
|
+
const MAX_RETENTION_LIMIT = 10_000;
|
|
18
|
+
const DEFAULT_LEGACY_RETENTION_DAYS = 30;
|
|
14
19
|
|
|
15
|
-
export type
|
|
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
|
|
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:
|
|
39
|
+
reason: SessionEvidenceRetentionProtectionReason;
|
|
32
40
|
}>;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
export
|
|
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
|
-
|
|
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
|
|
99
|
+
const segments = (
|
|
51
100
|
await listSessionEvidenceSegments({
|
|
52
101
|
homeDir: input.homeDir,
|
|
53
102
|
projectKey: input.projectKey,
|
|
54
103
|
})
|
|
55
|
-
).filter(
|
|
56
|
-
|
|
57
|
-
);
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
143
|
+
context,
|
|
91
144
|
});
|
|
92
|
-
if (protection === null)
|
|
93
|
-
|
|
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
|
|
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:
|
|
187
|
+
const result: PurgeExpiredSessionEvidenceResult = {
|
|
125
188
|
scanned: 0,
|
|
126
189
|
due: 0,
|
|
127
190
|
purged: 0,
|
|
128
|
-
alreadyPurged:
|
|
191
|
+
alreadyPurged: segments.filter((segment) => segment.retention?.rawState === "purged").length,
|
|
129
192
|
protected: [],
|
|
130
193
|
};
|
|
131
|
-
const
|
|
194
|
+
const context = createRetentionContext();
|
|
132
195
|
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
247
|
+
const freshProtection = await findRetentionProtection({
|
|
191
248
|
homeDir: input.homeDir,
|
|
192
|
-
|
|
249
|
+
segment: current,
|
|
250
|
+
context: createRetentionContext(),
|
|
193
251
|
});
|
|
194
|
-
if (
|
|
252
|
+
if (freshProtection !== null) {
|
|
195
253
|
result.protected.push({
|
|
196
|
-
projectKey:
|
|
197
|
-
segmentId:
|
|
198
|
-
reason:
|
|
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:
|
|
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
|
-
`
|
|
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
|
-
|
|
242
|
-
}): Promise<
|
|
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)
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
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("
|
|
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:
|
|
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
|
}
|