From 3c7e02f9746c5ba489402dbf3681e8758f787f51 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 11 May 2026 16:30:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8E=86=E5=8F=B2=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E4=BF=9D=E5=AD=98=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/agents/{agent.md => instruction.md} | 0 .opencode/tools/skill_manager.ts | 9 +++- opencode.json | 2 +- src/config.ts | 2 + src/memory/store.ts | 17 ++++-- src/utils/fileStore.ts | 54 ++++++++++++++++++- 6 files changed, 77 insertions(+), 7 deletions(-) rename .opencode/agents/{agent.md => instruction.md} (100%) diff --git a/.opencode/agents/agent.md b/.opencode/agents/instruction.md similarity index 100% rename from .opencode/agents/agent.md rename to .opencode/agents/instruction.md diff --git a/.opencode/tools/skill_manager.ts b/.opencode/tools/skill_manager.ts index 23a7876..d88632a 100644 --- a/.opencode/tools/skill_manager.ts +++ b/.opencode/tools/skill_manager.ts @@ -1,10 +1,11 @@ import { tool } from "@opencode-ai/plugin"; import { join, posix } from "node:path"; +import { config } from "../../src/config.js"; import { ResultReferenceStore } from "../../src/results/store.js"; import { ToolSessionContextStore } from "../../src/session/toolContextStore.js"; import { - atomicWriteFile, + atomicWriteFileWithHistory, ensureDirectory, readTextFile, } from "../../src/utils/fileStore.js"; @@ -17,6 +18,7 @@ const initializePromise = Promise.all([ toolContextStore.initialize(), ]); const SKILLS_ROOT_DIR = ".opencode/skills"; +const SKILLS_HISTORY_DIR = join(config.PERSISTENCE_HISTORY_DIR, "skills"); const LEARNED_PATTERNS_MARKER = "## Learned Patterns"; let writeQueue: Promise = Promise.resolve(); @@ -118,7 +120,10 @@ const appendLearnedSkillPattern = async (skillPath: string, pattern: string) => : `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- ${pattern}\n`; await ensureDirectory(join(SKILLS_ROOT_DIR, skillPath)); - await atomicWriteFile(target, next); + await atomicWriteFileWithHistory(target, next, { + historyDir: SKILLS_HISTORY_DIR, + rootDir: SKILLS_ROOT_DIR, + }); return { changed: true, target }; }); }; diff --git a/opencode.json b/opencode.json index 9610dcd..b4b12ab 100644 --- a/opencode.json +++ b/opencode.json @@ -12,5 +12,5 @@ "hostname": "127.0.0.1", "port": 4096 }, - "default_agent": "agent" + "default_agent": "instruction" } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index feb882c..397ebe5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,6 +48,8 @@ const envSchema = z.object({ MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3), // memory 持久化存储目录。 MEMORY_STORAGE_DIR: z.string().default("./data/memory"), + // 持久化文件写入前保留历史版本的目录。 + PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"), // 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。 MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800), // result_ref 持久化存储目录。 diff --git a/src/memory/store.ts b/src/memory/store.ts index 9b9a287..9141e8d 100644 --- a/src/memory/store.ts +++ b/src/memory/store.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { config } from "../config.js"; import { sanitizePersistentLine } from "../utils/persistencePolicy.js"; import { - atomicWriteFile, + atomicWriteFileWithHistory, ensureDirectory, readTextFile, } from "../utils/fileStore.js"; @@ -37,12 +37,16 @@ const SUSPICIOUS_MEMORY_PATTERNS = [ export class MemoryStore { private writeQueue: Promise = Promise.resolve(); - constructor(private readonly baseDir = config.MEMORY_STORAGE_DIR) {} + constructor( + private readonly baseDir = config.MEMORY_STORAGE_DIR, + private readonly historyDir = join(config.PERSISTENCE_HISTORY_DIR, "memory"), + ) {} async initialize() { await ensureDirectory(this.baseDir); await ensureDirectory(join(this.baseDir, "users")); await ensureDirectory(join(this.baseDir, "workspaces")); + await ensureDirectory(this.historyDir); } async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) { @@ -60,7 +64,14 @@ export class MemoryStore { const entry: MemoryEntry = { content }; entries.unshift(entry); - await atomicWriteFile(this.filePath(scope, key), renderMemoryMarkdown(scope, entries)); + await atomicWriteFileWithHistory( + this.filePath(scope, key), + renderMemoryMarkdown(scope, entries), + { + historyDir: this.historyDir, + rootDir: this.baseDir, + }, + ); return { changed: true, entry }; }); } diff --git a/src/utils/fileStore.ts b/src/utils/fileStore.ts index 4ec45f6..dac66ce 100644 --- a/src/utils/fileStore.ts +++ b/src/utils/fileStore.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { basename, dirname, join, relative } from "node:path"; type JsonRecord = Record; @@ -18,6 +18,50 @@ export const atomicWriteFile = async (path: string, content: string) => { await rename(tempPath, path); }; +type HistoricalWriteOptions = { + afterWrite?: () => Promise | void; + historyDir: string; + rootDir: string; +}; + +export const atomicWriteFileWithHistory = async ( + path: string, + content: string, + options: HistoricalWriteOptions, +) => { + const previous = await readTextFile(path); + if (previous === content) { + return { backupPath: null as string | null, changed: false }; + } + + let backupPath: string | null = null; + if (previous !== null) { + backupPath = buildHistoryBackupPath(path, options); + await atomicWriteFile(backupPath, previous); + } + + try { + await atomicWriteFile(path, content); + await options.afterWrite?.(); + } catch (error) { + try { + if (previous === null) { + await removeFileIfExists(path); + } else { + await atomicWriteFile(path, previous); + } + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `write failed and rollback failed for ${path}`, + ); + } + throw error; + } + + return { backupPath, changed: true }; +}; + export const atomicWriteJson = async (path: string, value: JsonRecord | unknown[]) => { await atomicWriteFile(path, JSON.stringify(value, null, 2)); }; @@ -109,3 +153,11 @@ export const slugify = (value: string) => .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 64) || "entry"; + +const buildHistoryBackupPath = (path: string, options: HistoricalWriteOptions) => { + const relativePath = relative(options.rootDir, path); + const scopedPath = + relativePath && !relativePath.startsWith("..") ? relativePath : basename(path); + const backupName = `${basename(path)}.${Date.now().toString(36)}.bak`; + return join(options.historyDir, dirname(scopedPath), backupName); +};