@parkgogogo/openclaw-reflection 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.
@@ -0,0 +1,316 @@
1
+ import * as path from "path";
2
+ import type { JsonSchema, LLMService, Logger } from "../types.js";
3
+ import { readFile, writeFileWithLock } from "../utils/file-utils.js";
4
+ import { CONSOLIDATION_SYSTEM_PROMPT } from "./prompt.js";
5
+ import type {
6
+ ConsolidatedFilename,
7
+ ConsolidationConfig,
8
+ ConsolidationPatch,
9
+ ConsolidationProposal,
10
+ ConsolidationResult,
11
+ } from "./types.js";
12
+
13
+ const MANAGED_FILES: ConsolidatedFilename[] = [
14
+ "MEMORY.md",
15
+ "USER.md",
16
+ "SOUL.md",
17
+ "TOOLS.md",
18
+ ];
19
+ const VALID_PATCH_ACTIONS = new Set<ConsolidationPatch["action"]>([
20
+ "add",
21
+ "replace",
22
+ "remove",
23
+ ]);
24
+ const CONSOLIDATION_RESPONSE_SCHEMA: JsonSchema = {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ required: ["decision", "proposed_updates"],
28
+ properties: {
29
+ decision: {
30
+ type: "string",
31
+ enum: ["NO_WRITE", "WRITE_CLEANUP"],
32
+ },
33
+ proposed_updates: {
34
+ type: "object",
35
+ additionalProperties: false,
36
+ properties: {
37
+ "MEMORY.md": {
38
+ type: "array",
39
+ items: {
40
+ type: "object",
41
+ additionalProperties: false,
42
+ required: ["section", "action", "content"],
43
+ properties: {
44
+ section: { type: "string" },
45
+ action: { type: "string", enum: ["add", "replace", "remove"] },
46
+ content: { type: "string" },
47
+ },
48
+ },
49
+ },
50
+ "USER.md": {
51
+ type: "array",
52
+ items: {
53
+ type: "object",
54
+ additionalProperties: false,
55
+ required: ["section", "action", "content"],
56
+ properties: {
57
+ section: { type: "string" },
58
+ action: { type: "string", enum: ["add", "replace", "remove"] },
59
+ content: { type: "string" },
60
+ },
61
+ },
62
+ },
63
+ "SOUL.md": {
64
+ type: "array",
65
+ items: {
66
+ type: "object",
67
+ additionalProperties: false,
68
+ required: ["section", "action", "content"],
69
+ properties: {
70
+ section: { type: "string" },
71
+ action: { type: "string", enum: ["add", "replace", "remove"] },
72
+ content: { type: "string" },
73
+ },
74
+ },
75
+ },
76
+ "TOOLS.md": {
77
+ type: "array",
78
+ items: {
79
+ type: "object",
80
+ additionalProperties: false,
81
+ required: ["section", "action", "content"],
82
+ properties: {
83
+ section: { type: "string" },
84
+ action: { type: "string", enum: ["add", "replace", "remove"] },
85
+ content: { type: "string" },
86
+ },
87
+ },
88
+ },
89
+ },
90
+ },
91
+ },
92
+ };
93
+
94
+ function getErrorMessage(error: unknown): string {
95
+ if (error instanceof Error) {
96
+ return error.message;
97
+ }
98
+
99
+ return String(error);
100
+ }
101
+
102
+ function stripMdExtension(filename: string): string {
103
+ return filename.endsWith(".md") ? filename.slice(0, -3) : filename;
104
+ }
105
+
106
+ function splitContentLines(content: string): string[] {
107
+ const trimmed = content.trim();
108
+ return trimmed === "" ? [] : trimmed.split(/\r?\n/);
109
+ }
110
+
111
+ function findSectionRange(
112
+ lines: string[],
113
+ section: string
114
+ ): { start: number; end: number } | null {
115
+ const sectionHeader = `## ${section}`;
116
+ const start = lines.findIndex((line) => line.trim() === sectionHeader);
117
+
118
+ if (start === -1) {
119
+ return null;
120
+ }
121
+
122
+ let end = lines.length;
123
+ for (let index = start + 1; index < lines.length; index += 1) {
124
+ if (/^##\s+/.test(lines[index].trim())) {
125
+ end = index;
126
+ break;
127
+ }
128
+ }
129
+
130
+ return { start, end };
131
+ }
132
+
133
+ function applyPatchToContent(
134
+ existingContent: string,
135
+ patch: ConsolidationPatch
136
+ ): string {
137
+ const lines = existingContent.replace(/\r\n/g, "\n").split("\n");
138
+ const sectionHeader = `## ${patch.section}`;
139
+ const contentLines = splitContentLines(patch.content);
140
+ const range = findSectionRange(lines, patch.section);
141
+
142
+ if (!range) {
143
+ if (patch.action === "remove") {
144
+ return `${existingContent.trimEnd()}\n`;
145
+ }
146
+
147
+ const appended = [existingContent.trimEnd(), "", sectionHeader, ...contentLines]
148
+ .join("\n")
149
+ .trimEnd();
150
+ return `${appended}\n`;
151
+ }
152
+
153
+ const before = lines.slice(0, range.start);
154
+ const currentBody = lines.slice(range.start + 1, range.end);
155
+ const after = lines.slice(range.end);
156
+ let nextBody = currentBody;
157
+
158
+ if (patch.action === "replace") {
159
+ nextBody = contentLines;
160
+ } else if (patch.action === "add") {
161
+ const existing = new Set(
162
+ currentBody.map((line) => line.trim()).filter((line) => line !== "")
163
+ );
164
+ nextBody = [
165
+ ...currentBody,
166
+ ...contentLines.filter((line) => !existing.has(line.trim())),
167
+ ];
168
+ } else if (patch.action === "remove") {
169
+ const removed = new Set(contentLines.map((line) => line.trim()));
170
+ nextBody = currentBody.filter((line) => !removed.has(line.trim()));
171
+ }
172
+
173
+ return `${[...before, sectionHeader, ...nextBody, ...after].join("\n").trimEnd()}\n`;
174
+ }
175
+
176
+ export class Consolidator {
177
+ private config: ConsolidationConfig;
178
+ private logger: Logger;
179
+ private llmService: LLMService;
180
+
181
+ constructor(config: ConsolidationConfig, logger: Logger, llmService: LLMService) {
182
+ this.config = config;
183
+ this.logger = logger;
184
+ this.llmService = llmService;
185
+ }
186
+
187
+ async consolidate(): Promise<ConsolidationResult> {
188
+ const currentFiles = await this.readManagedFiles();
189
+ const proposal = await this.generateProposal(currentFiles);
190
+
191
+ if (proposal.decision === "NO_WRITE") {
192
+ this.logger.info("Consolidator", "Skipped consolidation cleanup", {
193
+ decision: proposal.decision,
194
+ });
195
+ return { updates: {} };
196
+ }
197
+
198
+ const updates = this.applyProposalToFiles(currentFiles, proposal);
199
+ await this.writeUpdates(updates);
200
+
201
+ this.logger.info("Consolidator", "Consolidation cleanup completed", {
202
+ updatedFiles: Object.keys(updates),
203
+ });
204
+
205
+ return { updates };
206
+ }
207
+
208
+ private async readManagedFiles(): Promise<Record<ConsolidatedFilename, string>> {
209
+ const files = {} as Record<ConsolidatedFilename, string>;
210
+
211
+ for (const filename of MANAGED_FILES) {
212
+ const filePath = path.join(this.config.workspaceDir, filename);
213
+ files[filename] =
214
+ (await readFile(filePath)) ?? `# ${stripMdExtension(filename)}\n`;
215
+ }
216
+
217
+ return files;
218
+ }
219
+
220
+ private buildPrompt(
221
+ currentFiles: Record<ConsolidatedFilename, string>
222
+ ): string {
223
+ return [
224
+ "Current MEMORY.md:",
225
+ currentFiles["MEMORY.md"].trim() || "(empty)",
226
+ "",
227
+ "Current USER.md:",
228
+ currentFiles["USER.md"].trim() || "(empty)",
229
+ "",
230
+ "Current SOUL.md:",
231
+ currentFiles["SOUL.md"].trim() || "(empty)",
232
+ "",
233
+ "Current TOOLS.md:",
234
+ currentFiles["TOOLS.md"].trim() || "(empty)",
235
+ "",
236
+ "Return JSON only as specified in the system prompt.",
237
+ ].join("\n");
238
+ }
239
+
240
+ private async generateProposal(
241
+ currentFiles: Record<ConsolidatedFilename, string>
242
+ ): Promise<ConsolidationProposal> {
243
+ try {
244
+ const response = await this.llmService.generateObject<{
245
+ decision: "NO_WRITE" | "WRITE_CLEANUP";
246
+ proposed_updates: Partial<Record<ConsolidatedFilename, ConsolidationPatch[]>>;
247
+ }>({
248
+ systemPrompt: CONSOLIDATION_SYSTEM_PROMPT,
249
+ userPrompt: this.buildPrompt(currentFiles),
250
+ schema: CONSOLIDATION_RESPONSE_SCHEMA,
251
+ });
252
+ return {
253
+ decision: response.decision,
254
+ proposedUpdates: response.proposed_updates ?? {},
255
+ };
256
+ } catch (error) {
257
+ const reason = getErrorMessage(error);
258
+ this.logger.error("Consolidator", "Consolidation LLM request failed", {
259
+ reason,
260
+ });
261
+ return {
262
+ decision: "NO_WRITE",
263
+ proposedUpdates: {},
264
+ };
265
+ }
266
+ }
267
+
268
+ private applyProposalToFiles(
269
+ currentFiles: Record<ConsolidatedFilename, string>,
270
+ proposal: ConsolidationProposal
271
+ ): ConsolidationResult["updates"] {
272
+ const updates: ConsolidationResult["updates"] = {};
273
+
274
+ for (const filename of MANAGED_FILES) {
275
+ const patches = proposal.proposedUpdates[filename];
276
+ if (!patches || patches.length === 0) {
277
+ continue;
278
+ }
279
+
280
+ let nextContent = currentFiles[filename];
281
+ for (const patch of patches) {
282
+ nextContent = applyPatchToContent(nextContent, patch);
283
+ }
284
+
285
+ updates[filename] = nextContent;
286
+ }
287
+
288
+ return updates;
289
+ }
290
+
291
+ private async writeUpdates(
292
+ updates: ConsolidationResult["updates"]
293
+ ): Promise<void> {
294
+ const entries = Object.entries(updates) as Array<
295
+ [ConsolidatedFilename, string | undefined]
296
+ >;
297
+
298
+ for (const [filename, content] of entries) {
299
+ if (typeof content !== "string") {
300
+ continue;
301
+ }
302
+
303
+ const filePath = path.join(this.config.workspaceDir, filename);
304
+
305
+ try {
306
+ await writeFileWithLock(filePath, content);
307
+ } catch (error) {
308
+ this.logger.error("Consolidator", "Failed to write consolidated file", {
309
+ filename,
310
+ filePath,
311
+ reason: getErrorMessage(error),
312
+ });
313
+ }
314
+ }
315
+ }
316
+ }
@@ -0,0 +1,9 @@
1
+ export { Consolidator } from "./consolidator.js";
2
+ export { ConsolidationScheduler } from "./scheduler.js";
3
+ export type {
4
+ ConsolidationConfig,
5
+ ConsolidatedFilename,
6
+ ConsolidationPatch,
7
+ ConsolidationProposal,
8
+ ConsolidationResult,
9
+ } from "./types.js";
@@ -0,0 +1,58 @@
1
+ export const CONSOLIDATION_SYSTEM_PROMPT = `You are Lia's Memory Consolidation job.
2
+
3
+ Inputs:
4
+ - Current MEMORY.md
5
+ - Current USER.md
6
+ - Current SOUL.md
7
+ - Current TOOLS.md
8
+
9
+ Task:
10
+ - Decide whether cleanup is needed
11
+ - Merge repeated observations
12
+ - Replace outdated long-term entries
13
+ - Keep long-term memory concise and stable
14
+
15
+ Rules:
16
+ - Most runs should be NO_WRITE
17
+ - At most 5 changes per run
18
+ - Prefer replacing noisy detail with cleaner abstraction
19
+ - Do NOT invent new facts
20
+ - SOUL.md updates should be low-frequency
21
+ - IDENTITY.md is out of scope
22
+ - TOOLS.md cleanup should stay narrow: merge duplicate mappings, remove stale aliases, and keep local tool notes compact
23
+ - Do not infer runtime tool availability from TOOLS.md
24
+
25
+ Output JSON only:
26
+ {
27
+ "decision": "NO_WRITE|WRITE_CLEANUP",
28
+ "proposed_updates": {
29
+ "MEMORY.md": [
30
+ {
31
+ "section": "target section",
32
+ "action": "add|replace|remove",
33
+ "content": "new markdown content"
34
+ }
35
+ ],
36
+ "USER.md": [
37
+ {
38
+ "section": "target section",
39
+ "action": "add|replace|remove",
40
+ "content": "new markdown content"
41
+ }
42
+ ],
43
+ "SOUL.md": [
44
+ {
45
+ "section": "target section",
46
+ "action": "add|replace|remove",
47
+ "content": "new markdown content"
48
+ }
49
+ ],
50
+ "TOOLS.md": [
51
+ {
52
+ "section": "target section",
53
+ "action": "add|replace|remove",
54
+ "content": "new markdown content"
55
+ }
56
+ ]
57
+ }
58
+ }`;
@@ -0,0 +1,153 @@
1
+ import type { LLMService, Logger } from "../types.js";
2
+ import { Consolidator } from "./consolidator.js";
3
+ import type { ConsolidationConfig } from "./types.js";
4
+
5
+ const DAY_IN_MS = 24 * 60 * 60 * 1000;
6
+
7
+ interface DailySchedule {
8
+ minute: number;
9
+ hour: number;
10
+ }
11
+
12
+ function getErrorMessage(error: unknown): string {
13
+ if (error instanceof Error) {
14
+ return error.message;
15
+ }
16
+
17
+ return String(error);
18
+ }
19
+
20
+ function parseNumber(value: string): number | null {
21
+ if (!/^\d+$/.test(value)) {
22
+ return null;
23
+ }
24
+
25
+ const parsed = Number(value);
26
+ return Number.isInteger(parsed) ? parsed : null;
27
+ }
28
+
29
+ function parseDailySchedule(schedule: string): DailySchedule {
30
+ const parts = schedule.trim().split(/\s+/);
31
+
32
+ if (parts.length !== 5) {
33
+ throw new Error(`Invalid schedule format: "${schedule}"`);
34
+ }
35
+
36
+ const [minutePart, hourPart, dayOfMonthPart, monthPart, dayOfWeekPart] = parts;
37
+
38
+ if (dayOfMonthPart !== "*" || monthPart !== "*" || dayOfWeekPart !== "*") {
39
+ throw new Error(`Only daily schedule is supported: "${schedule}"`);
40
+ }
41
+
42
+ const minute = parseNumber(minutePart);
43
+ const hour = parseNumber(hourPart);
44
+
45
+ if (minute === null || minute < 0 || minute > 59) {
46
+ throw new Error(`Invalid minute in schedule: "${schedule}"`);
47
+ }
48
+
49
+ if (hour === null || hour < 0 || hour > 23) {
50
+ throw new Error(`Invalid hour in schedule: "${schedule}"`);
51
+ }
52
+
53
+ return { minute, hour };
54
+ }
55
+
56
+ function getNextRunTime(schedule: DailySchedule, now: Date = new Date()): Date {
57
+ const next = new Date(now);
58
+ next.setSeconds(0, 0);
59
+ next.setHours(schedule.hour, schedule.minute, 0, 0);
60
+
61
+ if (next <= now) {
62
+ next.setDate(next.getDate() + 1);
63
+ }
64
+
65
+ return next;
66
+ }
67
+
68
+ export class ConsolidationScheduler {
69
+ private config: ConsolidationConfig;
70
+ private logger: Logger;
71
+ private consolidator: Consolidator;
72
+ private timeoutId: ReturnType<typeof setTimeout> | null;
73
+ private intervalId: ReturnType<typeof setInterval> | null;
74
+
75
+ constructor(config: ConsolidationConfig, logger: Logger, llmService: LLMService) {
76
+ this.config = config;
77
+ this.logger = logger;
78
+ this.consolidator = new Consolidator(config, logger, llmService);
79
+ this.timeoutId = null;
80
+ this.intervalId = null;
81
+ }
82
+
83
+ start(): void {
84
+ if (this.timeoutId !== null || this.intervalId !== null) {
85
+ this.logger.warn("ConsolidationScheduler", "Scheduler is already running", {
86
+ schedule: this.config.schedule,
87
+ });
88
+ return;
89
+ }
90
+
91
+ try {
92
+ const parsedSchedule = parseDailySchedule(this.config.schedule);
93
+ const nextRunAt = getNextRunTime(parsedSchedule);
94
+ const delayMs = Math.max(nextRunAt.getTime() - Date.now(), 0);
95
+
96
+ this.logger.info("ConsolidationScheduler", "Scheduler started", {
97
+ schedule: this.config.schedule,
98
+ nextRunAt: nextRunAt.toISOString(),
99
+ delayMs,
100
+ });
101
+
102
+ this.timeoutId = setTimeout(() => {
103
+ void this.runConsolidation();
104
+
105
+ this.intervalId = setInterval(() => {
106
+ void this.runConsolidation();
107
+ }, DAY_IN_MS);
108
+
109
+ this.timeoutId = null;
110
+ }, delayMs);
111
+ } catch (error) {
112
+ this.logger.error("ConsolidationScheduler", "Failed to start scheduler", {
113
+ schedule: this.config.schedule,
114
+ reason: getErrorMessage(error),
115
+ });
116
+ }
117
+ }
118
+
119
+ stop(): void {
120
+ if (this.timeoutId !== null) {
121
+ clearTimeout(this.timeoutId);
122
+ this.timeoutId = null;
123
+ }
124
+
125
+ if (this.intervalId !== null) {
126
+ clearInterval(this.intervalId);
127
+ this.intervalId = null;
128
+ }
129
+
130
+ this.logger.info("ConsolidationScheduler", "Scheduler stopped", {
131
+ schedule: this.config.schedule,
132
+ });
133
+ }
134
+
135
+ private async runConsolidation(): Promise<void> {
136
+ this.logger.info("ConsolidationScheduler", "Starting scheduled consolidation run", {
137
+ schedule: this.config.schedule,
138
+ });
139
+
140
+ try {
141
+ const result = await this.consolidator.consolidate();
142
+ this.logger.info("ConsolidationScheduler", "Scheduled consolidation run completed", {
143
+ decision: Object.keys(result.updates).length === 0 ? "NO_WRITE" : "WRITE_CLEANUP",
144
+ updatedFiles: Object.keys(result.updates),
145
+ });
146
+ } catch (error) {
147
+ const reason = getErrorMessage(error);
148
+ this.logger.error("ConsolidationScheduler", "Scheduled consolidation run failed", {
149
+ reason,
150
+ });
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,25 @@
1
+ export interface ConsolidationConfig {
2
+ workspaceDir: string;
3
+ schedule: string;
4
+ }
5
+
6
+ export type ConsolidatedFilename =
7
+ | "MEMORY.md"
8
+ | "USER.md"
9
+ | "SOUL.md"
10
+ | "TOOLS.md";
11
+
12
+ export interface ConsolidationPatch {
13
+ section: string;
14
+ action: "add" | "replace" | "remove";
15
+ content: string;
16
+ }
17
+
18
+ export interface ConsolidationProposal {
19
+ decision: "NO_WRITE" | "WRITE_CLEANUP";
20
+ proposedUpdates: Partial<Record<ConsolidatedFilename, ConsolidationPatch[]>>;
21
+ }
22
+
23
+ export interface ConsolidationResult {
24
+ updates: Partial<Record<ConsolidatedFilename, string>>;
25
+ }
@@ -0,0 +1,45 @@
1
+ export type EvalSuite = "all" | "memory-gate" | "writer-guardian";
2
+
3
+ export interface EvalCliOptions {
4
+ suite: EvalSuite;
5
+ useJudge: boolean;
6
+ datasetRoot?: string;
7
+ sharedDatasetPath?: string;
8
+ memoryGateDatasetPath?: string;
9
+ writerGuardianDatasetPath?: string;
10
+ }
11
+
12
+ function getArgValue(argv: string[], flag: string): string | undefined {
13
+ const index = argv.indexOf(flag);
14
+ if (index === -1 || index + 1 >= argv.length) {
15
+ return undefined;
16
+ }
17
+
18
+ return argv[index + 1];
19
+ }
20
+
21
+ function parseSuite(value: string | undefined): EvalSuite {
22
+ const suite = value ?? "all";
23
+ if (
24
+ suite === "all" ||
25
+ suite === "memory-gate" ||
26
+ suite === "writer-guardian"
27
+ ) {
28
+ return suite;
29
+ }
30
+
31
+ throw new Error(
32
+ `Unsupported suite: ${suite}. Expected one of: all, memory-gate, writer-guardian`
33
+ );
34
+ }
35
+
36
+ export function parseEvalCliOptions(argv: string[]): EvalCliOptions {
37
+ return {
38
+ suite: parseSuite(getArgValue(argv, "--suite")),
39
+ useJudge: !argv.includes("--no-judge"),
40
+ datasetRoot: getArgValue(argv, "--dataset-root"),
41
+ sharedDatasetPath: getArgValue(argv, "--shared-dataset"),
42
+ memoryGateDatasetPath: getArgValue(argv, "--memory-gate-dataset"),
43
+ writerGuardianDatasetPath: getArgValue(argv, "--writer-guardian-dataset"),
44
+ };
45
+ }
@@ -0,0 +1,39 @@
1
+ import path from "node:path";
2
+
3
+ export interface ResolveEvalDatasetPathsInput {
4
+ rootDir: string;
5
+ datasetRoot?: string;
6
+ sharedDatasetPath?: string;
7
+ memoryGateDatasetPath?: string;
8
+ writerGuardianDatasetPath?: string;
9
+ }
10
+
11
+ export interface EvalDatasetPaths {
12
+ sharedDatasetPath: string;
13
+ memoryGateDatasetPath: string;
14
+ writerGuardianDatasetPath: string;
15
+ }
16
+
17
+ function resolvePath(rootDir: string, targetPath: string): string {
18
+ return path.isAbsolute(targetPath) ? targetPath : path.join(rootDir, targetPath);
19
+ }
20
+
21
+ export function resolveEvalDatasetPaths(
22
+ input: ResolveEvalDatasetPathsInput
23
+ ): EvalDatasetPaths {
24
+ const datasetRoot = input.datasetRoot
25
+ ? resolvePath(input.rootDir, input.datasetRoot)
26
+ : path.join(input.rootDir, "evals/datasets");
27
+
28
+ return {
29
+ sharedDatasetPath: input.sharedDatasetPath
30
+ ? resolvePath(input.rootDir, input.sharedDatasetPath)
31
+ : path.join(datasetRoot, "shared/scenarios.jsonl"),
32
+ memoryGateDatasetPath: input.memoryGateDatasetPath
33
+ ? resolvePath(input.rootDir, input.memoryGateDatasetPath)
34
+ : path.join(datasetRoot, "memory-gate/benchmark.jsonl"),
35
+ writerGuardianDatasetPath: input.writerGuardianDatasetPath
36
+ ? resolvePath(input.rootDir, input.writerGuardianDatasetPath)
37
+ : path.join(datasetRoot, "writer-guardian/benchmark.jsonl"),
38
+ };
39
+ }