Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/designs/git-native-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@
teamai recall "api timeout"
│
▼
Ranked results → AI reads → auto-upvote
Ranked results → recalled_count++
│
(adoption evidence: agent opens the doc, or
opt-in LLM-judge) → upvoted_count++
```

## Hindsight Mapping
Expand All @@ -56,7 +59,7 @@
| **Recall** (retrieve) | `teamai recall <query>` → local search index (BM25 + Intl.Segmenter) | 🔨 This plan |
| **Reflect** (learn) | `teamai reflect` → LLM analyzes learnings → meta-insights | ⏳ Deferred |
| Memory Bank isolation | Per-user directories in git repo | ✅ Existing |
| Ranking/relevance | Auto-upvote on recall → votes/<user>.yaml → aggregate at pull | 🔨 This plan |
| Ranking/relevance | Recall bumps `recalled_count`; adoption evidence (agent opened the doc, or the opt-in LLM-judge) bumps `upvoted_count` → votes/<user>.yaml → aggregate at pull (#723) | 🔨 This plan |

## Key Design Decisions

Expand All @@ -65,7 +68,7 @@
| 1 | Storage backend | Git repo (no PostgreSQL/vector DB) | Zero external dependencies, matches teamai philosophy |
| 2 | Search method | Keyword matching + Intl.Segmenter | Handles CJK text, no embedding cost, sufficient for V1 |
| 3 | Index location | Local-only search-index.json | Rebuilt at pull time, no sync conflicts |
| 4 | Vote mechanism | Per-user YAML, search-triggered | Idempotent, zero Git conflict, knowledge self-curates |
| 4 | Vote mechanism | Per-user YAML; recall bumps `recalled_count`, adoption evidence (tool-use / opt-in LLM-judge) bumps `upvoted_count` (#723) | Idempotent, zero Git conflict, knowledge self-curates |
| 5 | Frontmatter | Required in SKILL.md template | Structured metadata improves search precision |
| 6 | AI integration | Rule injection → AI calls recall via Bash | Works across all AI tools, no tool-specific integration |

Expand Down Expand Up @@ -93,7 +96,7 @@
| # | Proposal | Effort | Decision | Reasoning |
|---|----------|--------|----------|-----------|
| 1 | Auto-Recall on SessionStart | M | SUPERSEDED | 已被 `teamai-recall` subagent + builtin-rules 主动检索替代 |
| 2 | Recall 自动投票 (Upvote) | S | **ACCEPTED** | 零冲突,飞轮反馈机制的关键一环 |
| 2 | 投票机制:recalled_count + 采纳 upvote | S | **ACCEPTED** | Recall 累加 `recalled_count`;采纳证据(工具使用 / 可选 LLM-judge)累加 `upvoted_count`(#723)。零冲突,飞轮反馈机制的关键一环 |
| 3 | Frontmatter 标准化 | S | **ACCEPTED** | 提升搜索质量,向后兼容 |
| 4 | Reflect 层 | L | DEFERRED | 知识库冷启动阶段,数据不足 |

Expand Down
3 changes: 2 additions & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,7 @@ The Recall feature is controlled by a two-tier configuration — admins set the
| Team default | `teamai.yaml` | `sharing.recall.enabled` | `true` / `false` (default `false`) |
| User override | `~/.teamai/config.yaml` | `recallEnabled` | `true` / `false`, takes priority over the team default |
| Environment variable | shell | `TEAMAI_RECALL_DISABLED=1` | Force-disables all recall hooks (emergency kill switch) |
| Environment variable | shell | `TEAMAI_UPVOTE_JUDGE=1` | Opt-in: on a git-team session a background pass asks your local signed-in CLI whether the latest reply substantively used each recalled doc that left no other adoption trace, and upvotes that subset. Each doc is judged at most once per session (a doc recalled or used only on a later turn is still judged then); an inherited user-scope doc is not upvoted while a project is active. Off by default; runs detached (no added latency) and uses your CLI subscription |

```bash
teamai recall enable # Enable recall, deploy the subagent and rules
Expand Down Expand Up @@ -1591,7 +1592,7 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th
- **Skills** land in `.opencode/skills/` (project) or `~/.config/opencode/skills/` (user). OpenCode also reads `.claude/skills` natively, but teamai writes the OpenCode path too so an OpenCode-only user still gets them.
- **Subagents** are rendered into OpenCode's own `agents/*.md` format: frontmatter carries `description` + `mode: subagent` (plus `model` and any `tool_extras.opencode` fields such as `temperature`); the agent name comes from the filename. OpenCode does **not** read `.claude/agents`, so this native copy is required.
- **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory — the files are inert until referenced. teamai therefore adds a `rules/*.md` glob to the `instructions` array in `opencode.json` and removes it again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched.
- **Hooks** are delivered as an OpenCode *plugin*, not a settings-file entry — OpenCode has no `hooks` array; it auto-loads JS/TS plugins from **both** `~/.config/opencode/plugin/` and `<project>/.opencode/plugin/`. A plugin present in both dirs is loaded twice and would dispatch every event twice, so teamai keeps exactly one copy: `teamai-hooks.ts` in the user dir, which covers every project. Any project-scope copy left by an earlier layout is deleted on the next sync. This matches the other tools, whose `settings.json` hooks also live in HOME and gate on the `cwd` handed to `hook-dispatch`. The plugin subscribes to OpenCode's own events and shelling out to the same `teamai hook-dispatch` entry point every other tool uses. The event mapping mirrors the Claude built-in set: `session.created` → session-start, `session.idle` → stop, `chat.message` → prompt-submit, `tool.execute.after` → post-tool-use. The plugin forwards the same STDIN payload other agents send (`cwd`, `tool_name`, `tool_input`, `prompt`), and maps OpenCode's lowercase tool ids (`skill`, `todowrite`) back to the PascalCase matchers the handler registry expects. OpenCode cannot inject a hook's stdout back into the session, so hooks run purely for their side effects (status report / sync / update). Note that OpenCode *awaits* its named hooks (`chat.message`, `tool.execute.after`), so those dispatches briefly wait on the `teamai` subprocess before the agent continues; the errors are always swallowed so a hook can never fail the session. Server-pushed agent hooks (`teamai-agent-<slug>.ts`) install into the same user plugin dir.
- **Hooks** are delivered as an OpenCode *plugin*, not a settings-file entry — OpenCode has no `hooks` array; it auto-loads JS/TS plugins from **both** `~/.config/opencode/plugin/` and `<project>/.opencode/plugin/`. A plugin present in both dirs is loaded twice and would dispatch every event twice, so teamai keeps exactly one copy: `teamai-hooks.ts` in the user dir, which covers every project. Any project-scope copy left by an earlier layout is deleted on the next sync. This matches the other tools, whose `settings.json` hooks also live in HOME and gate on the `cwd` handed to `hook-dispatch`. The plugin subscribes to OpenCode's own events and shelling out to the same `teamai hook-dispatch` entry point every other tool uses. The event mapping mirrors the Claude built-in set: `session.created` → session-start, `session.idle` → stop, `chat.message` → prompt-submit, `tool.execute.after` → post-tool-use. The plugin forwards the same STDIN payload other agents send (`cwd`, `tool_name`, `tool_input`, `prompt`), and maps OpenCode's lowercase tool ids (`skill`, `todowrite`) back to the PascalCase matchers the handler registry expects. OpenCode cannot inject a hook's stdout back into the session, so hooks run purely for their side effects (status report / sync / update). Note that OpenCode *awaits* its named hooks (`chat.message`, `tool.execute.after`), so those dispatches briefly wait on the `teamai` subprocess before the agent continues; the errors are always swallowed so a hook can never fail the session. Server-pushed agent hooks (`teamai-agent-<slug>.ts`) install into the same user plugin dir. Because OpenCode's `session.idle` event carries no Claude-style JSONL `transcript_path`, upvote **adoption** (tool-use evidence, the opt-in LLM-judge, and the "adopted team knowledge" summary) does not run for OpenCode — recall still bumps `recalled_count`, but `upvoted_count` collection is a Claude-family (transcript-bearing) feature.
- **MCP** servers live under the `mcp` key of the shared `opencode.json` (see the MCP section above).

