核心思想:

        每次会话完毕,存储用户输入,定期总结到 项目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`。
- 如果工具返回“没有待压缩内容”,直接把结果告知用户。
- 如果工具执行成功,向用户报告压缩了多少条记录、是否更新时间戳、以及更新的是哪个项目目录。
]()

更多推荐