@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,446 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+
5
+ import { FileCurator } from "../file-curator/index.js";
6
+ import { LLMService } from "../llm/service.js";
7
+ import { MemoryGateAnalyzer } from "../memory-gate/analyzer.js";
8
+ import type {
9
+ AgentStep,
10
+ LLMService as LLMServiceContract,
11
+ Logger,
12
+ MemoryGateOutput,
13
+ } from "../types.js";
14
+
15
+ export interface SharedScenario {
16
+ scenario_id: string;
17
+ task_type?: "memory_gate" | "writer_guardian";
18
+ title: string;
19
+ recent_messages?: Array<{
20
+ role: "user" | "agent";
21
+ message: string;
22
+ }>;
23
+ current_user_message?: string;
24
+ current_agent_reply?: string;
25
+ gate_decision?: MemoryGateOutput["decision"];
26
+ gate_reason?: string;
27
+ candidate_fact?: string;
28
+ target_file?: "MEMORY.md" | "USER.md" | "SOUL.md" | "IDENTITY.md" | "TOOLS.md";
29
+ current_file_content?: string;
30
+ notes: string;
31
+ }
32
+
33
+ export interface MemoryGateBenchmarkCase {
34
+ scenario_id: string;
35
+ expected_decision: MemoryGateOutput["decision"];
36
+ expected_candidate_fact?: string;
37
+ allowed_candidate_fact_variants?: string[];
38
+ severity: "core" | "boundary";
39
+ tags: string[];
40
+ }
41
+
42
+ export interface WriterGuardianBenchmarkCase {
43
+ scenario_id: string;
44
+ expected_should_write: boolean;
45
+ expected_outcome_type: string;
46
+ allowed_tool_traces: string[][];
47
+ expected_content_contains?: string[];
48
+ expected_content_not_contains?: string[];
49
+ tags: string[];
50
+ }
51
+
52
+ export interface MemoryGateCaseResult {
53
+ scenarioId: string;
54
+ pass: boolean;
55
+ decisionPass: boolean;
56
+ candidatePass: boolean;
57
+ judgeUsed: boolean;
58
+ actualDecision: MemoryGateOutput["decision"];
59
+ expectedDecision: MemoryGateOutput["decision"];
60
+ actualCandidateFact?: string;
61
+ expectedCandidateFact?: string;
62
+ error?: string;
63
+ }
64
+
65
+ export interface WriterGuardianCaseResult {
66
+ scenarioId: string;
67
+ pass: boolean;
68
+ shouldWritePass: boolean;
69
+ toolTracePass: boolean;
70
+ contentPass: boolean;
71
+ actualShouldWrite: boolean;
72
+ actualToolTrace: string[];
73
+ targetFile: string;
74
+ error?: string;
75
+ }
76
+
77
+ export interface BenchmarkSummary {
78
+ total: number;
79
+ passed: number;
80
+ }
81
+
82
+ export interface Judge {
83
+ compareCandidateFact(input: {
84
+ expected: string;
85
+ actual: string;
86
+ variants: string[];
87
+ }): Promise<{ equivalent: boolean; reason: string }>;
88
+ }
89
+
90
+ function createNoopLogger(): Logger {
91
+ return {
92
+ debug() {},
93
+ info() {},
94
+ warn() {},
95
+ error() {},
96
+ };
97
+ }
98
+
99
+ function getErrorMessage(error: unknown): string {
100
+ if (error instanceof Error) {
101
+ return error.message;
102
+ }
103
+
104
+ return String(error);
105
+ }
106
+
107
+ function withScenarioLogger(baseLogger: Logger, scenarioId: string): Logger {
108
+ return {
109
+ debug(component, event, details) {
110
+ baseLogger.debug(component, event, details, scenarioId);
111
+ },
112
+ info(component, event, details) {
113
+ baseLogger.info(component, event, details, scenarioId);
114
+ },
115
+ warn(component, event, details) {
116
+ baseLogger.warn(component, event, details, scenarioId);
117
+ },
118
+ error(component, event, details) {
119
+ baseLogger.error(component, event, details, scenarioId);
120
+ },
121
+ };
122
+ }
123
+
124
+ function normalizeText(value: string): string {
125
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
126
+ }
127
+
128
+ function buildScenarioMap(scenarios: SharedScenario[]): Map<string, SharedScenario> {
129
+ return new Map(scenarios.map((scenario) => [scenario.scenario_id, scenario]));
130
+ }
131
+
132
+ function arraysEqual(left: string[], right: string[]): boolean {
133
+ return left.length === right.length && left.every((item, index) => item === right[index]);
134
+ }
135
+
136
+ function normalizeFileContent(content: string): string {
137
+ const normalized = content.replace(/\r\n/g, "\n");
138
+ return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
139
+ }
140
+
141
+ export async function evaluateMemoryGateBenchmark(input: {
142
+ scenarios: SharedScenario[];
143
+ benchmarkCases: MemoryGateBenchmarkCase[];
144
+ executeCase: (scenario: SharedScenario) => Promise<MemoryGateOutput>;
145
+ judge?: Judge;
146
+ logger?: Logger;
147
+ }): Promise<{ summary: BenchmarkSummary; results: MemoryGateCaseResult[] }> {
148
+ const scenarioMap = buildScenarioMap(input.scenarios);
149
+ const results: MemoryGateCaseResult[] = [];
150
+ const logger = input.logger ?? createNoopLogger();
151
+
152
+ for (const benchmarkCase of input.benchmarkCases) {
153
+ const scenario = scenarioMap.get(benchmarkCase.scenario_id);
154
+ if (!scenario) {
155
+ throw new Error(`Missing shared scenario: ${benchmarkCase.scenario_id}`);
156
+ }
157
+
158
+ try {
159
+ logger.info("EvalRunner", "Starting memory gate case", {
160
+ scenarioId: benchmarkCase.scenario_id,
161
+ expectedDecision: benchmarkCase.expected_decision,
162
+ });
163
+
164
+ const actual = await input.executeCase(scenario);
165
+ const decisionPass = actual.decision === benchmarkCase.expected_decision;
166
+ let candidatePass = true;
167
+ let judgeUsed = false;
168
+
169
+ if (benchmarkCase.expected_decision !== "NO_WRITE") {
170
+ const expectedFact = benchmarkCase.expected_candidate_fact ?? "";
171
+ const actualFact = actual.candidateFact ?? "";
172
+ const variants = benchmarkCase.allowed_candidate_fact_variants ?? [];
173
+ const exactMatches =
174
+ normalizeText(actualFact) === normalizeText(expectedFact) ||
175
+ variants.some((variant) => normalizeText(actualFact) === normalizeText(variant));
176
+
177
+ candidatePass = exactMatches;
178
+
179
+ if (!candidatePass && input.judge && actualFact.trim() !== "" && expectedFact.trim() !== "") {
180
+ const judged = await input.judge.compareCandidateFact({
181
+ expected: expectedFact,
182
+ actual: actualFact,
183
+ variants,
184
+ });
185
+ candidatePass = judged.equivalent;
186
+ judgeUsed = true;
187
+ }
188
+ }
189
+
190
+ const pass = decisionPass && candidatePass;
191
+ results.push({
192
+ scenarioId: benchmarkCase.scenario_id,
193
+ pass,
194
+ decisionPass,
195
+ candidatePass,
196
+ judgeUsed,
197
+ actualDecision: actual.decision,
198
+ expectedDecision: benchmarkCase.expected_decision,
199
+ actualCandidateFact: actual.candidateFact,
200
+ expectedCandidateFact: benchmarkCase.expected_candidate_fact,
201
+ });
202
+ logger.info("EvalRunner", "Completed memory gate case", {
203
+ scenarioId: benchmarkCase.scenario_id,
204
+ pass,
205
+ decisionPass,
206
+ candidatePass,
207
+ judgeUsed,
208
+ actualDecision: actual.decision,
209
+ });
210
+ } catch (error) {
211
+ const reason = getErrorMessage(error);
212
+ results.push({
213
+ scenarioId: benchmarkCase.scenario_id,
214
+ pass: false,
215
+ decisionPass: false,
216
+ candidatePass: false,
217
+ judgeUsed: false,
218
+ actualDecision: "NO_WRITE",
219
+ expectedDecision: benchmarkCase.expected_decision,
220
+ expectedCandidateFact: benchmarkCase.expected_candidate_fact,
221
+ error: reason,
222
+ });
223
+ logger.error("EvalRunner", "Memory gate case failed", {
224
+ scenarioId: benchmarkCase.scenario_id,
225
+ reason,
226
+ });
227
+ }
228
+ }
229
+
230
+ return {
231
+ summary: {
232
+ total: results.length,
233
+ passed: results.filter((result) => result.pass).length,
234
+ },
235
+ results,
236
+ };
237
+ }
238
+
239
+ export async function evaluateWriterGuardianBenchmark(input: {
240
+ scenarios: SharedScenario[];
241
+ benchmarkCases: WriterGuardianBenchmarkCase[];
242
+ executeCase: (scenario: SharedScenario) => Promise<{
243
+ shouldWrite: boolean;
244
+ toolTrace: string[];
245
+ finalContent: string;
246
+ }>;
247
+ logger?: Logger;
248
+ }): Promise<{ summary: BenchmarkSummary; results: WriterGuardianCaseResult[] }> {
249
+ const scenarioMap = buildScenarioMap(input.scenarios);
250
+ const results: WriterGuardianCaseResult[] = [];
251
+ const logger = input.logger ?? createNoopLogger();
252
+
253
+ for (const benchmarkCase of input.benchmarkCases) {
254
+ const scenario = scenarioMap.get(benchmarkCase.scenario_id);
255
+ if (!scenario) {
256
+ throw new Error(`Missing shared scenario: ${benchmarkCase.scenario_id}`);
257
+ }
258
+
259
+ if (!scenario.target_file || typeof scenario.current_file_content !== "string") {
260
+ throw new Error(`Writer scenario is missing target_file or current_file_content: ${scenario.scenario_id}`);
261
+ }
262
+
263
+ try {
264
+ logger.info("EvalRunner", "Starting writer guardian case", {
265
+ scenarioId: benchmarkCase.scenario_id,
266
+ targetFile: scenario.target_file,
267
+ expectedShouldWrite: benchmarkCase.expected_should_write,
268
+ });
269
+
270
+ const actual = await input.executeCase(scenario);
271
+ const initialContent = normalizeFileContent(scenario.current_file_content);
272
+ const normalizedFinal = normalizeFileContent(actual.finalContent);
273
+ const shouldWritePass =
274
+ actual.shouldWrite === benchmarkCase.expected_should_write &&
275
+ (benchmarkCase.expected_should_write ? true : normalizedFinal === initialContent);
276
+ const toolTracePass = benchmarkCase.allowed_tool_traces.some((trace) =>
277
+ arraysEqual(trace, actual.toolTrace)
278
+ );
279
+ const expectedContains = benchmarkCase.expected_content_contains ?? [];
280
+ const expectedNotContains = benchmarkCase.expected_content_not_contains ?? [];
281
+ const contentPass =
282
+ expectedContains.every((snippet) => normalizedFinal.includes(snippet)) &&
283
+ expectedNotContains.every((snippet) => !normalizedFinal.includes(snippet));
284
+ const pass = shouldWritePass && toolTracePass && contentPass;
285
+
286
+ results.push({
287
+ scenarioId: benchmarkCase.scenario_id,
288
+ pass,
289
+ shouldWritePass,
290
+ toolTracePass,
291
+ contentPass,
292
+ actualShouldWrite: actual.shouldWrite,
293
+ actualToolTrace: actual.toolTrace,
294
+ targetFile: scenario.target_file,
295
+ });
296
+ logger.info("EvalRunner", "Completed writer guardian case", {
297
+ scenarioId: benchmarkCase.scenario_id,
298
+ pass,
299
+ shouldWritePass,
300
+ toolTracePass,
301
+ contentPass,
302
+ actualShouldWrite: actual.shouldWrite,
303
+ actualToolTrace: actual.toolTrace,
304
+ });
305
+ } catch (error) {
306
+ const reason = getErrorMessage(error);
307
+ results.push({
308
+ scenarioId: benchmarkCase.scenario_id,
309
+ pass: false,
310
+ shouldWritePass: false,
311
+ toolTracePass: false,
312
+ contentPass: false,
313
+ actualShouldWrite: false,
314
+ actualToolTrace: [],
315
+ targetFile: scenario.target_file,
316
+ error: reason,
317
+ });
318
+ logger.error("EvalRunner", "Writer guardian case failed", {
319
+ scenarioId: benchmarkCase.scenario_id,
320
+ targetFile: scenario.target_file,
321
+ reason,
322
+ });
323
+ }
324
+ }
325
+
326
+ return {
327
+ summary: {
328
+ total: results.length,
329
+ passed: results.filter((result) => result.pass).length,
330
+ },
331
+ results,
332
+ };
333
+ }
334
+
335
+ export async function runMemoryGateCase(input: {
336
+ scenario: SharedScenario;
337
+ llmService: LLMServiceContract;
338
+ logger?: Logger;
339
+ }): Promise<MemoryGateOutput> {
340
+ if (
341
+ !input.scenario.current_user_message ||
342
+ typeof input.scenario.current_agent_reply !== "string"
343
+ ) {
344
+ throw new Error(`Memory gate scenario is missing current turn fields: ${input.scenario.scenario_id}`);
345
+ }
346
+
347
+ const analyzer = new MemoryGateAnalyzer(
348
+ input.llmService,
349
+ withScenarioLogger(input.logger ?? createNoopLogger(), input.scenario.scenario_id)
350
+ );
351
+ const recentMessages = (input.scenario.recent_messages ?? []).map((message, index) => ({
352
+ ...message,
353
+ timestamp: 1_700_000_000_000 + index * 1000,
354
+ }));
355
+
356
+ return analyzer.analyze({
357
+ recentMessages,
358
+ currentUserMessage: input.scenario.current_user_message,
359
+ currentAgentReply: input.scenario.current_agent_reply,
360
+ });
361
+ }
362
+
363
+ export async function runWriterGuardianCase(input: {
364
+ scenario: SharedScenario;
365
+ llmService: LLMServiceContract;
366
+ logger?: Logger;
367
+ }): Promise<{ shouldWrite: boolean; toolTrace: string[]; finalContent: string }> {
368
+ const scenario = input.scenario;
369
+ if (
370
+ !scenario.target_file ||
371
+ !scenario.gate_decision ||
372
+ !scenario.gate_reason ||
373
+ !scenario.candidate_fact ||
374
+ typeof scenario.current_file_content !== "string"
375
+ ) {
376
+ throw new Error(`Writer guardian scenario is missing required fields: ${scenario.scenario_id}`);
377
+ }
378
+
379
+ const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "reflection-eval-"));
380
+ const logger = withScenarioLogger(
381
+ input.logger ?? createNoopLogger(),
382
+ scenario.scenario_id
383
+ );
384
+ const filePath = path.join(workspaceDir, scenario.target_file);
385
+ const originalContent = normalizeFileContent(scenario.current_file_content);
386
+ await writeFile(filePath, originalContent, "utf8");
387
+
388
+ let lastSteps: AgentStep[] = [];
389
+ const recordingService: LLMServiceContract = {
390
+ generateObject: (params) => input.llmService.generateObject(params),
391
+ runAgent: async (params) => {
392
+ const result = await input.llmService.runAgent(params);
393
+ lastSteps = result.steps;
394
+ return result;
395
+ },
396
+ };
397
+
398
+ try {
399
+ const curator = new FileCurator({ workspaceDir }, logger, recordingService);
400
+ await curator.write({
401
+ decision: scenario.gate_decision,
402
+ reason: scenario.gate_reason,
403
+ candidateFact: scenario.candidate_fact,
404
+ });
405
+ const finalContent = normalizeFileContent((await readFile(filePath, "utf8")) ?? originalContent);
406
+ const toolTrace = lastSteps
407
+ .filter((step) => step.type === "tool" && typeof step.toolName === "string")
408
+ .map((step) => step.toolName as string);
409
+ const shouldWrite = toolTrace.includes("write") || finalContent !== originalContent;
410
+
411
+ return {
412
+ shouldWrite,
413
+ toolTrace,
414
+ finalContent,
415
+ };
416
+ } finally {
417
+ await rm(workspaceDir, { recursive: true, force: true });
418
+ }
419
+ }
420
+
421
+ export function createJudge(llmService: LLMService): Judge {
422
+ return {
423
+ async compareCandidateFact(input) {
424
+ return llmService.generateObject({
425
+ systemPrompt:
426
+ "You judge whether two candidate memory facts are semantically equivalent. Output JSON only.",
427
+ userPrompt: [
428
+ `Expected fact: ${input.expected}`,
429
+ `Actual fact: ${input.actual}`,
430
+ `Allowed variants: ${input.variants.join(" | ") || "(none)"}`,
431
+ "",
432
+ "Return whether the actual fact is an acceptable semantic match.",
433
+ ].join("\n"),
434
+ schema: {
435
+ type: "object",
436
+ additionalProperties: false,
437
+ required: ["equivalent", "reason"],
438
+ properties: {
439
+ equivalent: { type: "boolean" },
440
+ reason: { type: "string" },
441
+ },
442
+ },
443
+ });
444
+ },
445
+ };
446
+ }
@@ -0,0 +1,204 @@
1
+ import * as path from "path";
2
+ import type { AgentTool, LLMService, MemoryGateOutput, Logger } from "../types.js";
3
+ import { readFile, writeFileWithLock } from "../utils/file-utils.js";
4
+
5
+ type UpdateDecision =
6
+ | "UPDATE_MEMORY"
7
+ | "UPDATE_USER"
8
+ | "UPDATE_SOUL"
9
+ | "UPDATE_IDENTITY"
10
+ | "UPDATE_TOOLS";
11
+
12
+ type CuratedFilename =
13
+ | "MEMORY.md"
14
+ | "USER.md"
15
+ | "SOUL.md"
16
+ | "IDENTITY.md"
17
+ | "TOOLS.md";
18
+
19
+ interface FileCuratorConfig {
20
+ workspaceDir: string;
21
+ }
22
+
23
+ export interface FileCuratorWriteResult {
24
+ status: "written" | "refused" | "failed" | "skipped";
25
+ reason?: string;
26
+ }
27
+
28
+ const FILE_CURATOR_SYSTEM_PROMPT = `You are the assistant's Writer Guardian.
29
+
30
+ Your job:
31
+ - Decide whether the candidate fact should update the target memory file
32
+ - Use the read tool if you need the current file content
33
+ - Use the write tool only if the target file truly should change
34
+ - If the target file should not change, finish without calling write
35
+ - If you write, preserve the candidate fact explicitly unless the exact wording is already present
36
+
37
+ You are a guardian, not an eager writer.
38
+ When in doubt, refuse.
39
+
40
+ File meanings:
41
+ - MEMORY.md: curated long-term memory. Keep durable decisions, lessons learned, shared context, and important private context. Reject fleeting chatter, short-lived project chatter, user profile facts, identity metadata, and assistant principles.
42
+ - USER.md: about your human. Keep stable preferences, collaboration style, and helpful personal context. Do not turn this into a dossier. Reject project chatter in USER.md, one-off tactics, temporary moods, and surveillance-style detail.
43
+ - SOUL.md: the assistant's enduring principles, boundaries, continuity rules, and general voice. General write-policy or disclosure-policy rules can belong here. Reject temporary tone shifts, project tactics, user profile facts, and identity metadata.
44
+ - IDENTITY.md: Identity metadata only. Keep name, creature, vibe, emoji, avatar, or equivalent identity metadata. If the candidate fact is an explicit metadata change, write it and replace existing metadata when needed. Reject anything that is not identity metadata.
45
+ - TOOLS.md: environment-specific tool context only. Keep local aliases, endpoints, room or device names, preferred TTS voices, and other local mappings that help the assistant use tools correctly in this workspace. Reject reusable procedures that belong in a skill, runtime tool availability claims, user facts, identity metadata, and general long-term memory.
46
+
47
+ Hard constraints:
48
+ - Only reason about the target file you were given
49
+ - Do not route to another file
50
+ - Do not read or infer from other files
51
+ - If you refuse, finish without calling write
52
+ - If you write, overwrite the full target file content
53
+ - Preserve useful existing structure unless there is a strong reason to reorganize`;
54
+
55
+ const TARGET_FILES: Record<UpdateDecision, CuratedFilename> = {
56
+ UPDATE_MEMORY: "MEMORY.md",
57
+ UPDATE_USER: "USER.md",
58
+ UPDATE_SOUL: "SOUL.md",
59
+ UPDATE_IDENTITY: "IDENTITY.md",
60
+ UPDATE_TOOLS: "TOOLS.md",
61
+ };
62
+
63
+ function isUpdateDecision(
64
+ decision: MemoryGateOutput["decision"]
65
+ ): decision is UpdateDecision {
66
+ return (
67
+ decision === "UPDATE_MEMORY" ||
68
+ decision === "UPDATE_USER" ||
69
+ decision === "UPDATE_SOUL" ||
70
+ decision === "UPDATE_IDENTITY" ||
71
+ decision === "UPDATE_TOOLS"
72
+ );
73
+ }
74
+
75
+ function getErrorMessage(error: unknown): string {
76
+ if (error instanceof Error) {
77
+ return error.message;
78
+ }
79
+
80
+ return String(error);
81
+ }
82
+
83
+ function getDefaultContent(targetFile: CuratedFilename): string {
84
+ return `# ${targetFile.replace(/\.md$/, "")}\n`;
85
+ }
86
+
87
+ function normalizeFileContent(content: string): string {
88
+ const normalized = content.replace(/\r\n/g, "\n");
89
+ return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
90
+ }
91
+
92
+ export class FileCurator {
93
+ private config: FileCuratorConfig;
94
+ private logger: Logger;
95
+ private llmService: LLMService;
96
+
97
+ constructor(config: FileCuratorConfig, logger: Logger, llmService: LLMService) {
98
+ this.config = config;
99
+ this.logger = logger;
100
+ this.llmService = llmService;
101
+ }
102
+
103
+ async write(output: MemoryGateOutput): Promise<FileCuratorWriteResult> {
104
+ if (!isUpdateDecision(output.decision)) {
105
+ return { status: "skipped", reason: "not an update decision" };
106
+ }
107
+
108
+ const candidateFact = output.candidateFact?.trim();
109
+ if (!candidateFact) {
110
+ this.logger.warn("FileCurator", "Skip UPDATE_* without candidate fact", {
111
+ decision: output.decision,
112
+ reason: output.reason,
113
+ });
114
+ return { status: "skipped", reason: "missing candidate fact" };
115
+ }
116
+
117
+ const targetFile = TARGET_FILES[output.decision];
118
+ const filePath = path.join(this.config.workspaceDir, targetFile);
119
+
120
+ const tools = this.createTools(filePath, targetFile);
121
+
122
+ try {
123
+ const result = await this.llmService.runAgent({
124
+ systemPrompt: FILE_CURATOR_SYSTEM_PROMPT,
125
+ userPrompt: [
126
+ `Memory Gate decision: ${output.decision}`,
127
+ `Reason from gate: ${output.reason}`,
128
+ `Candidate fact: ${candidateFact}`,
129
+ `Target file: ${targetFile}`,
130
+ "",
131
+ "Decide whether this target file should change. Use read first if you need current content. If the file should change, call write with the full next file content. Otherwise finish without write.",
132
+ ].join("\n"),
133
+ tools,
134
+ maxSteps: 4,
135
+ });
136
+
137
+ if (!result.didWrite) {
138
+ const reason = result.finalMessage ?? "Writer guardian finished without write";
139
+ this.logger.info("FileCurator", "Guardian refused update", {
140
+ decision: output.decision,
141
+ filePath,
142
+ reason,
143
+ });
144
+ return { status: "refused", reason };
145
+ }
146
+
147
+ this.logger.info("FileCurator", "Writer guardian rewrote target file", {
148
+ decision: output.decision,
149
+ filePath,
150
+ });
151
+ return { status: "written" };
152
+ } catch (error) {
153
+ const reason = getErrorMessage(error);
154
+ this.logger.error("FileCurator", "Writer guardian execution failed", {
155
+ decision: output.decision,
156
+ filePath,
157
+ reason,
158
+ });
159
+ return { status: "failed", reason };
160
+ }
161
+ }
162
+
163
+ private createTools(filePath: string, targetFile: CuratedFilename): AgentTool[] {
164
+ return [
165
+ {
166
+ name: "read",
167
+ description: `Read the current raw content of ${targetFile}`,
168
+ inputSchema: {
169
+ type: "object",
170
+ properties: {},
171
+ additionalProperties: false,
172
+ },
173
+ execute: async () => (await readFile(filePath)) ?? getDefaultContent(targetFile),
174
+ },
175
+ {
176
+ name: "write",
177
+ description: `Overwrite ${targetFile} with the provided full content`,
178
+ inputSchema: {
179
+ type: "object",
180
+ properties: {
181
+ content: { type: "string" },
182
+ },
183
+ required: ["content"],
184
+ additionalProperties: false,
185
+ },
186
+ execute: async (input) => {
187
+ if (
188
+ typeof input !== "object" ||
189
+ input === null ||
190
+ typeof (input as { content?: unknown }).content !== "string"
191
+ ) {
192
+ throw new Error("write tool requires string content");
193
+ }
194
+
195
+ await writeFileWithLock(
196
+ filePath,
197
+ normalizeFileContent((input as { content: string }).content)
198
+ );
199
+ return "ok";
200
+ },
201
+ },
202
+ ];
203
+ }
204
+ }