pi-session-continuity 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/docs/deploys/README.md +18 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0001-package-skeleton.md +53 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0002-core-artifact-engine.md +39 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0003-manual-checkpoint.md +38 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0004-status-settings.md +38 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0005-automatic-threshold.md +37 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0006-compaction-hygiene.md +38 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0007-tests-smoke.md +38 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0008-public-docs-release-readiness.md +37 -0
- package/docs/deploys/deploy-2026-07-07-SCOPE-0009-dogfood-hardening-local-readiness.md +40 -0
- package/docs/deploys/deploy-2026-07-08-SCOPE-0010-ux-settings-menu.md +41 -0
- package/docs/deploys/deploy-2026-07-08-SCOPE-0011-compact-status-footer.md +26 -0
- package/docs/deploys/deploy-2026-07-08-SCOPE-0012-remove-settings-show.md +20 -0
- package/docs/deploys/deploy-2026-07-08-SCOPE-0014-status-and-effort.md +21 -0
- package/docs/deploys/deploy-2026-07-08-SCOPE-0016-codex-empty-synthesis.md +21 -0
- package/docs/product-spec.md +702 -0
- package/docs/release-readiness/local-v1-readiness-2026-07-07.md +76 -0
- package/extensions/session-continuity/index.ts +317 -0
- package/package.json +58 -0
- package/scripts/smoke/manual-checks.sh +42 -0
- package/src/artifact.ts +535 -0
- package/src/config.ts +360 -0
- package/src/constants.ts +68 -0
- package/src/handoff.ts +674 -0
- package/src/status.ts +130 -0
- package/src/trigger.ts +40 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_ARTIFACT_DIRECTORY,
|
|
7
|
+
DEFAULT_KEEP_RECENT_PERCENT,
|
|
8
|
+
DEFAULT_SYNTHESIS_EFFORT,
|
|
9
|
+
DEFAULT_SYNTHESIS_MODEL,
|
|
10
|
+
DEFAULT_TRIGGER_AT_PERCENT,
|
|
11
|
+
} from "./constants.js";
|
|
12
|
+
|
|
13
|
+
export type SynthesisEffort =
|
|
14
|
+
| "inherit"
|
|
15
|
+
| "minimal"
|
|
16
|
+
| "low"
|
|
17
|
+
| "medium"
|
|
18
|
+
| "high"
|
|
19
|
+
| "xhigh";
|
|
20
|
+
|
|
21
|
+
export interface ContinuityConfig {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
triggerAtPercent: number;
|
|
24
|
+
keepRecentPercent: number;
|
|
25
|
+
synthesisModel: string;
|
|
26
|
+
synthesisEffort: SynthesisEffort;
|
|
27
|
+
artifactDirectory: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedContinuityConfig extends ContinuityConfig {
|
|
31
|
+
configPath: string;
|
|
32
|
+
artifactDirectoryPath: string;
|
|
33
|
+
trusted: boolean;
|
|
34
|
+
valid: boolean;
|
|
35
|
+
disabledReason?: string;
|
|
36
|
+
errors: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_CONFIG: ContinuityConfig = {
|
|
40
|
+
enabled: true,
|
|
41
|
+
triggerAtPercent: DEFAULT_TRIGGER_AT_PERCENT,
|
|
42
|
+
keepRecentPercent: DEFAULT_KEEP_RECENT_PERCENT,
|
|
43
|
+
synthesisModel: DEFAULT_SYNTHESIS_MODEL,
|
|
44
|
+
synthesisEffort: DEFAULT_SYNTHESIS_EFFORT as SynthesisEffort,
|
|
45
|
+
artifactDirectory: DEFAULT_ARTIFACT_DIRECTORY,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function publicConfigFromResolved(
|
|
49
|
+
config: ResolvedContinuityConfig,
|
|
50
|
+
): ContinuityConfig {
|
|
51
|
+
return {
|
|
52
|
+
enabled: config.enabled,
|
|
53
|
+
triggerAtPercent: config.triggerAtPercent,
|
|
54
|
+
keepRecentPercent: config.keepRecentPercent,
|
|
55
|
+
synthesisModel: config.synthesisModel,
|
|
56
|
+
synthesisEffort: config.synthesisEffort,
|
|
57
|
+
artifactDirectory: config.artifactDirectory,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
62
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SYNTHESIS_EFFORTS = new Set<SynthesisEffort>([
|
|
66
|
+
"inherit",
|
|
67
|
+
"minimal",
|
|
68
|
+
"low",
|
|
69
|
+
"medium",
|
|
70
|
+
"high",
|
|
71
|
+
"xhigh",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
export function isSynthesisEffort(value: string): value is SynthesisEffort {
|
|
75
|
+
return SYNTHESIS_EFFORTS.has(value as SynthesisEffort);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function validateConfig(
|
|
79
|
+
raw: unknown,
|
|
80
|
+
configPath: string,
|
|
81
|
+
): { config?: ContinuityConfig; errors: string[] } {
|
|
82
|
+
const errors: string[] = [];
|
|
83
|
+
if (!isRecord(raw)) {
|
|
84
|
+
return { errors: [`${configPath}: configuration must be a JSON object`] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const config: ContinuityConfig = { ...DEFAULT_CONFIG };
|
|
88
|
+
|
|
89
|
+
if (raw.enabled !== undefined) {
|
|
90
|
+
if (typeof raw.enabled !== "boolean")
|
|
91
|
+
errors.push("enabled must be a boolean");
|
|
92
|
+
else config.enabled = raw.enabled;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (raw.triggerAtPercent !== undefined) {
|
|
96
|
+
if (
|
|
97
|
+
typeof raw.triggerAtPercent !== "number" ||
|
|
98
|
+
!Number.isFinite(raw.triggerAtPercent)
|
|
99
|
+
) {
|
|
100
|
+
errors.push("triggerAtPercent must be a finite number");
|
|
101
|
+
} else {
|
|
102
|
+
config.triggerAtPercent = raw.triggerAtPercent;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (raw.keepRecentPercent !== undefined) {
|
|
107
|
+
if (
|
|
108
|
+
typeof raw.keepRecentPercent !== "number" ||
|
|
109
|
+
!Number.isFinite(raw.keepRecentPercent)
|
|
110
|
+
) {
|
|
111
|
+
errors.push("keepRecentPercent must be a finite number");
|
|
112
|
+
} else {
|
|
113
|
+
config.keepRecentPercent = raw.keepRecentPercent;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (raw.synthesisModel !== undefined) {
|
|
118
|
+
if (
|
|
119
|
+
typeof raw.synthesisModel !== "string" ||
|
|
120
|
+
raw.synthesisModel.trim() === ""
|
|
121
|
+
) {
|
|
122
|
+
errors.push(
|
|
123
|
+
'synthesisModel must be "inherit" or a provider/model string',
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
config.synthesisModel = raw.synthesisModel.trim();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (raw.synthesisEffort !== undefined) {
|
|
131
|
+
const effort =
|
|
132
|
+
typeof raw.synthesisEffort === "string" ? raw.synthesisEffort.trim() : "";
|
|
133
|
+
if (!isSynthesisEffort(effort)) {
|
|
134
|
+
errors.push(
|
|
135
|
+
'synthesisEffort must be one of "inherit", "minimal", "low", "medium", "high", or "xhigh"',
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
config.synthesisEffort = effort;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (raw.artifactDirectory !== undefined) {
|
|
143
|
+
if (
|
|
144
|
+
typeof raw.artifactDirectory !== "string" ||
|
|
145
|
+
raw.artifactDirectory.trim() === ""
|
|
146
|
+
) {
|
|
147
|
+
errors.push("artifactDirectory must be a non-empty string");
|
|
148
|
+
} else {
|
|
149
|
+
config.artifactDirectory = raw.artifactDirectory.trim();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (config.triggerAtPercent <= 0 || config.triggerAtPercent >= 100) {
|
|
154
|
+
errors.push("triggerAtPercent must be positive and below 100");
|
|
155
|
+
}
|
|
156
|
+
if (config.keepRecentPercent <= 0 || config.keepRecentPercent >= 100) {
|
|
157
|
+
errors.push("keepRecentPercent must be positive and below 100");
|
|
158
|
+
}
|
|
159
|
+
if (config.keepRecentPercent >= config.triggerAtPercent) {
|
|
160
|
+
errors.push("keepRecentPercent must be lower than triggerAtPercent");
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
config.synthesisModel !== "inherit" &&
|
|
164
|
+
!config.synthesisModel.includes("/")
|
|
165
|
+
) {
|
|
166
|
+
errors.push(
|
|
167
|
+
'synthesisModel must be "inherit" or a concrete provider/model id',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return errors.length > 0 ? { errors } : { config, errors: [] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function resolveArtifactDirectoryPath(
|
|
175
|
+
configRoot: string,
|
|
176
|
+
artifactDirectory: string,
|
|
177
|
+
configPath: string,
|
|
178
|
+
): { artifactDirectoryPath?: string; errors: string[] } {
|
|
179
|
+
if (isAbsolute(artifactDirectory)) {
|
|
180
|
+
return { artifactDirectoryPath: artifactDirectory, errors: [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const artifactDirectoryPath = resolve(configRoot, artifactDirectory);
|
|
184
|
+
const relativeArtifactPath = relative(configRoot, artifactDirectoryPath);
|
|
185
|
+
if (
|
|
186
|
+
relativeArtifactPath === ".." ||
|
|
187
|
+
relativeArtifactPath.startsWith("../") ||
|
|
188
|
+
isAbsolute(relativeArtifactPath)
|
|
189
|
+
) {
|
|
190
|
+
return {
|
|
191
|
+
errors: [
|
|
192
|
+
`${configPath}: artifactDirectory must resolve under ${configRoot} unless absolute`,
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { artifactDirectoryPath, errors: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function validateConfigDraftForPath(
|
|
201
|
+
config: ContinuityConfig,
|
|
202
|
+
configPath: string,
|
|
203
|
+
): string[] {
|
|
204
|
+
const validation = validateConfig(config, configPath);
|
|
205
|
+
if (!validation.config) return validation.errors;
|
|
206
|
+
const resolved = resolveArtifactDirectoryPath(
|
|
207
|
+
dirname(configPath),
|
|
208
|
+
validation.config.artifactDirectory,
|
|
209
|
+
configPath,
|
|
210
|
+
);
|
|
211
|
+
return resolved.errors;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function writeConfigToDisk(
|
|
215
|
+
configPath: string,
|
|
216
|
+
config: ContinuityConfig,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const errors = validateConfigDraftForPath(config, configPath);
|
|
219
|
+
if (errors.length > 0) {
|
|
220
|
+
throw new Error(errors.join("; "));
|
|
221
|
+
}
|
|
222
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
223
|
+
await writeFile(
|
|
224
|
+
`${configPath}`,
|
|
225
|
+
`${JSON.stringify(config, null, "\t")}\n`,
|
|
226
|
+
"utf8",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function loadConfigFromDisk(
|
|
231
|
+
cwd: string,
|
|
232
|
+
configDirName: string,
|
|
233
|
+
trusted: boolean,
|
|
234
|
+
): Promise<ResolvedContinuityConfig> {
|
|
235
|
+
const configPath = join(cwd, configDirName, "session-continuity.json");
|
|
236
|
+
|
|
237
|
+
if (!trusted) {
|
|
238
|
+
const artifactDirectoryPath = resolve(
|
|
239
|
+
cwd,
|
|
240
|
+
configDirName,
|
|
241
|
+
DEFAULT_ARTIFACT_DIRECTORY,
|
|
242
|
+
);
|
|
243
|
+
return {
|
|
244
|
+
...DEFAULT_CONFIG,
|
|
245
|
+
configPath,
|
|
246
|
+
artifactDirectoryPath,
|
|
247
|
+
trusted,
|
|
248
|
+
valid: false,
|
|
249
|
+
enabled: false,
|
|
250
|
+
disabledReason: "project is not trusted",
|
|
251
|
+
errors: [
|
|
252
|
+
`Project-local config is ignored until the project is trusted: ${configPath}`,
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let raw: unknown = DEFAULT_CONFIG;
|
|
258
|
+
if (existsSync(configPath)) {
|
|
259
|
+
try {
|
|
260
|
+
raw = JSON.parse(await readFile(configPath, "utf8"));
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
263
|
+
return {
|
|
264
|
+
...DEFAULT_CONFIG,
|
|
265
|
+
configPath,
|
|
266
|
+
artifactDirectoryPath: resolve(
|
|
267
|
+
cwd,
|
|
268
|
+
configDirName,
|
|
269
|
+
DEFAULT_ARTIFACT_DIRECTORY,
|
|
270
|
+
),
|
|
271
|
+
trusted,
|
|
272
|
+
valid: false,
|
|
273
|
+
enabled: false,
|
|
274
|
+
disabledReason: `invalid configuration in ${configPath}`,
|
|
275
|
+
errors: [`${configPath}: ${message}`],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const validation = validateConfig(raw, configPath);
|
|
281
|
+
if (!validation.config) {
|
|
282
|
+
return {
|
|
283
|
+
...DEFAULT_CONFIG,
|
|
284
|
+
configPath,
|
|
285
|
+
artifactDirectoryPath: resolve(
|
|
286
|
+
cwd,
|
|
287
|
+
configDirName,
|
|
288
|
+
DEFAULT_ARTIFACT_DIRECTORY,
|
|
289
|
+
),
|
|
290
|
+
trusted,
|
|
291
|
+
valid: false,
|
|
292
|
+
enabled: false,
|
|
293
|
+
disabledReason: `invalid configuration in ${configPath}`,
|
|
294
|
+
errors: validation.errors,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const resolvedArtifactDirectory = resolveArtifactDirectoryPath(
|
|
299
|
+
resolve(cwd, configDirName),
|
|
300
|
+
validation.config.artifactDirectory,
|
|
301
|
+
configPath,
|
|
302
|
+
);
|
|
303
|
+
if (!resolvedArtifactDirectory.artifactDirectoryPath) {
|
|
304
|
+
return {
|
|
305
|
+
...validation.config,
|
|
306
|
+
configPath,
|
|
307
|
+
artifactDirectoryPath: resolve(
|
|
308
|
+
cwd,
|
|
309
|
+
configDirName,
|
|
310
|
+
DEFAULT_ARTIFACT_DIRECTORY,
|
|
311
|
+
),
|
|
312
|
+
trusted,
|
|
313
|
+
valid: false,
|
|
314
|
+
enabled: false,
|
|
315
|
+
disabledReason: `invalid configuration in ${configPath}`,
|
|
316
|
+
errors: resolvedArtifactDirectory.errors,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const artifactDirectoryPath = resolvedArtifactDirectory.artifactDirectoryPath;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
...validation.config,
|
|
324
|
+
configPath,
|
|
325
|
+
artifactDirectoryPath,
|
|
326
|
+
trusted,
|
|
327
|
+
valid: true,
|
|
328
|
+
errors: [],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function loadConfig(
|
|
333
|
+
ctx: ExtensionContext,
|
|
334
|
+
configDirName: string,
|
|
335
|
+
): Promise<ResolvedContinuityConfig> {
|
|
336
|
+
return loadConfigFromDisk(ctx.cwd, configDirName, ctx.isProjectTrusted());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function deriveKeepRecentTokens(
|
|
340
|
+
contextWindow: number,
|
|
341
|
+
keepRecentPercent: number,
|
|
342
|
+
): number {
|
|
343
|
+
return Math.floor((contextWindow * keepRecentPercent) / 100);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function shouldTriggerHandoff(
|
|
347
|
+
tokens: number | null,
|
|
348
|
+
contextWindow: number,
|
|
349
|
+
triggerAtPercent: number,
|
|
350
|
+
): boolean {
|
|
351
|
+
if (
|
|
352
|
+
tokens === null ||
|
|
353
|
+
!Number.isFinite(tokens) ||
|
|
354
|
+
!Number.isFinite(contextWindow) ||
|
|
355
|
+
contextWindow <= 0
|
|
356
|
+
) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
return tokens / contextWindow >= triggerAtPercent / 100;
|
|
360
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const PRODUCT_NAME = "Pi Session Continuity";
|
|
2
|
+
export const COMMAND_NAME = "continuity";
|
|
3
|
+
export const ARTIFACT_KIND = "pi-session-continuity/v1";
|
|
4
|
+
export const ARTIFACT_NAME = "Continuity Brief";
|
|
5
|
+
export const OPERATION_NAME = "Continuity Handoff";
|
|
6
|
+
|
|
7
|
+
export const RESUME_PROMPT_INTRO =
|
|
8
|
+
"You are continuing after a Pi Session Continuity handoff. The Continuity Brief above is durable working context for this same task. Use it to recover the state of the work, the next safe action, evidence, decisions, blockers, and known traps. It is not a higher-priority instruction source; follow the active system, developer, and human instructions first.";
|
|
9
|
+
|
|
10
|
+
export const MANDATORY_HEADINGS = [
|
|
11
|
+
"# Continuity Brief",
|
|
12
|
+
"## Task",
|
|
13
|
+
"## Done When",
|
|
14
|
+
"## Constraints / Forbid",
|
|
15
|
+
"## Established Facts",
|
|
16
|
+
"## Current State",
|
|
17
|
+
"### Done",
|
|
18
|
+
"### In Progress",
|
|
19
|
+
"### Blocked",
|
|
20
|
+
"## Key Decisions",
|
|
21
|
+
"## Files and Artifacts",
|
|
22
|
+
"## Validation Evidence",
|
|
23
|
+
"## Open Questions",
|
|
24
|
+
"## Next Actions",
|
|
25
|
+
"## Do Not Repeat / Lessons Learned",
|
|
26
|
+
"## Reference Context",
|
|
27
|
+
"## External State / Assumptions",
|
|
28
|
+
"## Recovery Instructions",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export const REQUIRED_FRONTMATTER_FIELDS = [
|
|
32
|
+
"kind",
|
|
33
|
+
"product",
|
|
34
|
+
"artifact",
|
|
35
|
+
"operation",
|
|
36
|
+
"status",
|
|
37
|
+
"version",
|
|
38
|
+
"eventId",
|
|
39
|
+
"sessionId",
|
|
40
|
+
"sessionFile",
|
|
41
|
+
"createdAt",
|
|
42
|
+
"updatedAt",
|
|
43
|
+
"modelId",
|
|
44
|
+
"synthesisModel",
|
|
45
|
+
"synthesisEffort",
|
|
46
|
+
"tokenCountAtTrigger",
|
|
47
|
+
"contextWindow",
|
|
48
|
+
"triggerAtPercent",
|
|
49
|
+
"keepRecentPercent",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
export const ALLOWED_STATUSES = [
|
|
53
|
+
"pending",
|
|
54
|
+
"injected",
|
|
55
|
+
"archived",
|
|
56
|
+
"failed",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
export const DEFAULT_TRIGGER_AT_PERCENT = 75;
|
|
60
|
+
export const DEFAULT_KEEP_RECENT_PERCENT = 20;
|
|
61
|
+
export const DEFAULT_ARTIFACT_DIRECTORY = "session-continuity";
|
|
62
|
+
export const DEFAULT_SYNTHESIS_MODEL = "inherit";
|
|
63
|
+
export const DEFAULT_SYNTHESIS_EFFORT = "medium";
|
|
64
|
+
|
|
65
|
+
export const SYNTHESIS_MAX_TOKENS = 32_768;
|
|
66
|
+
export const AUTOMATIC_FAILURE_COOLDOWN_MS = 600_000;
|
|
67
|
+
export const SINGLE_FLIGHT_WINDOW_MS = 600_000;
|
|
68
|
+
export const ARCHIVE_RETENTION_LIMIT = 10;
|