增加历史版本保存功能
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
import { join, posix } from "node:path";
|
import { join, posix } from "node:path";
|
||||||
|
|
||||||
|
import { config } from "../../src/config.js";
|
||||||
import { ResultReferenceStore } from "../../src/results/store.js";
|
import { ResultReferenceStore } from "../../src/results/store.js";
|
||||||
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||||
import {
|
import {
|
||||||
atomicWriteFile,
|
atomicWriteFileWithHistory,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
readTextFile,
|
readTextFile,
|
||||||
} from "../../src/utils/fileStore.js";
|
} from "../../src/utils/fileStore.js";
|
||||||
@@ -17,6 +18,7 @@ const initializePromise = Promise.all([
|
|||||||
toolContextStore.initialize(),
|
toolContextStore.initialize(),
|
||||||
]);
|
]);
|
||||||
const SKILLS_ROOT_DIR = ".opencode/skills";
|
const SKILLS_ROOT_DIR = ".opencode/skills";
|
||||||
|
const SKILLS_HISTORY_DIR = join(config.PERSISTENCE_HISTORY_DIR, "skills");
|
||||||
const LEARNED_PATTERNS_MARKER = "## Learned Patterns";
|
const LEARNED_PATTERNS_MARKER = "## Learned Patterns";
|
||||||
let writeQueue: Promise<void> = Promise.resolve();
|
let writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
@@ -118,7 +120,10 @@ const appendLearnedSkillPattern = async (skillPath: string, pattern: string) =>
|
|||||||
: `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- ${pattern}\n`;
|
: `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- ${pattern}\n`;
|
||||||
|
|
||||||
await ensureDirectory(join(SKILLS_ROOT_DIR, skillPath));
|
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 };
|
return { changed: true, target };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -12,5 +12,5 @@
|
|||||||
"hostname": "127.0.0.1",
|
"hostname": "127.0.0.1",
|
||||||
"port": 4096
|
"port": 4096
|
||||||
},
|
},
|
||||||
"default_agent": "agent"
|
"default_agent": "instruction"
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,8 @@ const envSchema = z.object({
|
|||||||
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
|
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
|
||||||
// memory 持久化存储目录。
|
// memory 持久化存储目录。
|
||||||
MEMORY_STORAGE_DIR: z.string().default("./data/memory"),
|
MEMORY_STORAGE_DIR: z.string().default("./data/memory"),
|
||||||
|
// 持久化文件写入前保留历史版本的目录。
|
||||||
|
PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"),
|
||||||
// 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。
|
// 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。
|
||||||
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
||||||
// result_ref 持久化存储目录。
|
// result_ref 持久化存储目录。
|
||||||
|
|||||||
+14
-3
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { sanitizePersistentLine } from "../utils/persistencePolicy.js";
|
import { sanitizePersistentLine } from "../utils/persistencePolicy.js";
|
||||||
import {
|
import {
|
||||||
atomicWriteFile,
|
atomicWriteFileWithHistory,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
readTextFile,
|
readTextFile,
|
||||||
} from "../utils/fileStore.js";
|
} from "../utils/fileStore.js";
|
||||||
@@ -37,12 +37,16 @@ const SUSPICIOUS_MEMORY_PATTERNS = [
|
|||||||
export class MemoryStore {
|
export class MemoryStore {
|
||||||
private writeQueue: Promise<void> = Promise.resolve();
|
private writeQueue: Promise<void> = 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() {
|
async initialize() {
|
||||||
await ensureDirectory(this.baseDir);
|
await ensureDirectory(this.baseDir);
|
||||||
await ensureDirectory(join(this.baseDir, "users"));
|
await ensureDirectory(join(this.baseDir, "users"));
|
||||||
await ensureDirectory(join(this.baseDir, "workspaces"));
|
await ensureDirectory(join(this.baseDir, "workspaces"));
|
||||||
|
await ensureDirectory(this.historyDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
|
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
|
||||||
@@ -60,7 +64,14 @@ export class MemoryStore {
|
|||||||
|
|
||||||
const entry: MemoryEntry = { content };
|
const entry: MemoryEntry = { content };
|
||||||
entries.unshift(entry);
|
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 };
|
return { changed: true, entry };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-1
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
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<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -18,6 +18,50 @@ export const atomicWriteFile = async (path: string, content: string) => {
|
|||||||
await rename(tempPath, path);
|
await rename(tempPath, path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HistoricalWriteOptions = {
|
||||||
|
afterWrite?: () => Promise<void> | 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[]) => {
|
export const atomicWriteJson = async (path: string, value: JsonRecord | unknown[]) => {
|
||||||
await atomicWriteFile(path, JSON.stringify(value, null, 2));
|
await atomicWriteFile(path, JSON.stringify(value, null, 2));
|
||||||
};
|
};
|
||||||
@@ -109,3 +153,11 @@ export const slugify = (value: string) =>
|
|||||||
.replace(/[^a-z0-9._-]+/g, "-")
|
.replace(/[^a-z0-9._-]+/g, "-")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
.slice(0, 64) || "entry";
|
.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);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user