opencode hook 沉淀用户历史输入,作为永久项目记忆
·
核心思想:
每次会话完毕,存储用户输入,定期总结到 项目dir/.history/history.txt 如果切换agent工具,或者工具内新建会话,则可以告诉agent,迅速获取历史记忆.
cursor自动做了; codex claudecode貌似没有;
使用方法
每次使用 "opencode -c 项目文件夹 " 进入会话,则hook会使用该目录当做工作目录,会话历史总结存储在该文件夹下 .history中
如果切换agent,则agent内告诉器该文件夹作用即可.
概述 readme
# OpenCode Project History Hook
## 概述
这是一个基于 OpenCode plugin hook 的项目历史沉淀方案。
目标是把用户在项目迭代过程中的关键信息沉淀到项目目录下的 `.history/` 中,让后续接手项目的人或模型可以快速理解:
- 项目背景
- 架构要点
- 业务边界
- 关键决策
- 迭代历史
- 表 schema 等结构化信息
这套方案不是会话级 summary,而是项目级长期记忆。
## 当前实现文件
- Hook / Plugin 脚本:`~/.config/opencode/plugins/project-history.js`
- 手动触发 Skill:`~/.config/opencode/skills/project-history-compress/SKILL.md`
## 实现思路
核心思路是把“自动采集”和“自动压缩”拆开:
1. 用 `chat.message` hook 采集用户原始提问
2. 把提问写入项目目录下 `.history/pending.ndjson`
3. 当满足自动压缩条件时,触发 compact
4. compact 生成长期总结,写入 `.history/history.txt`
5. compact 完成后,在 `pending.ndjson` 顶部插入已完成边界 marker,供后续压缩识别边界
这样可以兼顾两件事:
- 保存原始用户输入轨迹
- 持续产出更紧凑的长期项目记忆
## 架构
### 1. 输入采集层
通过 OpenCode plugin hook 拦截用户消息:
- 只采集用户原始文本
- 不采集 assistant 回复
- 不采集 tool 输出
- 不采集 plan 文本
- 不采集 reasoning
对采集内容做两件事:
- 单条文本长度截断
- 按时间倒序写入 `pending.ndjson`
### 2. 待压缩队列层
文件:`.history/pending.ndjson`
职责:
- 保存最新的用户原始提问
- 顶部永远是最新记录
- 文件里保留 compact 边界 marker
- 保持最多 2000 行
普通 prompt 记录当前只保留最小字段:
```json
{"ts":"2026-05-13T14:12:00.123+08:00","text":"用户原始提问内容"}
```
compact 边界 marker 类似:
```json
{"marker":"compacted","compactedAt":"2026-05-13T14:30:00.000+08:00","reason":"auto","processedPromptCount":12}
```
### 3. 历史总结层
文件:`.history/history.txt`
职责:
- 保存长期项目记忆
- 用中文标题输出
- 标题不是写死的,而是根据内容动态生成
例如:
- `# 项目概述`
- `# 业务边界`
- `# 表 Schema`
- `# 架构要点`
如果用户后续 prompt 中出现了新的知识类别,compact 会新增新的中文标题分类。
### 4. 状态层
文件:`.history/state.json`
职责:
- 记录最近压缩时间
- 记录最近压缩统计信息
- 为自动 compact 判断提供依据
## 自动 compact 规则
当前实现是“满足任一条件即压缩”:
- 待压缩用户提问中最老的一条距离现在超过 `3h`
- 待压缩用户提问数量达到 `100` 条
这些值都放在插件脚本顶部,可直接调整:
```js
const AUTO_COMPACT_MAX_AGE_MS = 3 * 60 * 60 * 1000;
const AUTO_COMPACT_MAX_PENDING_PROMPTS = 100;
const PENDING_MAX_TEXT_CHARS = 10000;
const PENDING_MAX_LINES = 2000;
```
## 表 Schema 保留策略
这套方案对表结构类内容做了特殊处理。
如果用户 prompt 中出现:
- DDL
- create table
- 字段清单
- 字段类型
- 主键
- 索引
- 分区字段
- 注释
- schema 说明
compact 会优先:
- 新增类似 `# 表 Schema`、`# 表结构`、`# 数据表定义` 的中文标题
- 尽量保留原始结构
- 保留字段顺序
- 只做轻量压缩
轻量压缩包括:
- 去掉多余空格
- 去掉无意义空行
- 统一空白风格
不会故意把 schema 过度改写成一句抽象总结。
## 截断与保留策略
### 单条用户 prompt 截断
- 单次写入 `pending.ndjson` 的用户提问最大长度:`10000` 字
- 超出后会自动截断,并追加 `...(已截断)`
### `pending.ndjson` 滚动保留
- 默认最多保留 `2000` 行
- 文件顶部永远是最新记录
- 超出后自动删除最老记录
## 手动使用方式
手动触发 skill:
- skill 名:`project-history-compress`
它会指导 agent 调用工具:
```json
{"force": true}
```
工具名:
- `project_history_compact`
## 分享给其他用户后的安装方式
### 方式一:直接复制本地插件目录
把以下文件复制给对方:
- `~/.config/opencode/plugins/project-history.js`
- `~/.config/opencode/skills/project-history-compress/SKILL.md`
建议同时给对方一份:
- `~/.config/opencode/project-history/README.md`
### 方式二:连同最小配置一起安装
如果对方没有对应配置目录,至少需要准备:
1. `~/.config/opencode/plugins/project-history.js`
2. `~/.config/opencode/skills/project-history-compress/SKILL.md`
3. `~/.config/opencode/package.json`
当前最小 `package.json` 可以是:
```json
{
"type": "module",
"dependencies": {
"@opencode-ai/plugin": "1.14.48"
}
}
```
说明:
- 插件本体是 JS
- OpenCode 本地 plugin 官方原生支持 JS/TS
- 当前这套方案不依赖 Python
## 安装步骤
1. 创建目录:`~/.config/opencode/plugins/`
2. 创建目录:`~/.config/opencode/skills/project-history-compress/`
3. 放入 `project-history.js`
4. 放入 `SKILL.md`
5. 如果没有 `~/.config/opencode/package.json`,创建一个并安装 `@opencode-ai/plugin`
6. 退出当前 OpenCode 进程
7. 重新进入 OpenCode
注意:
- 插件是在 OpenCode 进程启动时加载的
- 修改 plugin 脚本后,通常要退出并重新进入 OpenCode,才能让新版本生效
## 使用方式
推荐启动方式:
```bash
opencode /path/to/project
```
或者:
```bash
cd /path/to/project && opencode
```
不要把 `-c` 当成目录参数使用,因为:
- `-c` 在 OpenCode 里是 `--continue`
## 项目目录下生成的文件
当进入有效项目目录后,插件会在项目下自动创建:
- `.history/history.txt`
- `.history/pending.ndjson`
- `.history/state.json`
- `.history/.gitignore`
## 当前限制
- `pending.ndjson` 目前只保留 `ts` 和 `text`,不会保存更重的上下文字段
- 如果用户完全不提供项目事实,只是短测试语句,history 总结会比较保守
- plugin 变更后通常需要重启 OpenCode 会话生效
## 适用场景
- 长期项目迭代
- 多人轮流接手同一项目
- 想把 AI 辅助开发过程中的项目知识持续沉淀下来
- 想让后续模型快速理解项目上下文,而不是依赖旧会话
~/.config/opencode/plugins/project-history.js
hook支持js ts 貌似还不支持python
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { tool } from "@opencode-ai/plugin";
const HISTORY_VERSION = 1;
const HISTORY_DIRNAME = ".history";
const HISTORY_FILE = "history.txt";
const PENDING_FILE = "pending.ndjson";
const STATE_FILE = "state.json";
const GITIGNORE_FILE = ".gitignore";
const LOCK_FILE = "lock";
const INTERNAL_METADATA_KEY = "projectHistoryInternal";
const AUTO_COMPACT_MAX_AGE_MS = 3 * 60 * 60 * 1000;
const AUTO_COMPACT_MAX_PENDING_PROMPTS = 100;
const PENDING_MAX_TEXT_CHARS = 10000;
const PENDING_MAX_LINES = 2000;
const LOCK_STALE_MS = 2 * AUTO_COMPACT_MAX_AGE_MS;
const COMMAND_MESSAGE_TTL_MS = 10 * 60 * 1000;
const INTERNAL_SESSION_TTL_MS = 60 * 60 * 1000;
const PENDING_MARKER_VALUE = "compacted";
const HISTORY_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["sections"],
properties: {
sections: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["title", "content"],
properties: {
title: { type: "string" },
content: { type: "array", items: { type: "string" } },
},
},
},
},
};
const EMPTY_MEMORY = {
sections: [
{
title: "项目概述",
content: ["暂无项目历史摘要。"],
},
],
};
const DEFAULT_STATE = {
version: HISTORY_VERSION,
lastCompactedAt: null,
lastCompaction: null,
};
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function unwrapData(value) {
if (isObject(value) && "data" in value) {
return value.data;
}
return value;
}
function nowIso() {
const date = new Date();
const pad = (value, size = 2) => String(value).padStart(size, "0");
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absMinutes = Math.abs(offsetMinutes);
const offsetHours = Math.floor(absMinutes / 60);
const offsetMins = absMinutes % 60;
return [
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`,
`${sign}${pad(offsetHours)}:${pad(offsetMins)}`,
].join("");
}
function truncateText(text, maxChars) {
const normalized = String(text ?? "");
const chars = Array.from(normalized);
if (chars.length <= maxChars) {
return normalized;
}
const suffix = "...(已截断)";
const keep = Math.max(0, maxChars - Array.from(suffix).length);
return `${chars.slice(0, keep).join("")}${suffix}`;
}
function toText(value) {
if (Array.isArray(value)) {
return value.map((item) => toText(item)).filter(Boolean).join(" ");
}
if (isObject(value)) {
if (typeof value.term === "string" && typeof value.definition === "string") {
return `${value.term}: ${value.definition}`;
}
if (typeof value.text === "string") {
return value.text;
}
if (typeof value.title === "string" && typeof value.body === "string") {
return `${value.title}: ${value.body}`;
}
return JSON.stringify(value);
}
return String(value ?? "");
}
function normalizeLine(value) {
const text = toText(value);
return text
.replace(/\r\n/g, "\n")
.replace(/\u0000/g, "")
.trim();
}
function normalizeParagraph(value, fallback) {
const text = normalizeLine(value).replace(/\n{3,}/g, "\n\n");
return text || fallback;
}
function looksLikeIsoTime(value) {
return typeof value === "string" && Number.isFinite(Date.parse(value));
}
function normalizeStringList(value) {
const items = Array.isArray(value) ? value : [];
const seen = new Set();
const result = [];
for (const item of items) {
const text = normalizeLine(item);
if (!text || seen.has(text)) {
continue;
}
seen.add(text);
result.push(text);
}
return result;
}
function normalizeTitle(value, fallback = "补充信息") {
const title = normalizeLine(value).replace(/^#+\s*/, "");
return title || fallback;
}
function normalizeSection(section) {
if (!isObject(section)) {
return null;
}
const title = normalizeTitle(section.title);
const content = normalizeStringList(section.content);
return {
title,
content: content.length ? content : ["暂无记录。"],
};
}
function buildLegacySections(input) {
const sections = [];
const summary = normalizeParagraph(input.project_summary, "暂无项目历史摘要。");
sections.push({
title: "项目概述",
content: [summary],
});
const mapping = [
["业务背景", input.business_context],
["架构要点", input.architecture_notes],
["关键决策", input.key_decisions],
["迭代时间线", input.iteration_timeline],
["当前关注点", input.current_focus],
["待确认问题", input.open_questions],
["术语表", input.glossary],
];
for (const [title, value] of mapping) {
const content = normalizeStringList(value);
if (content.length) {
sections.push({ title, content });
}
}
return sections;
}
function normalizeMemory(value) {
const input = isObject(value) ? value : {};
const rawSections = Array.isArray(input.sections)
? input.sections.map((section) => normalizeSection(section)).filter(Boolean)
: [];
if (rawSections.length) {
return {
sections: rawSections,
};
}
const legacySections = buildLegacySections(input);
return {
sections: legacySections.length ? legacySections : EMPTY_MEMORY.sections,
};
}
function parseJson(text, fallback) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
function projectRootFromContext(directory, worktree) {
const primary = typeof directory === "string" ? path.resolve(directory) : null;
if (isSafeProjectRoot(primary)) {
return primary;
}
const fallback = typeof worktree === "string" ? path.resolve(worktree) : null;
if (primary == null && isSafeProjectRoot(fallback)) {
return fallback;
}
return null;
}
function isSafeProjectRoot(projectRoot) {
if (!projectRoot || typeof projectRoot !== "string") return false;
if (projectRoot === "/" || projectRoot === path.parse(projectRoot).root) {
return false;
}
if (projectRoot === os.homedir()) return false;
return true;
}
function historyPaths(projectRoot) {
const historyDir = path.join(projectRoot, HISTORY_DIRNAME);
return {
projectRoot,
historyDir,
historyFile: path.join(historyDir, HISTORY_FILE),
pendingFile: path.join(historyDir, PENDING_FILE),
stateFile: path.join(historyDir, STATE_FILE),
gitignoreFile: path.join(historyDir, GITIGNORE_FILE),
lockFile: path.join(historyDir, LOCK_FILE),
};
}
function renderSection(title, content) {
if (!content.length) {
return [`# ${title}`, "", "暂无记录。", ""];
}
if (content.length === 1) {
return [`# ${title}`, "", content[0], ""];
}
return [`# ${title}`, "", ...content.map((item) => `- ${item}`), ""];
}
function renderHistory(memory, meta = {}) {
const normalized = normalizeMemory(memory);
const updatedAt = meta.updatedAt || nowIso();
const lines = [
"项目历史(自动维护)",
"",
"该文件由 OpenCode 项目历史插件自动维护。",
`最后更新时间:${updatedAt}`,
"",
];
for (const section of normalized.sections) {
lines.push(...renderSection(section.title, section.content));
}
return lines.join("\n").trimEnd() + "\n";
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function writeFileAtomic(filePath, contents) {
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
await fs.writeFile(tempPath, contents, "utf8");
await fs.rename(tempPath, filePath);
}
async function ensureHistoryFiles(paths) {
await fs.mkdir(paths.historyDir, { recursive: true });
if (!(await pathExists(paths.historyFile))) {
await writeFileAtomic(
paths.historyFile,
renderHistory(EMPTY_MEMORY, { updatedAt: nowIso() }),
);
}
if (!(await pathExists(paths.pendingFile))) {
await writeFileAtomic(paths.pendingFile, "");
}
if (!(await pathExists(paths.stateFile))) {
await writeFileAtomic(
paths.stateFile,
`${JSON.stringify(DEFAULT_STATE, null, 2)}\n`,
);
}
if (!(await pathExists(paths.gitignoreFile))) {
await writeFileAtomic(
paths.gitignoreFile,
[PENDING_FILE, STATE_FILE, LOCK_FILE].join("\n") + "\n",
);
}
}
async function readText(filePath, fallback = "") {
try {
return await fs.readFile(filePath, "utf8");
} catch {
return fallback;
}
}
async function readState(paths) {
const raw = await readText(paths.stateFile, "");
const parsed = parseJson(raw, DEFAULT_STATE);
if (!isObject(parsed)) {
return { ...DEFAULT_STATE };
}
return {
...DEFAULT_STATE,
...parsed,
};
}
async function writeState(paths, state) {
const next = {
...DEFAULT_STATE,
...state,
version: HISTORY_VERSION,
};
await writeFileAtomic(paths.stateFile, `${JSON.stringify(next, null, 2)}\n`);
}
async function refreshHistoryTimestamp(paths) {
const current = await readText(paths.historyFile, "");
const updatedAt = nowIso();
const replaced = current.replace(
/^最后更新时间:.*$/m,
`最后更新时间:${updatedAt}`,
);
const next = replaced === current ? renderHistory(EMPTY_MEMORY, { updatedAt }) : replaced;
await writeFileAtomic(paths.historyFile, next);
return updatedAt;
}
function isPendingPromptEntry(entry) {
return isObject(entry) && typeof entry.text === "string" && entry.marker == null;
}
function isPendingMarkerEntry(entry) {
return (
isObject(entry) &&
(entry.marker === PENDING_MARKER_VALUE || entry.type === "marker")
);
}
function normalizePendingPromptEntry(entry) {
return {
ts: looksLikeIsoTime(entry.ts) ? entry.ts : nowIso(),
text: normalizeParagraph(entry.text, ""),
};
}
function normalizePendingMarkerEntry(entry) {
return {
marker: PENDING_MARKER_VALUE,
compactedAt: looksLikeIsoTime(entry.compactedAt) ? entry.compactedAt : nowIso(),
reason: typeof entry.reason === "string" ? entry.reason : "auto",
processedPromptCount:
typeof entry.processedPromptCount === "number" ? entry.processedPromptCount : 0,
};
}
function normalizePendingEntry(entry) {
if (isPendingPromptEntry(entry)) {
return normalizePendingPromptEntry(entry);
}
if (isPendingMarkerEntry(entry)) {
return normalizePendingMarkerEntry(entry);
}
return null;
}
async function readPendingEntries(paths) {
const raw = await readText(paths.pendingFile, "");
if (!raw.trim()) {
return [];
}
const entries = [];
for (const line of raw.split(/\n+/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parsed = parseJson(trimmed, null);
const normalized = normalizePendingEntry(parsed);
if (normalized) {
entries.push(normalized);
}
}
return entries;
}
async function writePendingEntries(paths, entries) {
const trimmedEntries = entries.slice(0, PENDING_MAX_LINES);
const contents = trimmedEntries.length
? `${trimmedEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`
: "";
await writeFileAtomic(paths.pendingFile, contents);
}
async function prependPendingEntry(paths, entry) {
const existing = await readPendingEntries(paths);
await writePendingEntries(paths, [entry, ...existing]);
}
function collectActivePromptEntries(entries) {
const prompts = [];
for (const entry of entries) {
if (isPendingMarkerEntry(entry)) {
break;
}
if (isPendingPromptEntry(entry)) {
prompts.push(entry);
}
}
return prompts;
}
function extractUserText(parts) {
const texts = [];
for (const part of parts) {
if (!part || part.type !== "text") {
continue;
}
if (part.synthetic || part.ignored) {
continue;
}
const text = normalizeLine(part.text);
if (text) {
texts.push(text);
}
}
return texts.join("\n\n");
}
function hasInternalMarker(parts) {
return parts.some(
(part) =>
part &&
part.type === "text" &&
isObject(part.metadata) &&
part.metadata[INTERNAL_METADATA_KEY] === true,
);
}
function buildPendingEntry(input, text) {
return {
ts: nowIso(),
text: truncateText(text, PENDING_MAX_TEXT_CHARS),
};
}
function buildCompactionMarker(activePrompts, reason) {
return {
marker: PENDING_MARKER_VALUE,
compactedAt: nowIso(),
reason,
processedPromptCount: activePrompts.length,
newestPromptAt: activePrompts[0]?.ts ?? null,
oldestPromptAt: activePrompts.at(-1)?.ts ?? null,
};
}
function shouldCaptureText(text) {
if (!text) {
return false;
}
const normalized = text.trim();
const maintenancePatterns = [
/\bproject_history_compact\b/i,
/\bproject-history-compress\b/i,
/use the project_history_compact tool/i,
/reply with the tool result only/i,
/压缩.*\.history/i,
/手动触发.*compact/i,
];
return !maintenancePatterns.some((pattern) => pattern.test(normalized));
}
function buildCompactionPrompt(existingHistory, records) {
const formattedRecords = records
.map(
(record, index) =>
`### Prompt ${index + 1}\n- Timestamp: ${record.ts}\n- Text:\n${record.text
.split("\n")
.map((line) => ` ${line}`)
.join("\n")}`,
)
.join("\n\n");
return [
"你负责维护一个 OpenCode 项目的长期历史记忆文件。",
"请把现有总结和新的用户原始提问整合成新的长期记忆。",
"",
"只保留持久有效的项目信息:",
"- 项目背景、目标、约束、相关方",
"- 架构、数据流、集成关系、运行假设",
"- 关键决策、取舍、被接受和被否定的方案",
"- 有意义的迭代历程和当前关注点",
"- 风险、待确认问题、重要术语",
"- 用户直接提供的表 schema、字段定义、DDL、列顺序和约束",
"",
"不要保留这些噪音:",
"- 寒暄、闲聊、元话题",
"- 工具调用、计划文本、权限交互、执行过程描述",
"- 仅用于触发压缩/history 维护的提示",
"- 没有新增事实的重复表述",
"",
"请优先输出中文内容,使用简洁、事实性的表达。",
"history.txt 中的分类标题必须根据内容动态生成,不能死板固定。",
"例如当提问中出现业务约束、边界或规则时,应新增类似“业务边界”的标题。",
"如果提问中包含表 schema、DDL、字段清单、字段类型、分区字段、索引、主键、注释等结构化信息,应优先新增类似“表 Schema”“表结构”“数据表定义”的中文标题。",
"对于表 schema 内容,尽量保留原始结构、字段顺序、缩进和换行,只允许做轻量压缩,例如去掉多余空格、统一空白、删除无意义空行。",
"不要把表 schema 改写成过度抽象的摘要,除非原始内容明显重复或噪音极高。",
"每个标题都必须是中文标题,不要带 # 前缀,渲染器会自动加一级标题。",
"只返回一个 JSON 对象,不要输出 Markdown 代码块或额外说明。",
"JSON 必须严格包含以下键:",
"- sections: { title: string, content: string[] }[]",
"sections 至少包含 1 个 section。",
"建议把最核心、最概括的内容放在“项目概述”或类似标题里,但标题仍可按实际情况调整。",
"",
"## 当前历史总结",
existingHistory.trim() || "No persisted project history yet.",
"",
"## 需要整合的新增用户提问",
formattedRecords,
].join("\n");
}
function extractCompactionText(response) {
const payload = unwrapData(response);
const parts = Array.isArray(payload?.parts) ? payload.parts : [];
const text = parts
.filter((part) => part?.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
return text;
}
async function log(client, level, message, extra = {}) {
try {
await client.app.log({
body: {
service: "project-history",
level,
message,
extra,
},
});
} catch {
// Logging should never block the plugin.
}
}
async function acquireLock(paths) {
const attempt = async () => fs.open(paths.lockFile, "wx");
try {
return await attempt();
} catch (error) {
if (error?.code !== "EEXIST") {
throw error;
}
}
try {
const stats = await fs.stat(paths.lockFile);
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
await fs.unlink(paths.lockFile);
return await attempt();
}
} catch {
// Another process may have cleared the lock. Retry once below.
}
try {
return await attempt();
} catch (error) {
if (error?.code === "EEXIST") {
return null;
}
throw error;
}
}
async function releaseLock(handle, paths) {
if (!handle) {
return;
}
try {
await handle.close();
} catch {
// ignore close errors
}
try {
await fs.unlink(paths.lockFile);
} catch {
// ignore unlink errors
}
}
function cleanupExpiringEntries(map, ttlMs) {
const cutoff = Date.now() - ttlMs;
for (const [key, value] of map.entries()) {
if (value < cutoff) {
map.delete(key);
}
}
}
function shouldAutoCompact(state, records) {
if (!records.length) {
return false;
}
if (records.length >= AUTO_COMPACT_MAX_PENDING_PROMPTS) {
return true;
}
const oldestPrompt = records.at(-1);
const referenceMs = Date.parse(oldestPrompt?.ts || "");
return Number.isFinite(referenceMs)
? Date.now() - referenceMs >= AUTO_COMPACT_MAX_AGE_MS
: false;
}
async function runCompaction({
client,
rootPaths,
internalSessionIDs,
force = false,
reason = "manual",
}) {
await ensureHistoryFiles(rootPaths);
const lockHandle = await acquireLock(rootPaths);
if (!lockHandle) {
return {
status: "busy",
message: "Project history compaction is already running.",
};
}
let tempSessionID = null;
try {
const state = await readState(rootPaths);
const entries = await readPendingEntries(rootPaths);
const activePrompts = collectActivePromptEntries(entries);
if (!activePrompts.length) {
if (force) {
const refreshedAt = await refreshHistoryTimestamp(rootPaths);
await writeState(rootPaths, {
...state,
lastCompactedAt: refreshedAt,
lastCompaction: {
reason,
processedMessages: 0,
remainingMessages: 0,
processedBytes: 0,
},
});
return {
status: "ok",
message: `No pending user prompts. Refreshed ${path.relative(rootPaths.projectRoot, rootPaths.historyFile)} timestamp.`,
batchCount: 0,
remainingCount: 0,
bytes: 0,
};
}
return {
status: "skipped",
message: "No pending user prompts to compact.",
};
}
if (!force && !shouldAutoCompact(state, activePrompts)) {
return {
status: "skipped",
message: "待压缩内容尚未达到自动压缩阈值。",
};
}
const batch = activePrompts;
const bytes = batch.reduce(
(total, record) => total + Buffer.byteLength(JSON.stringify(record)),
0,
);
const existingHistory = await readText(rootPaths.historyFile, "");
const created = unwrapData(
await client.session.create(
{
body: {
title: `[project-history] ${path.basename(rootPaths.projectRoot)} ${reason}`,
},
query: {
directory: rootPaths.projectRoot,
},
responseStyle: "data",
throwOnError: true,
},
),
);
tempSessionID = created.id;
internalSessionIDs.set(tempSessionID, Date.now());
const promptResult = await client.session.prompt(
{
path: {
id: tempSessionID,
},
query: {
directory: rootPaths.projectRoot,
},
body: {
parts: [
{
type: "text",
text: buildCompactionPrompt(existingHistory, batch),
metadata: {
[INTERNAL_METADATA_KEY]: true,
},
},
],
system: [
"You produce compact project memory from user-authored prompts.",
"Return only valid JSON matching the requested shape.",
"Do not call tools.",
].join("\n"),
tools: {},
},
responseStyle: "data",
throwOnError: true,
},
);
const promptText = extractCompactionText(promptResult);
if (!promptText) {
throw new Error("Compaction response did not contain text output");
}
const parsedMemory = parseJson(promptText, null);
if (!parsedMemory) {
throw new Error("Compaction response text was not valid JSON");
}
const normalizedMemory = normalizeMemory(parsedMemory);
await writeFileAtomic(
rootPaths.historyFile,
renderHistory(normalizedMemory, { updatedAt: nowIso() }),
);
await writePendingEntries(rootPaths, [buildCompactionMarker(batch, reason), ...entries]);
await writeState(rootPaths, {
...state,
lastCompactedAt: nowIso(),
lastCompaction: {
reason,
processedMessages: batch.length,
remainingMessages: 0,
processedBytes: bytes,
},
});
return {
status: "ok",
message: `Compacted ${batch.length} pending prompt(s) into ${path.relative(rootPaths.projectRoot, rootPaths.historyFile)}.`,
batchCount: batch.length,
remainingCount: 0,
bytes,
};
} finally {
if (tempSessionID) {
try {
await client.session.delete(
{
path: {
id: tempSessionID,
},
query: {
directory: rootPaths.projectRoot,
},
responseStyle: "data",
},
);
} catch {
// Best effort cleanup.
}
internalSessionIDs.delete(tempSessionID);
}
await releaseLock(lockHandle, rootPaths);
}
}
export const ProjectHistoryPlugin = async ({ client, directory, worktree }) => {
const projectRoot = projectRootFromContext(directory, worktree);
const rootPaths = projectRoot ? historyPaths(projectRoot) : null;
const commandGeneratedMessageIDs = new Map();
const internalSessionIDs = new Map();
const compactionRuns = new Map();
if (rootPaths) {
void ensureHistoryFiles(rootPaths).catch(async (error) => {
await log(client, "error", "Project history initialization failed", {
projectRoot,
error: error instanceof Error ? error.message : String(error),
});
});
}
const maybeRunCompaction = async (reason, force = false) => {
if (!rootPaths) return;
const active = compactionRuns.get(projectRoot);
if (active) {
return active;
}
const run = runCompaction({
client,
rootPaths,
internalSessionIDs,
force,
reason,
})
.catch(async (error) => {
await log(client, "error", "Project history compaction failed", {
projectRoot,
reason,
error: error instanceof Error ? error.message : String(error),
});
throw error;
})
.finally(() => {
compactionRuns.delete(projectRoot);
});
compactionRuns.set(projectRoot, run);
return run;
};
return {
event: async ({ event }) => {
cleanupExpiringEntries(commandGeneratedMessageIDs, COMMAND_MESSAGE_TTL_MS);
cleanupExpiringEntries(internalSessionIDs, INTERNAL_SESSION_TTL_MS);
if (event.type === "command.executed") {
commandGeneratedMessageIDs.set(event.properties.messageID, Date.now());
}
},
"chat.message": async (input, output) => {
cleanupExpiringEntries(commandGeneratedMessageIDs, COMMAND_MESSAGE_TTL_MS);
cleanupExpiringEntries(internalSessionIDs, INTERNAL_SESSION_TTL_MS);
if (!rootPaths) return;
if (internalSessionIDs.has(input.sessionID)) {
return;
}
if (input.messageID && commandGeneratedMessageIDs.has(input.messageID)) {
return;
}
if (hasInternalMarker(output.parts)) {
return;
}
const text = extractUserText(output.parts);
if (!shouldCaptureText(text)) {
return;
}
await ensureHistoryFiles(rootPaths);
await prependPendingEntry(rootPaths, buildPendingEntry(input, text));
void maybeRunCompaction("auto", false).catch(() => {
// Logged by maybeRunCompaction.
});
},
tool: {
project_history_compact: tool({
description:
"Compact persisted project history from pending user prompts into .history/history.txt for the current project.",
args: {
force: tool.schema
.boolean()
.default(true)
.describe(
"When true, compact all current pending prompts immediately, even if the auto-compaction interval has not elapsed.",
),
},
async execute(args, context) {
const toolProjectRoot = projectRootFromContext(
context.directory,
context.worktree,
);
if (!toolProjectRoot) {
return {
output:
"Project history is disabled: no valid project directory (opencode was launched from '/' or $HOME).",
metadata: { skipped: true },
};
}
const result = await runCompaction({
client,
rootPaths: historyPaths(toolProjectRoot),
internalSessionIDs,
force: args.force,
reason: "manual",
});
return {
output: result.message,
metadata: result,
};
},
}),
},
};
};
~/.config/opencode/skills/project-history-compress/SKILL.md
需根据使用 不断迭代skill内容
[---
name: project-history-compress
description: 压缩当前项目 .history 目录中的待整理用户提问,并更新长期项目历史总结文件
---
## 背景
这个 skill 用来维护项目级的长期记忆,避免 OpenCode 多轮会话里的项目背景、需求演化、关键决策只留在会话上下文中,后续换人或换模型后无法快速理解项目历史。
## 目的
- 把当前项目里尚未整理的用户原始提问压缩成长期可读的项目总结。
- 让 `.history/history.txt` 成为后续人类和模型都能快速阅读的项目记忆入口。
- 减少上下文体积,同时保留项目背景、功能迭代、关键决策和问题演进轨迹。
## 处理范围
只处理当前项目目录下的 `.history/` 文件夹。
相关文件路径如下:
- `.history/pending.ndjson`
- `.history/history.txt`
- `.history/state.json`
- `.history/.gitignore`
## 文件语义
### `.history/pending.ndjson`
- 存储尚未进入长期总结边界之前的用户原始提问记录。
- 文件顶部永远是最新记录,按时间倒序排列。
- 普通提问记录只保留最小字段:提问时间 `ts` 和正文 `text`。
- 其中也会包含压缩完成边界标记,用来识别“哪些记录已经被整理过”。
- 只保存用户输入,不应把 assistant 回复、tool 输出、plan 文本手工写入该文件。
### `.history/history.txt`
- 存储项目长期总结,是压缩后的可读历史文件。
- 标题必须为中文标题,并且要根据实际内容动态生成分类。
- 应优先保留项目背景、架构要点、关键决策、迭代时间线、当前关注点和待确认问题。
- 如果用户提供了表 schema、DDL、字段定义、字段顺序、分区字段、索引或主键信息,应优先新增类似“表 Schema”或“表结构”的中文标题分类。
- 对这类表 schema 内容,应尽量保留原始结构,只做轻量压缩,例如去掉多余空格、无意义空行和杂讯,不要把原始结构改写成过度抽象的总结。
### `.history/state.json`
- 存储最近一次压缩时间、最近一次压缩的原因和统计信息。
- 用于自动压缩判断和排障。
### `.history/.gitignore`
- 控制本地状态文件是否进入版本管理。
## 输入
输入来自插件自动采集到的用户原始提问,已经写入 `.history/pending.ndjson`。
## 输出
运行后应产生这些结果:
- 更新 `.history/history.txt`
- 更新 `.history/state.json`
- 在 `.history/pending.ndjson` 顶部插入新的已压缩边界标记
## 何时使用
在这些场景使用:
- 用户明确要求“压缩 history”
- 用户要求刷新当前项目的长期历史总结
- 你需要确认 `.history/history.txt` 是否已经吸收了最新的用户项目信息
## 正确动作
直接调用 `project_history_compact` 工具,参数使用:
```json
{"force": true}
```
## 约束
- 不要自己在聊天里手工代替工具做总结,优先调用工具。
- 不要把 tool 输出、plan 文本、assistant 回复复制到 `.history/pending.ndjson`。
- 如果工具返回“没有待压缩内容”,直接把结果告知用户。
- 如果工具执行成功,向用户报告压缩了多少条记录、是否更新时间戳、以及更新的是哪个项目目录。
]()
更多推荐

所有评论(0)