### Pi Coding Agent
Expand Down
3 changes: 2 additions & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,7 @@ Recall 功能通过两级配置控制——管理员设置团队默认值,成
| 团队默认 | `teamai.yaml` | `sharing.recall.enabled` | `true` / `false`(默认 `false`) |
| 用户覆盖 | `~/.teamai/config.yaml` | `recallEnabled` | `true` / `false`,优先级高于团队默认 |
| 环境变量 | shell | `TEAMAI_RECALL_DISABLED=1` | 强制禁用所有 recall hooks(应急开关) |
| 环境变量 | shell | `TEAMAI_UPVOTE_JUDGE=1` | 可选开关:git 团队会话中,后台向本地已登录的 CLI 询问最新回复是否实质性用到了每条「没有其他采纳痕迹」的召回文档,并为该子集补记 upvote。每篇文档每会话最多评判一次(仅在后续轮次才召回/使用的文档届时仍会被评判);项目激活时不会为继承的 user 作用域文档记 upvote。默认关闭;分离进程运行(不增加延迟),使用你自己的 CLI 订阅 |

```bash
teamai recall enable # 开启 recall,部署 subagent 和 rules
Expand Down Expand Up @@ -1536,7 +1537,7 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定
- **Skills** 落在 `.opencode/skills/`(项目)或 `~/.config/opencode/skills/`(用户)。OpenCode 也原生读取 `.claude/skills`,但 teamai 仍会写 OpenCode 路径,好让只用 OpenCode 的用户也能拿到。
- **Subagents** 会被渲染成 OpenCode 自己的 `agents/*.md` 格式:frontmatter 带 `description` + `mode: subagent`(以及 `model` 和 `tool_extras.opencode` 中的字段,如 `temperature`);agent 名取自文件名。OpenCode **不**读取 `.claude/agents`,因此这份原生副本是必需的。
- **Rules** 会被复制到 `.opencode/rules/`(或 `~/.config/opencode/rules/`),但 OpenCode 不会自动扫描 rules 目录——文件在被引用前是惰性的。因此 teamai 会往 `opencode.json` 的 `instructions` 数组里加一条 `rules/*.md` glob,并在团队最后一条 rule 消失时再把它移除,且只编辑这一个键、不动你自己的 `instructions` 条目。
- **Hooks** 以 OpenCode *plugin* 形式交付,而非配置文件条目——OpenCode 没有 `hooks` 数组,它会**同时**加载 `~/.config/opencode/plugin/` 和 `<project>/.opencode/plugin/` 下的 JS/TS 插件。两个目录都有插件时会被加载两次,每个事件也就派发两次,因此 teamai 只保留一份:写在用户目录的 `teamai-hooks.ts`,覆盖所有项目;早期布局残留的项目级副本会在下次同步时被删除。这与其他工具一致——它们的 `settings.json` hooks 同样放在 HOME,靠传给 `hook-dispatch` 的 `cwd` 做作用域判断。插件订阅 OpenCode 自己的事件,并 shell 到其他所有工具共用的 `teamai hook-dispatch` 入口。事件映射对齐 Claude 内置集合:`session.created` → session-start、`session.idle` → stop、`chat.message` → prompt-submit、`tool.execute.after` → post-tool-use。插件会转发与其他工具一致的 STDIN 负载(`cwd`、`tool_name`、`tool_input`、`prompt`),并把 OpenCode 的小写工具 id(`skill`、`todowrite`)映射回 handler 注册表期望的 PascalCase matcher。OpenCode 无法把 hook 的 stdout 回注到会话,因此 hooks 只为副作用运行(状态上报 / 同步 / 更新)。注意 OpenCode 会 **await** 它的具名 hook(`chat.message`、`tool.execute.after`),所以这两个事件的派发会短暂等待 `teamai` 子进程后 agent 才继续;错误始终被吞掉,hook 永远不会让会话失败。服务端下发的 agent hook(`teamai-agent-<slug>.ts`)同样装在这个用户级 plugin 目录下。
- **Hooks** 以 OpenCode *plugin* 形式交付,而非配置文件条目——OpenCode 没有 `hooks` 数组,它会**同时**加载 `~/.config/opencode/plugin/` 和 `<project>/.opencode/plugin/` 下的 JS/TS 插件。两个目录都有插件时会被加载两次,每个事件也就派发两次,因此 teamai 只保留一份:写在用户目录的 `teamai-hooks.ts`,覆盖所有项目;早期布局残留的项目级副本会在下次同步时被删除。这与其他工具一致——它们的 `settings.json` hooks 同样放在 HOME,靠传给 `hook-dispatch` 的 `cwd` 做作用域判断。插件订阅 OpenCode 自己的事件,并 shell 到其他所有工具共用的 `teamai hook-dispatch` 入口。事件映射对齐 Claude 内置集合:`session.created` → session-start、`session.idle` → stop、`chat.message` → prompt-submit、`tool.execute.after` → post-tool-use。插件会转发与其他工具一致的 STDIN 负载(`cwd`、`tool_name`、`tool_input`、`prompt`),并把 OpenCode 的小写工具 id(`skill`、`todowrite`)映射回 handler 注册表期望的 PascalCase matcher。OpenCode 无法把 hook 的 stdout 回注到会话,因此 hooks 只为副作用运行(状态上报 / 同步 / 更新)。注意 OpenCode 会 **await** 它的具名 hook(`chat.message`、`tool.execute.after`),所以这两个事件的派发会短暂等待 `teamai` 子进程后 agent 才继续;错误始终被吞掉,hook 永远不会让会话失败。服务端下发的 agent hook(`teamai-agent-<slug>.ts`)同样装在这个用户级 plugin 目录下。由于 OpenCode 的 `session.idle` 事件不带 Claude 式的 JSONL `transcript_path`,upvote **采纳(adoption)**(工具使用证据、可选的 LLM-judge,以及"本次会话采纳的团队知识"摘要)在 OpenCode 上不会运行——recall 仍会累加 `recalled_count`,但 `upvoted_count` 采集是 Claude 系(带 transcript)工具的特性。
- **MCP** server 位于共享 `opencode.json` 的 `mcp` 键下(详见上文 MCP 章节)。

### Pi Coding Agent
Expand Down
7 changes: 0 additions & 7 deletions src/__tests__/builtin-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,6 @@ describe('builtin-rules', () => {
const content = fs.readFileSync(deployed, 'utf-8');
expect(content).toContain('Team Knowledge Recall');
expect(content).toContain('teamai recall');
// The declaration section is CLI-shipped instruction text, so it is
// English and matches the wording compileRecallRulesBlock uses (#719).
const heading = '### After recall: declare what you used';
expect(content).toContain(heading);
const declaration = content.slice(content.indexOf(heading));
expect(declaration).toContain('teamai:referenced-doc-ids');
expect(declaration).not.toMatch(/[\u4e00-\u9fff]/);
});

it('should deploy the recall rule to cursor as .mdc, not an ignored .md', async () => {
Expand Down
44 changes: 0 additions & 44 deletions src/__tests__/contribute-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ import {
computeSmartScore,
contributeCheckForSession,
takePendingHint,
stashVotesHint,
takePendingVotesHint,
claimVotesNudge,
} from '../contribute-check.js';
import { appendEvent } from '../dashboard-collector.js';
import {
Expand Down Expand Up @@ -66,34 +63,6 @@ describe('contributeState', () => {
expect(readB.contributed).toBe(true);
});

it('stores vote hints independently from contribute state', async () => {
const sessionId = 'independent-votes-hint';
await writeContributeState(sessionId, { contributed: false, pendingHint: 'contribute' });

await stashVotesHint(sessionId, 'votes');

const sidecar = path.join(tmpDir, '.teamai', 'sessions', `${sessionId}.votes-hint.json`);
expect(fs.existsSync(sidecar)).toBe(true);
expect(await takePendingVotesHint(sessionId)).toBe('votes');
expect(fs.existsSync(sidecar)).toBe(false);
expect((await readContributeState(sessionId)).pendingHint).toBe('contribute');
expect(await takePendingVotesHint(sessionId)).toBeNull();
});

it('claims a Cursor votes nudge only once per session', async () => {
expect(await claimVotesNudge('cursor-nudge')).toBe(true);
expect(await claimVotesNudge('cursor-nudge')).toBe(false);
});

it('keeps a successful Cursor claim when stale cleanup fails', async () => {
const readdir = vi.spyOn(fs.promises, 'readdir').mockRejectedValueOnce(new Error('EMFILE'));

expect(await claimVotesNudge('cursor-cleanup-failure')).toBe(true);
expect(await claimVotesNudge('cursor-cleanup-failure')).toBe(false);

readdir.mockRestore();
});

it('returns defaults when session file does not exist', async () => {
const state = await readContributeState('nonexistent-session');
expect(state).toEqual({ contributed: false });
Expand Down Expand Up @@ -986,17 +955,4 @@ describe('sessionId filesystem safety (L2)', () => {

expect(fs.existsSync(filePath)).toBe(true);
});

it('cleanupStaleSessions removes stale votes-hint sidecars', async () => {
const { cleanupStaleSessions } = await import('../contribute-check.js');
await stashVotesHint('old-votes-session', 'votes');
const sessionsDir = path.join(tmpDir, '.teamai', 'sessions');
const sidecarPath = path.join(sessionsDir, 'old-votes-session.votes-hint.json');
const past = Date.now() - 25 * 60 * 60 * 1000;
fs.utimesSync(sidecarPath, new Date(past), new Date(past));

await cleanupStaleSessions(sessionsDir, 'current-session');

expect(fs.existsSync(sidecarPath)).toBe(false);
});
});
27 changes: 22 additions & 5 deletions src/__tests__/hook-dispatch-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ vi.mock('node:child_process', async (importOriginal) => ({
spawn: vi.fn(() => ({ on: vi.fn(), stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) }, unref: vi.fn() })),
}));
vi.mock('../pull.js', () => ({ pull: vi.fn(async () => undefined) }));
vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined) }));
// votes.ts's withVotesLock imports acquireLock/releaseLock from update.js; a
// no-op lock (always acquired) is enough in this single-process test.
vi.mock('../update.js', () => ({
doUpdate: vi.fn(async () => undefined),
acquireLock: vi.fn(async () => true),
releaseLock: vi.fn(async () => undefined),
}));
vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) }));
vi.mock('../utils/reports-branch.js', () => ({ updateReports: vi.fn(async () => undefined) }));

Expand Down Expand Up @@ -130,11 +136,22 @@ describe('hook runs and the scope they belong to (#748)', () => {
});
const fetchSpy = vi.fn(async () => new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
// Upvote adoption is now collected from tool-use evidence (the main agent
// opens a recalled doc's file), not AI self-declaration (#723). Recall a doc
// via its File: path and Read that file so an upvote is actually recorded —
// this is what proves the handler writes to the dispatcher-resolved scope.
const doc1 = path.join(tmp, 'doc-1.md');
fs.writeFileSync(doc1, '# doc-1\nteam knowledge');
const transcript = path.join(tmp, 'transcript.jsonl');
fs.writeFileSync(transcript, JSON.stringify({ type: 'assistant', message: { content: [{
type: 'text',
text: '<!-- teamai:recalled-doc-ids: [doc-1] --> <!-- teamai:referenced-doc-ids: [doc-1] -->',
}] } }) + '\n');
fs.writeFileSync(transcript, [
JSON.stringify({ type: 'assistant', message: { content: [{
type: 'text',
text: `--- [teamai:recall:start] ---\nFile: ${doc1}\n--- [teamai:recall:end] ---`,
}] } }),
JSON.stringify({ type: 'assistant', message: { content: [{
type: 'tool_use', name: 'Read', input: { file_path: doc1 },
}] } }),
].join('\n') + '\n');
// The session's worktree is gone, so chdir fails and the host's launch
// directory, project A, stays the process cwd.
process.chdir(root);
Expand Down
Loading
Loading