From 7d30ed7d83ca1ea3672c3cb0a7aa185d2d224823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 10:39:15 +0800 Subject: [PATCH 01/17] feat(gateway): add optional Bearer token authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When TDAI_GATEWAY_TOKEN env var is set, the gateway requires Authorization: Bearer on all non-OPTIONS requests. When unset, behavior is unchanged (backward compatible with Hermes). Required by the Claude Code plugin which spawns the gateway as a per-user daemon with a randomly generated token. Signed-off-by: 李冠辰 --- src/gateway/__tests__/auth.test.ts | 116 +++++++++++++++++++++++++++++ src/gateway/server.ts | 13 +++- 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/gateway/__tests__/auth.test.ts diff --git a/src/gateway/__tests__/auth.test.ts b/src/gateway/__tests__/auth.test.ts new file mode 100644 index 0000000..d538bce --- /dev/null +++ b/src/gateway/__tests__/auth.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest"; +import http from "node:http"; +import { TdaiGateway } from "../server.js"; + +async function request( + port: number, + path: string, + headers: Record = {}, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { host: "127.0.0.1", port, path, method: "GET", headers }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf-8"), + }), + ); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +describe("Gateway optional Bearer token", () => { + let gateway: TdaiGateway; + const PORT = 18421; + const TOKEN = "test-token-abc-123"; + + beforeAll(async () => { + vi.stubEnv("TDAI_GATEWAY_TOKEN", TOKEN); + gateway = new TdaiGateway({ + server: { port: PORT, host: "127.0.0.1" }, + } as never); + await gateway.start(); + }); + + // vitest config has `unstubEnvs: true`, which resets stubs before each test. + // Re-stub here so the middleware (which reads process.env per-request) sees the token. + beforeEach(() => { + vi.stubEnv("TDAI_GATEWAY_TOKEN", TOKEN); + }); + + afterAll(async () => { + await gateway.stop(); + }); + + it("rejects unauthenticated requests with 401 when token is configured", async () => { + const res = await request(PORT, "/health"); + expect(res.status).toBe(401); + }); + + it("rejects wrong token with 401", async () => { + const res = await request(PORT, "/health", { + Authorization: "Bearer wrong-token", + }); + expect(res.status).toBe(401); + }); + + it("accepts correct Bearer token", async () => { + const res = await request(PORT, "/health", { + Authorization: `Bearer ${TOKEN}`, + }); + expect(res.status).toBe(200); + }); + + it("allows OPTIONS preflight without token (CORS)", async () => { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port: PORT, + path: "/recall", + method: "OPTIONS", + }, + (res) => { + expect(res.statusCode).toBe(204); + resolve(); + }, + ); + req.on("error", reject); + req.end(); + }); + }); +}); + +describe("Gateway with no token configured", () => { + let gateway: TdaiGateway; + const PORT = 18422; + + beforeAll(async () => { + vi.stubEnv("TDAI_GATEWAY_TOKEN", ""); + gateway = new TdaiGateway({ + server: { port: PORT, host: "127.0.0.1" }, + } as never); + await gateway.start(); + }); + + // vitest config has `unstubEnvs: true`; re-stub each test so middleware sees empty token. + beforeEach(() => { + vi.stubEnv("TDAI_GATEWAY_TOKEN", ""); + }); + + afterAll(async () => { + await gateway.stop(); + }); + + it("accepts unauthenticated requests when token is empty (backward compat)", async () => { + const res = await request(PORT, "/health"); + expect(res.status).toBe(200); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index bd7d0a0..c4d1f54 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -176,7 +176,7 @@ export class TdaiGateway { // CORS headers (for development) res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (method === "OPTIONS") { res.writeHead(204); @@ -184,6 +184,17 @@ export class TdaiGateway { return; } + // ----- BEGIN: Optional Bearer token auth (added for Claude Code plugin) ----- + const expectedToken = process.env.TDAI_GATEWAY_TOKEN; + if (expectedToken) { + const authHeader = req.headers.authorization; + if (authHeader !== `Bearer ${expectedToken}`) { + sendError(res, 401, "Unauthorized"); + return; + } + } + // ----- END: Optional Bearer token auth ----- + try { switch (`${method} ${pathname}`) { case "GET /health": From 73f63e17c6150c96bf08a618437cc9dadd366c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 10:39:57 +0800 Subject: [PATCH 02/17] feat(plugin): add Claude Code plugin adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distributes via cc's '/plugin install tdai-memory' with zero ~/.claude/settings.json mutation. Spawns the existing TdaiGateway as a per-user daemon with a randomly generated 256-bit Bearer token (chmod 600) and binds the daemon's lifecycle to the parent cc process. Plugin contents (claude-code-plugin/): - .claude-plugin/plugin.json - cc plugin manifest - hooks/hooks.json - 3 hooks: SessionStart (async warmup), UserPromptSubmit (sync recall + additionalContext injection, capped at 10000 chars), Stop (async capture from cc transcript jsonl) - skills/ - 4 skills: tdai-memory (umbrella), memory-search, memory-status, memory-clear-session (disable-model-invocation) - lib/ - 6 TypeScript modules: - session-key.ts: hash(cwd) partitioning with TDAI_SESSION_KEY env override - gateway-client.ts: HTTP + Bearer client with silent-failure semantics - transcript.ts: defensive cc-transcript jsonl parser - daemon.ts: spawn / probe / token-file / state.json - hook.ts: unified CLI entry dispatching 7 events - gateway-entry.ts: daemon wrapper with SIGTERM/SIGINT handling and 60s parent-pid liveness probe + 5s graceful shutdown - tests/ - 47 unit tests + 3 smoke e2e tests - README.md and README_CN.md Build: - tsdown produces dist/lib/hook.mjs and dist/lib/gateway-entry.mjs - pnpm build:cc-plugin / test:cc-plugin / test:cc-plugin:e2e Per-tool buffering (PostToolUse hook) and MCP support are deferred to follow-up work. Signed-off-by: 李冠辰 --- .gitignore | 6 + claude-code-plugin/.claude-plugin/plugin.json | 8 + claude-code-plugin/README.md | 81 +++++++ claude-code-plugin/README_CN.md | 81 +++++++ claude-code-plugin/hooks/hooks.json | 41 ++++ claude-code-plugin/lib/daemon.ts | 205 ++++++++++++++++ claude-code-plugin/lib/gateway-client.ts | 176 ++++++++++++++ claude-code-plugin/lib/gateway-entry.ts | 56 +++++ claude-code-plugin/lib/hook.ts | 229 ++++++++++++++++++ claude-code-plugin/lib/session-key.ts | 22 ++ claude-code-plugin/lib/transcript.ts | 102 ++++++++ .../skills/memory-clear-session/SKILL.md | 11 + .../skills/memory-search/SKILL.md | 11 + .../skills/memory-status/SKILL.md | 10 + .../skills/tdai-memory/SKILL.md | 29 +++ claude-code-plugin/tests/daemon.test.ts | 115 +++++++++ .../tests/fixtures/transcript-sample.jsonl | 4 + .../tests/gateway-client.test.ts | 183 ++++++++++++++ claude-code-plugin/tests/hook.test.ts | 146 +++++++++++ claude-code-plugin/tests/session-key.test.ts | 49 ++++ claude-code-plugin/tests/smoke.e2e.test.ts | 69 ++++++ claude-code-plugin/tests/transcript.test.ts | 68 ++++++ claude-code-plugin/tsconfig.json | 20 ++ claude-code-plugin/tsdown.config.ts | 16 ++ claude-code-plugin/vitest.e2e.config.ts | 11 + package.json | 10 + vitest.config.ts | 8 +- 27 files changed, 1765 insertions(+), 2 deletions(-) create mode 100644 claude-code-plugin/.claude-plugin/plugin.json create mode 100644 claude-code-plugin/README.md create mode 100644 claude-code-plugin/README_CN.md create mode 100644 claude-code-plugin/hooks/hooks.json create mode 100644 claude-code-plugin/lib/daemon.ts create mode 100644 claude-code-plugin/lib/gateway-client.ts create mode 100644 claude-code-plugin/lib/gateway-entry.ts create mode 100644 claude-code-plugin/lib/hook.ts create mode 100644 claude-code-plugin/lib/session-key.ts create mode 100644 claude-code-plugin/lib/transcript.ts create mode 100644 claude-code-plugin/skills/memory-clear-session/SKILL.md create mode 100644 claude-code-plugin/skills/memory-search/SKILL.md create mode 100644 claude-code-plugin/skills/memory-status/SKILL.md create mode 100644 claude-code-plugin/skills/tdai-memory/SKILL.md create mode 100644 claude-code-plugin/tests/daemon.test.ts create mode 100644 claude-code-plugin/tests/fixtures/transcript-sample.jsonl create mode 100644 claude-code-plugin/tests/gateway-client.test.ts create mode 100644 claude-code-plugin/tests/hook.test.ts create mode 100644 claude-code-plugin/tests/session-key.test.ts create mode 100644 claude-code-plugin/tests/smoke.e2e.test.ts create mode 100644 claude-code-plugin/tests/transcript.test.ts create mode 100644 claude-code-plugin/tsconfig.json create mode 100644 claude-code-plugin/tsdown.config.ts create mode 100644 claude-code-plugin/vitest.e2e.config.ts diff --git a/.gitignore b/.gitignore index 93c0dad..8f5228c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,9 @@ test-offload-sessions.sh # npm pack / release tarballs (never commit packaged outputs) *.tgz *.tar.gz + +# Local development notes (contributor-only, not shipped) +docs/superpowers/ + +# Plugin build output +claude-code-plugin/dist/ diff --git a/claude-code-plugin/.claude-plugin/plugin.json b/claude-code-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..71c8cf5 --- /dev/null +++ b/claude-code-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "tdai-memory", + "version": "0.1.0", + "description": "Long-term + symbolic short-term memory for Claude Code, powered by TencentDB Agent Memory.", + "homepage": "https://github.com/Tencent/TencentDB-Agent-Memory", + "license": "MIT", + "author": "李冠辰 " +} diff --git a/claude-code-plugin/README.md b/claude-code-plugin/README.md new file mode 100644 index 0000000..7374f9c --- /dev/null +++ b/claude-code-plugin/README.md @@ -0,0 +1,81 @@ +# TencentDB Agent Memory — Claude Code Plugin + +Long-term + symbolic short-term memory for [Claude Code](https://claude.com/claude-code), powered by [TencentDB Agent Memory](https://github.com/Tencent/TencentDB-Agent-Memory). + +[中文版](./README_CN.md) + +## What this gives you + +- **Automatic recall** before every prompt — relevant past memories injected into context +- **Automatic capture** after every turn — L0 conversation written, L1/L2/L3 extracted in the background +- **Manual control** via slash skills: `/memory-search`, `/memory-status`, `/memory-clear-session` +- **Project-level isolation** by default (sessionKey = hash of cwd) — your `react-app` memories don't leak into your `golang-svc` work +- **Bearer-secured local daemon** — no plaintext localhost API + +## Installation + +```bash +/plugin install tdai-memory +``` + +That's it. No `~/.claude/settings.json` edits, no global config to track. + +The first time cc starts a session after installation, the plugin will spawn a local daemon (the existing TDAI Gateway) on port 8421–8430 with a randomly generated Bearer token. State persists under `${CLAUDE_PLUGIN_DATA}`. + +## Configuration + +The plugin reads three optional environment variables: + +| Variable | Default | Purpose | +|---|---|---| +| `TDAI_SESSION_KEY` | `hash(cwd)` | Override the per-project memory partition | +| `TDAI_GATEWAY_TOKEN` | auto-generated | Bearer token for daemon ↔ hook IPC | +| `TDAI_GATEWAY_ENTRY` | resolved from plugin | Path to the Gateway entry script | + +Most users never need to set any of these. `TDAI_SESSION_KEY=shared-with-other-project` is the most common power-user override. + +## Data location + +- `${CLAUDE_PLUGIN_DATA}/state.json` — daemon PID + port +- `${CLAUDE_PLUGIN_DATA}/token` — Bearer token (chmod 600) +- `${CLAUDE_PLUGIN_DATA}/memory-tdai/` — SQLite + sqlite-vec database, scene blocks, persona snapshots +- `${CLAUDE_PLUGIN_DATA}/hook.log` — hook diagnostic log + +## How it works + +``` +User prompt → UserPromptSubmit hook → POST /recall → cc injects context +cc replies → Stop hook → POST /capture → L0 + L1/L2/L3 pipeline +Session end → daemon detects parent cc exit → graceful shutdown +``` + +All hook handlers fail silently (writing to `hook.log`) — memory is never on the critical path of your conversation. + +## Troubleshooting + +**`/memory-status` says "unreachable"**: +- Check `${CLAUDE_PLUGIN_DATA}/hook.log` for the most recent error +- Restart your cc session — the SessionStart hook re-probes and re-spawns the daemon + +**Multiple cc terminals on the same project**: +- All terminals share one daemon. The first to launch spawns it; subsequent terminals discover and reuse it via `state.json`. + +**Memory doesn't recall what I expect**: +- Run `/memory-search ` directly to see what's stored +- Note that L1/L2/L3 extraction runs asynchronously — fresh conversations may need a few minutes before they appear in recall + +## Security model + +The daemon listens only on `127.0.0.1` and requires a Bearer token on every request. The token is generated freshly at each spawn and stored at `${CLAUDE_PLUGIN_DATA}/token` with permission 0600. Any process that cannot read that file cannot read your memories. + +## Building from source + +```bash +pnpm install +pnpm build:cc-plugin +pnpm test:cc-plugin +``` + +## License + +MIT — see [LICENSE](../LICENSE). diff --git a/claude-code-plugin/README_CN.md b/claude-code-plugin/README_CN.md new file mode 100644 index 0000000..774735f --- /dev/null +++ b/claude-code-plugin/README_CN.md @@ -0,0 +1,81 @@ +# TencentDB Agent Memory — Claude Code 插件 + +为 [Claude Code](https://claude.com/claude-code) 提供长期记忆 + 符号化短期记忆,由 [TencentDB Agent Memory](https://github.com/Tencent/TencentDB-Agent-Memory) 驱动。 + +[English version](./README.md) + +## 能给你什么 + +- **自动召回**:每次提问前,相关过往记忆自动注入到上下文 +- **自动捕获**:每轮对话结束后,L0 落盘、L1/L2/L3 后台抽取 +- **手动控制**:通过 slash 技能 `/memory-search`、`/memory-status`、`/memory-clear-session` +- **项目级隔离**:默认按 cwd hash 分区,`react-app` 的记忆不会泄漏到 `golang-svc` +- **Bearer Token 鉴权**:本地 daemon 不裸奔,所有请求需带 token + +## 安装 + +```bash +/plugin install tdai-memory +``` + +就这样。不需要改 `~/.claude/settings.json`,也没有全局配置需要维护。 + +第一次启动 cc 会话时,插件会在 8421–8430 端口拉起一个本地 daemon(即现有的 TDAI Gateway),并生成随机 Bearer token。状态保存在 `${CLAUDE_PLUGIN_DATA}`。 + +## 配置 + +插件读取三个可选环境变量: + +| 变量 | 默认值 | 作用 | +|---|---|---| +| `TDAI_SESSION_KEY` | `hash(cwd)` | 覆盖项目级记忆分区 | +| `TDAI_GATEWAY_TOKEN` | 自动生成 | daemon ↔ hook IPC 的 Bearer token | +| `TDAI_GATEWAY_ENTRY` | 自动解析 | Gateway 入口脚本路径 | + +大多数用户都不需要设置任何变量。`TDAI_SESSION_KEY=shared-with-other-project` 是最常用的高级用法。 + +## 数据位置 + +- `${CLAUDE_PLUGIN_DATA}/state.json` — daemon PID + 端口 +- `${CLAUDE_PLUGIN_DATA}/token` — Bearer token(chmod 600) +- `${CLAUDE_PLUGIN_DATA}/memory-tdai/` — SQLite + sqlite-vec 数据、场景块、画像快照 +- `${CLAUDE_PLUGIN_DATA}/hook.log` — hook 排障日志 + +## 工作原理 + +``` +用户输入 → UserPromptSubmit hook → POST /recall → cc 注入上下文 +cc 回复 → Stop hook → POST /capture → L0 + L1/L2/L3 流水线 +会话退出 → daemon 检测父 cc 退出 → 优雅关闭 +``` + +所有 hook 都是"失败静默"——日志写 `hook.log`,记忆系统永远不在对话的关键路径上。 + +## 排障 + +**`/memory-status` 显示 "unreachable"**: +- 看 `${CLAUDE_PLUGIN_DATA}/hook.log` 最近的错误 +- 重启 cc 会话——SessionStart hook 会重新探活并 spawn daemon + +**多个 cc 终端开同一个项目**: +- 共享一个 daemon。第一个启动的 cc 拉起它,后续 cc 通过 `state.json` 发现并复用。 + +**记忆召回不准**: +- 直接跑 `/memory-search ` 看存了什么 +- L1/L2/L3 抽取是异步的,新对话需要几分钟才能被召回到 + +## 安全模型 + +Daemon 仅监听 `127.0.0.1`,每个请求都需要 Bearer token。Token 在每次 spawn 时新生成,存放在 `${CLAUDE_PLUGIN_DATA}/token`,权限 0600。读不到这个文件的进程读不到你的记忆。 + +## 源码构建 + +```bash +pnpm install +pnpm build:cc-plugin +pnpm test:cc-plugin +``` + +## License + +MIT — 见 [LICENSE](../LICENSE)。 diff --git a/claude-code-plugin/hooks/hooks.json b/claude-code-plugin/hooks/hooks.json new file mode 100644 index 0000000..e5588ce --- /dev/null +++ b/claude-code-plugin/hooks/hooks.json @@ -0,0 +1,41 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs\" session-start", + "async": true, + "timeout": 30, + "statusMessage": "Initializing memory..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs\" user-prompt-submit", + "timeout": 5, + "statusMessage": "Recalling memories..." + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs\" stop", + "async": true, + "timeout": 30 + } + ] + } + ] + } +} diff --git a/claude-code-plugin/lib/daemon.ts b/claude-code-plugin/lib/daemon.ts new file mode 100644 index 0000000..9ca498d --- /dev/null +++ b/claude-code-plugin/lib/daemon.ts @@ -0,0 +1,205 @@ +/** + * Daemon manager — spawns the TdaiGateway as a long-lived sidecar bound + * to the parent cc process. Mirrors the supervisor.py pattern from + * hermes-plugin/. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdir, writeFile, readFile, stat, unlink } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import http from "node:http"; +import net from "node:net"; + +export interface DaemonState { + pid: number; + port: number; + ccPid: number; + startedAt: string; + tokenPath: string; +} + +export interface DaemonManagerConfig { + dataDir: string; + portStart?: number; + portEnd?: number; +} + +const DEFAULT_PORT_START = 8421; +const DEFAULT_PORT_END = 8430; +const STATE_FILE = "state.json"; + +export async function readDaemonState(dataDir: string): Promise { + const path = join(dataDir, STATE_FILE); + if (!existsSync(path)) return null; + try { + const raw = await readFile(path, "utf-8"); + return JSON.parse(raw) as DaemonState; + } catch { + return null; + } +} + +export async function writeDaemonState(dataDir: string, state: DaemonState): Promise { + await mkdir(dataDir, { recursive: true }); + await writeFile(join(dataDir, STATE_FILE), JSON.stringify(state, null, 2), { + mode: 0o600, + }); +} + +export async function clearDaemonState(dataDir: string): Promise { + const path = join(dataDir, STATE_FILE); + try { + await unlink(path); + } catch { + // ignore + } +} + +export class DaemonManager { + private dataDir: string; + private portStart: number; + private portEnd: number; + + constructor(config: DaemonManagerConfig) { + this.dataDir = config.dataDir; + this.portStart = config.portStart ?? DEFAULT_PORT_START; + this.portEnd = config.portEnd ?? DEFAULT_PORT_END; + } + + async generateToken(): Promise { + await mkdir(this.dataDir, { recursive: true }); + const token = randomBytes(32).toString("base64url"); + const tokenPath = join(this.dataDir, "token"); + await writeFile(tokenPath, token, { mode: 0o600 }); + return tokenPath; + } + + async readToken(tokenPath: string): Promise { + const st = await stat(tokenPath); + if ((st.mode & 0o077) !== 0) { + throw new Error(`Token file permission too loose: ${tokenPath}`); + } + const raw = await readFile(tokenPath, "utf-8"); + return raw.trim(); + } + + async findFreePort( + start = this.portStart, + end = this.portEnd, + ): Promise { + for (let p = start; p <= end; p++) { + if (await this.isPortFree(p)) return p; + } + throw new Error(`No free port in ${start}..${end}`); + } + + private isPortFree(port: number): Promise { + return new Promise((resolve) => { + const tester = net.createServer(); + tester.once("error", () => resolve(false)); + tester.once("listening", () => { + tester.close(() => resolve(true)); + }); + tester.listen(port, "127.0.0.1"); + }); + } + + async probe(): Promise { + const state = await readDaemonState(this.dataDir); + if (!state) return false; + let token: string; + try { + token = await this.readToken(state.tokenPath); + } catch { + return false; + } + return this.healthCheck(state.port, token); + } + + private healthCheck(port: number, token: string, timeoutMs = 2000): Promise { + return new Promise((resolve) => { + const req = http.request( + { + host: "127.0.0.1", + port, + path: "/health", + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }, + (res) => resolve(res.statusCode === 200), + ); + req.setTimeout(timeoutMs, () => { + req.destroy(); + resolve(false); + }); + req.on("error", () => resolve(false)); + req.end(); + }); + } + + async ensureRunning(ccPid: number, gatewayEntry: string): Promise { + const existing = await readDaemonState(this.dataDir); + if (existing) { + let existingToken = ""; + try { + existingToken = await this.readToken(existing.tokenPath); + } catch { + // fallthrough to spawn + } + if (existingToken && (await this.healthCheck(existing.port, existingToken))) { + return existing; + } + } + return this.spawn(ccPid, gatewayEntry); + } + + async spawn(ccPid: number, gatewayEntry: string): Promise { + const port = await this.findFreePort(); + const tokenPath = await this.generateToken(); + const token = await this.readToken(tokenPath); + + const child: ChildProcess = spawn( + process.execPath, + [gatewayEntry], + { + env: { + ...process.env, + TDAI_GATEWAY_TOKEN: token, + TDAI_GATEWAY_PORT: String(port), + TDAI_CC_PID: String(ccPid), + }, + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }, + ); + child.unref(); + + if (!child.pid) { + throw new Error("Failed to spawn daemon: child has no pid"); + } + + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (await this.healthCheck(port, token, 500)) { + const state: DaemonState = { + pid: child.pid, + port, + ccPid, + startedAt: new Date().toISOString(), + tokenPath, + }; + await writeDaemonState(this.dataDir, state); + return state; + } + await sleep(200); + } + + throw new Error(`Daemon did not become healthy on port ${port} within 10s`); + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/claude-code-plugin/lib/gateway-client.ts b/claude-code-plugin/lib/gateway-client.ts new file mode 100644 index 0000000..47ba1c0 --- /dev/null +++ b/claude-code-plugin/lib/gateway-client.ts @@ -0,0 +1,176 @@ +/** + * HTTP client for the TDAI Gateway, with Bearer token authentication and + * silent-failure semantics suitable for cc hook handlers (any error returns + * an empty / no-op response rather than throwing). + */ + +import http from "node:http"; +import { URL } from "node:url"; + +export interface GatewayClientConfig { + baseUrl: string; + token: string; + timeoutMs?: number; +} + +export interface RecallResult { + context: string; + strategy?: string; + memory_count?: number; +} + +export interface CaptureTurnPayload { + user_content: string; + assistant_content: string; + session_key: string; + session_id?: string; + messages?: Array<{ role: string; content: string }>; +} + +export interface CaptureTurnResult { + l0_recorded: number; + scheduler_notified: boolean; +} + +export interface SearchResult { + results: string; + total: number; + strategy?: string; +} + +export class GatewayClient { + private baseUrl: URL; + private token: string; + private timeoutMs: number; + + constructor(config: GatewayClientConfig) { + this.baseUrl = new URL(config.baseUrl); + this.token = config.token; + this.timeoutMs = config.timeoutMs ?? 5_000; + } + + async health(): Promise { + try { + const { status } = await this.request("GET", "/health"); + return status === 200; + } catch { + return false; + } + } + + async recall(query: string, sessionKey: string): Promise { + try { + const { status, body } = await this.request("POST", "/recall", { + query, + session_key: sessionKey, + }); + if (status !== 200) return { context: "" }; + const parsed = JSON.parse(body) as RecallResult; + return { + context: parsed.context ?? "", + strategy: parsed.strategy, + memory_count: parsed.memory_count, + }; + } catch { + return { context: "" }; + } + } + + async captureTurn(payload: CaptureTurnPayload): Promise { + try { + const { status, body } = await this.request("POST", "/capture", payload); + if (status !== 200) return null; + return JSON.parse(body) as CaptureTurnResult; + } catch { + return null; + } + } + + async searchMemories( + query: string, + opts?: { limit?: number; type?: string; scene?: string }, + ): Promise { + try { + const { status, body } = await this.request("POST", "/search/memories", { + query, + limit: opts?.limit, + type: opts?.type, + scene: opts?.scene, + }); + if (status !== 200) return { results: "", total: 0 }; + return JSON.parse(body) as SearchResult; + } catch { + return { results: "", total: 0 }; + } + } + + async searchConversations( + query: string, + opts?: { limit?: number; sessionKey?: string }, + ): Promise { + try { + const { status, body } = await this.request("POST", "/search/conversations", { + query, + limit: opts?.limit, + session_key: opts?.sessionKey, + }); + if (status !== 200) return { results: "", total: 0 }; + return JSON.parse(body) as SearchResult; + } catch { + return { results: "", total: 0 }; + } + } + + async sessionEnd(sessionKey: string): Promise { + try { + await this.request("POST", "/session/end", { session_key: sessionKey }); + } catch { + // silent + } + } + + private request( + method: string, + path: string, + bodyObj?: unknown, + ): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const bodyStr = bodyObj ? JSON.stringify(bodyObj) : undefined; + const opts: http.RequestOptions = { + protocol: this.baseUrl.protocol, + hostname: this.baseUrl.hostname, + port: this.baseUrl.port, + method, + path, + headers: { + Authorization: `Bearer ${this.token}`, + ...(bodyStr + ? { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(bodyStr).toString(), + } + : {}), + }, + }; + + const req = http.request(opts, (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf-8"), + }), + ); + }); + + req.setTimeout(this.timeoutMs, () => { + req.destroy(new Error(`Timeout after ${this.timeoutMs}ms`)); + }); + + req.on("error", reject); + if (bodyStr) req.write(bodyStr); + req.end(); + }); + } +} diff --git a/claude-code-plugin/lib/gateway-entry.ts b/claude-code-plugin/lib/gateway-entry.ts new file mode 100644 index 0000000..3c34dc2 --- /dev/null +++ b/claude-code-plugin/lib/gateway-entry.ts @@ -0,0 +1,56 @@ +/** + * Gateway entry script — wraps TdaiGateway with parent-process liveness + * binding for the Claude Code plugin daemon lifecycle. + * + * Spawned by lib/daemon.ts with env: + * TDAI_GATEWAY_TOKEN — Bearer token (required by gateway middleware) + * TDAI_GATEWAY_PORT — Port to bind + * TDAI_DATA_DIR — Data root + * TDAI_CC_PID — Parent cc process pid; we self-exit when it dies + */ + +import { TdaiGateway } from "../../src/gateway/server.js"; + +async function main(): Promise { + const gateway = new TdaiGateway(); + await gateway.start(); + + let shuttingDown = false; + const shutdown = async (reason: string): Promise => { + if (shuttingDown) return; + shuttingDown = true; + try { + await Promise.race([ + gateway.stop(), + new Promise((r) => setTimeout(r, 5_000)), + ]); + } catch { + // ignore — best effort + } + process.exit(reason === "error" ? 1 : 0); + }; + + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + + const ccPid = parseInt(process.env.TDAI_CC_PID ?? "0", 10); + if (Number.isFinite(ccPid) && ccPid > 0) { + const timer = setInterval(() => { + try { + process.kill(ccPid, 0); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.code === "ESRCH") { + clearInterval(timer); + void shutdown("parent-exit"); + } + } + }, 60_000); + timer.unref(); + } +} + +main().catch((err) => { + process.stderr.write(`gateway-entry failed: ${String(err)}\n`); + process.exit(1); +}); diff --git a/claude-code-plugin/lib/hook.ts b/claude-code-plugin/lib/hook.ts new file mode 100644 index 0000000..e99f7b7 --- /dev/null +++ b/claude-code-plugin/lib/hook.ts @@ -0,0 +1,229 @@ +/** + * Unified hook entry point. Dispatched by the first CLI arg. + * + * Usage from cc plugin hook config: + * node ${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs + * + * Where is one of: + * session-start | user-prompt-submit | post-tool-use | stop | + * search | status | clear-session + */ + +import { GatewayClient } from "./gateway-client.js"; +import { getSessionKey } from "./session-key.js"; +import { readLatestTurn } from "./transcript.js"; +import { DaemonManager, readDaemonState } from "./daemon.js"; +import { appendFile } from "node:fs/promises"; +import { join } from "node:path"; + +const MAX_INJECT_CHARS = 10_000; + +export type HookEvent = + | "session-start" + | "user-prompt-submit" + | "post-tool-use" + | "stop" + | "search" + | "status" + | "clear-session"; + +export interface HookInput { + stdin: string; + client: GatewayClient; + args?: string[]; +} + +export async function handleHook(event: HookEvent, input: HookInput): Promise { + const data = parseStdin(input.stdin); + switch (event) { + case "session-start": + return handleSessionStart(data, input.client); + case "user-prompt-submit": + return handleUserPromptSubmit(data, input.client); + case "post-tool-use": + return handlePostToolUse(data, input.client); + case "stop": + return handleStop(data, input.client); + case "search": + return handleSearch(input.args ?? [], input.client); + case "status": + return handleStatus(input.client); + case "clear-session": + return handleClearSession(data, input.client); + default: + return ""; + } +} + +interface HookStdin { + session_id?: string; + transcript_path?: string; + cwd?: string; + prompt?: string; + source?: string; + tool_name?: string; + tool_input?: unknown; + tool_response?: unknown; + tool_use_id?: string; + stop_hook_active?: boolean; +} + +function parseStdin(raw: string): HookStdin { + if (!raw) return {}; + try { + return JSON.parse(raw) as HookStdin; + } catch { + return {}; + } +} + +async function handleSessionStart(_data: HookStdin, client: GatewayClient): Promise { + await client.health(); + return ""; +} + +async function handleUserPromptSubmit(data: HookStdin, client: GatewayClient): Promise { + const prompt = data.prompt ?? ""; + const cwd = data.cwd ?? process.cwd(); + if (!prompt) return ""; + + const sessionKey = getSessionKey(cwd); + const result = await client.recall(prompt, sessionKey); + let context = result.context ?? ""; + if (!context) return ""; + + if (context.length > MAX_INJECT_CHARS) { + context = + context.slice(0, MAX_INJECT_CHARS - 100) + + "\n\n[…recall truncated — use /memory-search for full results…]"; + } + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: context, + }, + }); +} + +async function handlePostToolUse(_data: HookStdin, _client: GatewayClient): Promise { + // No-op fallback. PostToolUse capture is intentionally deferred to a + // follow-up PR — see spec §5.3 for the buffer endpoint design. The + // hooks.json registration was removed so this handler is unreachable + // by default; it remains here only as a safety net if someone manually + // re-enables the PostToolUse hook before the follow-up lands. + return ""; +} + +async function handleStop(data: HookStdin, client: GatewayClient): Promise { + if (data.stop_hook_active === true) return ""; + if (!data.transcript_path) return ""; + + const turn = await readLatestTurn(data.transcript_path); + if (!turn) return ""; + + const cwd = data.cwd ?? process.cwd(); + const sessionKey = getSessionKey(cwd); + + await client.captureTurn({ + user_content: turn.user, + assistant_content: turn.assistant, + session_key: sessionKey, + session_id: data.session_id, + }); + return ""; +} + +async function handleSearch(args: string[], client: GatewayClient): Promise { + const query = args.join(" ").trim(); + if (!query) return "Usage: /memory-search "; + const result = await client.searchMemories(query, { limit: 10 }); + return result.results || "No memories found."; +} + +async function handleStatus(client: GatewayClient): Promise { + const ok = await client.health(); + return ok ? "TDAI memory daemon: healthy" : "TDAI memory daemon: unreachable"; +} + +async function handleClearSession(data: HookStdin, client: GatewayClient): Promise { + const cwd = data.cwd ?? process.cwd(); + const sessionKey = getSessionKey(cwd); + await client.sessionEnd(sessionKey); + return `Cleared session buffer for: ${sessionKey}`; +} + +// ============================================================================ +// CLI entry — only runs when this file is executed directly via `node hook.js` +// ============================================================================ + +async function main(): Promise { + const event = (process.argv[2] ?? "") as HookEvent; + const args = process.argv.slice(3); + + const dataDir = process.env.CLAUDE_PLUGIN_DATA ?? join(process.env.HOME ?? ".", ".tdai-memory"); + const logPath = join(dataDir, "hook.log"); + + try { + const stdin = await readStdin(); + + const mgr = new DaemonManager({ dataDir }); + let state = await readDaemonState(dataDir); + + if (event === "session-start" && !state) { + const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? ""; + const gatewayEntry = + process.env.TDAI_GATEWAY_ENTRY ?? + (pluginRoot ? join(pluginRoot, "dist/lib/gateway-entry.mjs") : ""); + if (gatewayEntry) { + try { + state = await mgr.ensureRunning(process.ppid, gatewayEntry); + } catch (err) { + await safeLog(logPath, `session-start: spawn failed: ${(err as Error).message}`); + } + } + } + + if (!state) { + await safeLog(logPath, `${event}: no daemon, skipped`); + return; + } + + const token = await mgr.readToken(state.tokenPath); + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${state.port}`, + token, + timeoutMs: event === "user-prompt-submit" ? 4_000 : 10_000, + }); + + const out = await handleHook(event, { stdin, client, args }); + if (out) process.stdout.write(out); + } catch (err) { + await safeLog(logPath, `${event}: ${(err as Error).message}`); + } +} + +function readStdin(): Promise { + return new Promise((resolve) => { + if (process.stdin.isTTY) { + resolve(""); + return; + } + const chunks: Buffer[] = []; + process.stdin.on("data", (c) => chunks.push(c)); + process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + process.stdin.on("error", () => resolve("")); + }); +} + +async function safeLog(path: string, msg: string): Promise { + try { + await appendFile(path, `[${new Date().toISOString()}] ${msg}\n`); + } catch { + // ignore + } +} + +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + main().catch(() => process.exit(0)); +} diff --git a/claude-code-plugin/lib/session-key.ts b/claude-code-plugin/lib/session-key.ts new file mode 100644 index 0000000..d9b21ef --- /dev/null +++ b/claude-code-plugin/lib/session-key.ts @@ -0,0 +1,22 @@ +/** + * Compute a stable session key for a given working directory. + * + * Default: SHA-256 of the normalized absolute path, first 16 hex chars (64 bits). + * Override: TDAI_SESSION_KEY env var, if non-empty. + * + * Used by hook handlers to partition memory by project rather than by + * Claude Code session, so multiple cc terminals on the same project share + * recall results. + */ + +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; + +export function getSessionKey(cwd: string): string { + const override = process.env.TDAI_SESSION_KEY; + if (override && override.length > 0) { + return override; + } + const normalized = resolve(cwd); + return createHash("sha256").update(normalized).digest("hex").slice(0, 16); +} diff --git a/claude-code-plugin/lib/transcript.ts b/claude-code-plugin/lib/transcript.ts new file mode 100644 index 0000000..2b61ceb --- /dev/null +++ b/claude-code-plugin/lib/transcript.ts @@ -0,0 +1,102 @@ +/** + * Parse cc transcript jsonl files defensively. cc's transcript format is + * NOT a documented stable API — fields may rename across versions. This + * module returns null on any unexpected shape rather than throwing. + */ + +import { readFile } from "node:fs/promises"; + +export interface TranscriptEntry { + type: "user" | "assistant" | string; + role: string; + content: string; + uuid?: string; + parentUuid?: string; + timestamp?: string; +} + +export interface Turn { + user: string; + assistant: string; +} + +/** + * Parse a single JSONL line. Returns null on malformed or unrecognized shape. + */ +export function parseTranscriptLine(line: string): TranscriptEntry | null { + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + return null; + } + if (!obj || typeof obj !== "object") return null; + const o = obj as Record; + + const type = typeof o.type === "string" ? o.type : null; + if (!type) return null; + + const message = o.message as Record | undefined; + if (!message || typeof message !== "object") return null; + + const role = typeof message.role === "string" ? message.role : type; + + const content = extractContent(message.content); + if (content === null) return null; + + return { + type, + role, + content, + uuid: typeof o.uuid === "string" ? o.uuid : undefined, + parentUuid: typeof o.parentUuid === "string" ? o.parentUuid : undefined, + timestamp: typeof o.timestamp === "string" ? o.timestamp : undefined, + }; +} + +function extractContent(content: unknown): string | null { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const item of content) { + if (!item || typeof item !== "object") continue; + const it = item as Record; + if (typeof it.text === "string") parts.push(it.text); + } + return parts.length > 0 ? parts.join("\n") : null; + } + return null; +} + +/** + * Read the latest complete user+assistant turn from a transcript jsonl file. + * Returns null if the file is missing, empty, or contains no complete turn. + */ +export async function readLatestTurn(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf-8"); + } catch { + return null; + } + const lines = raw.split(/\r?\n/).filter((l) => l.length > 0); + if (lines.length === 0) return null; + + // Walk from the end backwards looking for assistant then user. + let assistant: string | null = null; + let user: string | null = null; + for (let i = lines.length - 1; i >= 0; i--) { + const entry = parseTranscriptLine(lines[i]); + if (!entry) continue; + if (assistant === null && entry.role === "assistant") { + assistant = entry.content; + continue; + } + if (assistant !== null && user === null && entry.role === "user") { + user = entry.content; + break; + } + } + if (user === null || assistant === null) return null; + return { user, assistant }; +} diff --git a/claude-code-plugin/skills/memory-clear-session/SKILL.md b/claude-code-plugin/skills/memory-clear-session/SKILL.md new file mode 100644 index 0000000..61126fa --- /dev/null +++ b/claude-code-plugin/skills/memory-clear-session/SKILL.md @@ -0,0 +1,11 @@ +--- +name: memory-clear-session +description: Manually clear the current session's accumulated memory buffer for this working directory. DESTRUCTIVE — call only when the user explicitly asks to forget the current context. +disable-model-invocation: true +--- + +The user has explicitly requested to clear this session's memory buffer. + +!`node "${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs" clear-session` + +Confirm to the user that the session buffer was cleared. Long-term memories (L1/L2/L3) are untouched. diff --git a/claude-code-plugin/skills/memory-search/SKILL.md b/claude-code-plugin/skills/memory-search/SKILL.md new file mode 100644 index 0000000..9280bdc --- /dev/null +++ b/claude-code-plugin/skills/memory-search/SKILL.md @@ -0,0 +1,11 @@ +--- +name: memory-search +description: Search long-term memory (TencentDB Agent Memory) for relevant past interactions, preferences, or decisions. Use when the user asks "do you remember…" or references past work in this project. +argument-hint: +--- + +Search TencentDB Agent Memory for: $ARGUMENTS + +!`node "${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs" search "$ARGUMENTS"` + +Summarize the results above and answer the user's question. If no memories were found, say so plainly. diff --git a/claude-code-plugin/skills/memory-status/SKILL.md b/claude-code-plugin/skills/memory-status/SKILL.md new file mode 100644 index 0000000..adc5f35 --- /dev/null +++ b/claude-code-plugin/skills/memory-status/SKILL.md @@ -0,0 +1,10 @@ +--- +name: memory-status +description: Check the health of the TDAI memory daemon. Reports whether the local gateway is running and reachable. +--- + +Checking TDAI memory daemon status... + +!`node "${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs" status` + +Report the result to the user. diff --git a/claude-code-plugin/skills/tdai-memory/SKILL.md b/claude-code-plugin/skills/tdai-memory/SKILL.md new file mode 100644 index 0000000..6c7c8c1 --- /dev/null +++ b/claude-code-plugin/skills/tdai-memory/SKILL.md @@ -0,0 +1,29 @@ +--- +name: tdai-memory +description: TencentDB Agent Memory provides long-term memory (user preferences, past decisions, style) and short-term project context. Use this skill to understand how to leverage memory in this conversation. +--- + +# Using TencentDB Agent Memory + +This plugin gives Claude long-term + symbolic short-term memory. + +## What happens automatically + +- Every prompt: relevant past memories are pre-loaded into context (via `UserPromptSubmit` hook → `/recall`) +- Every turn: the user/assistant exchange is captured to L0 (via `Stop` hook → `/capture`); structured L1/L2/L3 extraction runs in the background + +## Manual control (slash skills) + +- `/memory-search ` — search past memories for a specific topic +- `/memory-status` — check daemon health +- `/memory-clear-session` — clear the current session's buffer (manual invocation only) + +## Hints for Claude + +When the user asks "do you remember…" or references prior work, the recalled context (in the `` block this turn) is your source. If the context is missing, suggest the user run `/memory-search `. + +## Where data lives + +Memory is stored under `${CLAUDE_PLUGIN_DATA}/memory-tdai/` — a SQLite + sqlite-vec database plus markdown snapshots. Data is partitioned by working-directory hash by default; export `TDAI_SESSION_KEY=` to override. + +See the project README for full architecture details. diff --git a/claude-code-plugin/tests/daemon.test.ts b/claude-code-plugin/tests/daemon.test.ts new file mode 100644 index 0000000..f376b05 --- /dev/null +++ b/claude-code-plugin/tests/daemon.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DaemonManager, + readDaemonState, + writeDaemonState, +} from "../lib/daemon.js"; + +let dataDir: string; + +beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "tdai-daemon-test-")); +}); + +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +describe("DaemonManager state file", () => { + it("readDaemonState returns null when state.json missing", async () => { + const state = await readDaemonState(dataDir); + expect(state).toBeNull(); + }); + + it("writeDaemonState writes a parseable JSON file", async () => { + await writeDaemonState(dataDir, { + pid: 999, + port: 8421, + ccPid: 998, + startedAt: "2026-05-15T10:00:00Z", + tokenPath: join(dataDir, "token"), + }); + const state = await readDaemonState(dataDir); + expect(state).toEqual({ + pid: 999, + port: 8421, + ccPid: 998, + startedAt: "2026-05-15T10:00:00Z", + tokenPath: join(dataDir, "token"), + }); + }); +}); + +describe("DaemonManager token file", () => { + it("generateToken creates a 600-mode file with 256-bit base64url token", async () => { + const mgr = new DaemonManager({ dataDir }); + const tokenPath = await mgr.generateToken(); + const content = await readFile(tokenPath, "utf-8"); + expect(content).toMatch(/^[A-Za-z0-9_-]{43}$/); + const st = await stat(tokenPath); + expect(st.mode & 0o777).toBe(0o600); + }); + + it("readToken throws when permission is too loose", async () => { + const tokenPath = join(dataDir, "token"); + await writeFile(tokenPath, "abc", { mode: 0o644 }); + const mgr = new DaemonManager({ dataDir }); + await expect(mgr.readToken(tokenPath)).rejects.toThrow(/permission/i); + }); + + it("readToken returns the trimmed token when permission is 600", async () => { + const tokenPath = join(dataDir, "token"); + await writeFile(tokenPath, "secret-token\n", { mode: 0o600 }); + const mgr = new DaemonManager({ dataDir }); + const tok = await mgr.readToken(tokenPath); + expect(tok).toBe("secret-token"); + }); +}); + +describe("DaemonManager findFreePort", () => { + it("returns a free port within range", async () => { + const mgr = new DaemonManager({ dataDir }); + const port = await mgr.findFreePort(18500, 18510); + expect(port).toBeGreaterThanOrEqual(18500); + expect(port).toBeLessThanOrEqual(18510); + }); + + it("throws when all ports are taken", async () => { + const http = await import("node:http"); + const blockers: import("node:http").Server[] = []; + for (let p = 18600; p <= 18602; p++) { + const s = http.createServer(); + await new Promise((r) => s.listen(p, "127.0.0.1", () => r())); + blockers.push(s); + } + try { + const mgr = new DaemonManager({ dataDir }); + await expect(mgr.findFreePort(18600, 18602)).rejects.toThrow(/no free port/i); + } finally { + for (const s of blockers) await new Promise((r) => s.close(() => r())); + } + }); +}); + +describe("DaemonManager probe", () => { + it("probe returns false when state.json is missing", async () => { + const mgr = new DaemonManager({ dataDir }); + expect(await mgr.probe()).toBe(false); + }); + + it("probe returns false when daemon health check fails", async () => { + await writeDaemonState(dataDir, { + pid: 99999, + port: 1, + ccPid: process.pid, + startedAt: "2026-05-15T10:00:00Z", + tokenPath: join(dataDir, "token"), + }); + await writeFile(join(dataDir, "token"), "x", { mode: 0o600 }); + const mgr = new DaemonManager({ dataDir }); + expect(await mgr.probe()).toBe(false); + }); +}); diff --git a/claude-code-plugin/tests/fixtures/transcript-sample.jsonl b/claude-code-plugin/tests/fixtures/transcript-sample.jsonl new file mode 100644 index 0000000..141ce8b --- /dev/null +++ b/claude-code-plugin/tests/fixtures/transcript-sample.jsonl @@ -0,0 +1,4 @@ +{"type":"user","message":{"role":"user","content":"first question"},"uuid":"u1","timestamp":"2026-05-15T10:00:00Z"} +{"type":"assistant","message":{"role":"assistant","content":"first answer"},"uuid":"a1","parentUuid":"u1","timestamp":"2026-05-15T10:00:01Z"} +{"type":"user","message":{"role":"user","content":"second question"},"uuid":"u2","timestamp":"2026-05-15T10:01:00Z"} +{"type":"assistant","message":{"role":"assistant","content":"second answer"},"uuid":"a2","parentUuid":"u2","timestamp":"2026-05-15T10:01:01Z"} \ No newline at end of file diff --git a/claude-code-plugin/tests/gateway-client.test.ts b/claude-code-plugin/tests/gateway-client.test.ts new file mode 100644 index 0000000..317cfca --- /dev/null +++ b/claude-code-plugin/tests/gateway-client.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, afterEach } from "vitest"; +import http from "node:http"; +import { GatewayClient } from "../lib/gateway-client.js"; + +interface CapturedRequest { + method: string; + path: string; + headers: http.IncomingHttpHeaders; + body: string; +} + +function startStubServer( + handler: (req: CapturedRequest) => { status: number; body: unknown }, +): Promise<{ port: number; close: () => Promise; captured: CapturedRequest[] }> { + return new Promise((resolve) => { + const captured: CapturedRequest[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const captured1: CapturedRequest = { + method: req.method ?? "", + path: req.url ?? "", + headers: req.headers, + body: Buffer.concat(chunks).toString("utf-8"), + }; + captured.push(captured1); + const { status, body } = handler(captured1); + const json = JSON.stringify(body); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(json); + }); + }); + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as { port: number }).port; + resolve({ + port, + close: () => new Promise((r) => server.close(() => r())), + captured, + }); + }); + }); +} + +describe("GatewayClient", () => { + let stub: Awaited>; + + afterEach(async () => { + if (stub) await stub.close(); + }); + + it("sends Authorization: Bearer on health probe", async () => { + stub = await startStubServer(() => ({ + status: 200, + body: { status: "ok", version: "x", uptime: 1 }, + })); + + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${stub.port}`, + token: "secret-123", + }); + const ok = await client.health(); + expect(ok).toBe(true); + expect(stub.captured[0].headers.authorization).toBe("Bearer secret-123"); + }); + + it("health returns false on non-200", async () => { + stub = await startStubServer(() => ({ status: 500, body: { error: "x" } })); + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${stub.port}`, + token: "t", + }); + expect(await client.health()).toBe(false); + }); + + it("health returns false on connection error", async () => { + const client = new GatewayClient({ + baseUrl: "http://127.0.0.1:1", + token: "t", + }); + expect(await client.health()).toBe(false); + }); + + it("recall POSTs query and session_key, returns context string", async () => { + stub = await startStubServer(() => ({ + status: 200, + body: { context: "recalled-content", strategy: "hybrid", memory_count: 3 }, + })); + + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${stub.port}`, + token: "t", + }); + const result = await client.recall("hello", "session-abc"); + expect(result.context).toBe("recalled-content"); + expect(stub.captured[0].method).toBe("POST"); + expect(stub.captured[0].path).toBe("/recall"); + expect(JSON.parse(stub.captured[0].body)).toEqual({ + query: "hello", + session_key: "session-abc", + }); + }); + + it("recall returns empty context on error (silent failure)", async () => { + stub = await startStubServer(() => ({ status: 500, body: { error: "x" } })); + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${stub.port}`, + token: "t", + }); + const result = await client.recall("hello", "k"); + expect(result.context).toBe(""); + }); + + it("captureTurn POSTs the expected payload", async () => { + stub = await startStubServer(() => ({ + status: 200, + body: { l0_recorded: 1, scheduler_notified: true }, + })); + + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${stub.port}`, + token: "t", + }); + await client.captureTurn({ + user_content: "u", + assistant_content: "a", + session_key: "k", + session_id: "s", + }); + expect(stub.captured[0].path).toBe("/capture"); + expect(JSON.parse(stub.captured[0].body)).toEqual({ + user_content: "u", + assistant_content: "a", + session_key: "k", + session_id: "s", + }); + }); + + it("searchMemories POSTs query, returns results text", async () => { + stub = await startStubServer(() => ({ + status: 200, + body: { results: "memory-text", total: 5, strategy: "hybrid" }, + })); + + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${stub.port}`, + token: "t", + }); + const res = await client.searchMemories("query"); + expect(res.results).toBe("memory-text"); + expect(res.total).toBe(5); + }); + + it("searchConversations POSTs to /search/conversations", async () => { + stub = await startStubServer(() => ({ + status: 200, + body: { results: "conv-text", total: 2 }, + })); + + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${stub.port}`, + token: "t", + }); + const res = await client.searchConversations("q"); + expect(res.results).toBe("conv-text"); + expect(stub.captured[0].path).toBe("/search/conversations"); + }); + + it("times out long-running requests", async () => { + const hangServer = http.createServer((_req, _res) => {}); + await new Promise((r) => hangServer.listen(0, "127.0.0.1", () => r())); + const port = (hangServer.address() as { port: number }).port; + + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${port}`, + token: "t", + timeoutMs: 100, + }); + const result = await client.recall("q", "k"); + expect(result.context).toBe(""); + hangServer.close(); + }); +}); diff --git a/claude-code-plugin/tests/hook.test.ts b/claude-code-plugin/tests/hook.test.ts new file mode 100644 index 0000000..951f51c --- /dev/null +++ b/claude-code-plugin/tests/hook.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from "vitest"; +import { handleHook } from "../lib/hook.js"; +import type { GatewayClient, RecallResult } from "../lib/gateway-client.js"; + +function makeFakeClient(overrides: Partial = {}): GatewayClient { + return { + health: vi.fn(async () => true), + recall: vi.fn(async (): Promise => ({ context: "recalled" })), + captureTurn: vi.fn(async () => ({ l0_recorded: 1, scheduler_notified: true })), + searchMemories: vi.fn(async () => ({ results: "m", total: 1 })), + searchConversations: vi.fn(async () => ({ results: "c", total: 1 })), + sessionEnd: vi.fn(async () => {}), + ...overrides, + } as unknown as GatewayClient; +} + +describe("handleHook: user-prompt-submit", () => { + it("emits hookSpecificOutput with additionalContext from /recall", async () => { + const client = makeFakeClient(); + const stdin = JSON.stringify({ + session_id: "s1", + cwd: "/tmp/proj", + prompt: "what did we do?", + }); + const out = await handleHook("user-prompt-submit", { stdin, client }); + const parsed = JSON.parse(out); + expect(parsed.hookSpecificOutput.hookEventName).toBe("UserPromptSubmit"); + expect(parsed.hookSpecificOutput.additionalContext).toBe("recalled"); + }); + + it("truncates additionalContext over 10000 chars", async () => { + const big = "x".repeat(20_000); + const client = makeFakeClient({ + recall: vi.fn(async () => ({ context: big })), + } as Partial); + const stdin = JSON.stringify({ session_id: "s", cwd: "/tmp/p", prompt: "q" }); + const out = await handleHook("user-prompt-submit", { stdin, client }); + const parsed = JSON.parse(out); + expect(parsed.hookSpecificOutput.additionalContext.length).toBeLessThanOrEqual(10_000); + expect(parsed.hookSpecificOutput.additionalContext).toContain("truncated"); + }); + + it("emits empty string when recall returns no context", async () => { + const client = makeFakeClient({ + recall: vi.fn(async () => ({ context: "" })), + } as Partial); + const stdin = JSON.stringify({ session_id: "s", cwd: "/tmp/p", prompt: "q" }); + const out = await handleHook("user-prompt-submit", { stdin, client }); + expect(out).toBe(""); + }); +}); + +describe("handleHook: stop", () => { + it("exits silently when stop_hook_active is true", async () => { + const captureTurn = vi.fn(); + const client = makeFakeClient({ + captureTurn, + } as Partial); + const stdin = JSON.stringify({ + session_id: "s", + transcript_path: "/tmp/t.jsonl", + stop_hook_active: true, + }); + const out = await handleHook("stop", { stdin, client }); + expect(out).toBe(""); + expect(captureTurn).not.toHaveBeenCalled(); + }); + + it("calls captureTurn when stop_hook_active is false", async () => { + const captureTurn = vi.fn(async () => null); + const client = makeFakeClient({ + captureTurn, + } as Partial); + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const os = await import("node:os"); + const tmp = path.join(os.tmpdir(), `tx-${Date.now()}.jsonl`); + await fs.writeFile( + tmp, + [ + '{"type":"user","message":{"role":"user","content":"q"},"uuid":"u"}', + '{"type":"assistant","message":{"role":"assistant","content":"a"},"uuid":"a"}', + ].join("\n"), + ); + try { + const stdin = JSON.stringify({ + session_id: "s", + transcript_path: tmp, + cwd: "/tmp/proj", + stop_hook_active: false, + }); + await handleHook("stop", { stdin, client }); + expect(captureTurn).toHaveBeenCalledOnce(); + const call = captureTurn.mock.calls[0][0]; + expect(call.user_content).toBe("q"); + expect(call.assistant_content).toBe("a"); + } finally { + await fs.unlink(tmp); + } + }); +}); + +describe("handleHook: post-tool-use", () => { + it("fire-and-forget — does not throw on success", async () => { + const client = makeFakeClient(); + const stdin = JSON.stringify({ + session_id: "s", + tool_name: "Read", + tool_use_id: "t1", + }); + await expect( + handleHook("post-tool-use", { stdin, client }), + ).resolves.not.toThrow(); + }); +}); + +describe("handleHook: session-start", () => { + it("invokes health probe, succeeds silently", async () => { + const client = makeFakeClient(); + const stdin = JSON.stringify({ session_id: "s", cwd: "/tmp/p", source: "startup" }); + await expect( + handleHook("session-start", { stdin, client }), + ).resolves.not.toThrow(); + }); +}); + +describe("handleHook: search (slash command)", () => { + it("returns formatted memory search output", async () => { + const client = makeFakeClient({ + searchMemories: vi.fn(async () => ({ results: "MEMORY_RESULTS", total: 3 })), + } as Partial); + const out = await handleHook("search", { stdin: "", client, args: ["my", "query"] }); + expect(out).toContain("MEMORY_RESULTS"); + }); +}); + +describe("handleHook: invalid event", () => { + it("returns empty string on unknown event", async () => { + const client = makeFakeClient(); + const out = await handleHook("nonsense" as never, { + stdin: "{}", + client, + }); + expect(out).toBe(""); + }); +}); diff --git a/claude-code-plugin/tests/session-key.test.ts b/claude-code-plugin/tests/session-key.test.ts new file mode 100644 index 0000000..bcffca5 --- /dev/null +++ b/claude-code-plugin/tests/session-key.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { getSessionKey } from "../lib/session-key.js"; + +describe("getSessionKey", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("derives a 16-char hex key from cwd by default", () => { + const key = getSessionKey("/Users/alice/projects/foo"); + expect(key).toMatch(/^[0-9a-f]{16}$/); + }); + + it("returns the same key for the same cwd", () => { + const k1 = getSessionKey("/Users/alice/projects/foo"); + const k2 = getSessionKey("/Users/alice/projects/foo"); + expect(k1).toBe(k2); + }); + + it("returns different keys for different cwd", () => { + const k1 = getSessionKey("/Users/alice/projects/foo"); + const k2 = getSessionKey("/Users/alice/projects/bar"); + expect(k1).not.toBe(k2); + }); + + it("normalizes the path (foo/./bar === foo/bar)", () => { + const k1 = getSessionKey("/Users/alice/projects/foo"); + const k2 = getSessionKey("/Users/alice/projects/./foo"); + expect(k1).toBe(k2); + }); + + it("normalizes trailing slashes", () => { + const k1 = getSessionKey("/Users/alice/projects/foo"); + const k2 = getSessionKey("/Users/alice/projects/foo/"); + expect(k1).toBe(k2); + }); + + it("honors TDAI_SESSION_KEY env override", () => { + vi.stubEnv("TDAI_SESSION_KEY", "custom-key-42"); + const key = getSessionKey("/whatever"); + expect(key).toBe("custom-key-42"); + }); + + it("empty TDAI_SESSION_KEY falls back to cwd hash", () => { + vi.stubEnv("TDAI_SESSION_KEY", ""); + const key = getSessionKey("/Users/alice/projects/foo"); + expect(key).toMatch(/^[0-9a-f]{16}$/); + }); +}); diff --git a/claude-code-plugin/tests/smoke.e2e.test.ts b/claude-code-plugin/tests/smoke.e2e.test.ts new file mode 100644 index 0000000..6d6c38c --- /dev/null +++ b/claude-code-plugin/tests/smoke.e2e.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TdaiGateway } from "../../src/gateway/server.js"; +import { GatewayClient } from "../lib/gateway-client.js"; + +describe("cc-plugin smoke e2e (in-process gateway)", () => { + let dataDir: string; + let gateway: TdaiGateway; + const PORT = 19421; + const TOKEN = "smoke-e2e-token-" + Math.random().toString(36).slice(2); + + beforeAll(async () => { + dataDir = await mkdtemp(join(tmpdir(), "tdai-smoke-")); + vi.stubEnv("TDAI_GATEWAY_TOKEN", TOKEN); + vi.stubEnv("TDAI_DATA_DIR", dataDir); + // Re-stub TDAI_GATEWAY_TOKEN inside each test as well (vitest unstubEnvs: true). + gateway = new TdaiGateway({ + server: { port: PORT, host: "127.0.0.1" }, + data: { baseDir: dataDir }, + } as never); + await gateway.start(); + }, 60_000); + + afterAll(async () => { + if (gateway) await gateway.stop(); + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + }); + + it("rejects unauthenticated /health", async () => { + vi.stubEnv("TDAI_GATEWAY_TOKEN", TOKEN); + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${PORT}`, + token: "wrong-token", + timeoutMs: 5_000, + }); + const ok = await client.health(); + expect(ok).toBe(false); + }); + + it("accepts authenticated /health", async () => { + vi.stubEnv("TDAI_GATEWAY_TOKEN", TOKEN); + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${PORT}`, + token: TOKEN, + timeoutMs: 5_000, + }); + const ok = await client.health(); + expect(ok).toBe(true); + }); + + it("captures a turn end-to-end (L0 written)", async () => { + vi.stubEnv("TDAI_GATEWAY_TOKEN", TOKEN); + const client = new GatewayClient({ + baseUrl: `http://127.0.0.1:${PORT}`, + token: TOKEN, + timeoutMs: 30_000, + }); + const result = await client.captureTurn({ + user_content: "smoke test user message", + assistant_content: "smoke test assistant response", + session_key: "smoke-key-1", + session_id: "smoke-session-1", + }); + expect(result).not.toBeNull(); + expect(result!.l0_recorded).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/claude-code-plugin/tests/transcript.test.ts b/claude-code-plugin/tests/transcript.test.ts new file mode 100644 index 0000000..dd15ded --- /dev/null +++ b/claude-code-plugin/tests/transcript.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { readLatestTurn, parseTranscriptLine } from "../lib/transcript.js"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = resolve(__dirname, "fixtures/transcript-sample.jsonl"); + +describe("parseTranscriptLine", () => { + it("parses a user message", () => { + const line = '{"type":"user","message":{"role":"user","content":"hi"},"uuid":"u1"}'; + const parsed = parseTranscriptLine(line); + expect(parsed).toEqual({ + type: "user", + role: "user", + content: "hi", + uuid: "u1", + }); + }); + + it("parses an assistant message", () => { + const line = '{"type":"assistant","message":{"role":"assistant","content":"hello"},"uuid":"a1"}'; + const parsed = parseTranscriptLine(line); + expect(parsed?.role).toBe("assistant"); + expect(parsed?.content).toBe("hello"); + }); + + it("returns null for malformed JSON", () => { + expect(parseTranscriptLine("{ not json }")).toBeNull(); + }); + + it("returns null for messages without content", () => { + const line = '{"type":"user","message":{"role":"user"},"uuid":"u1"}'; + expect(parseTranscriptLine(line)).toBeNull(); + }); + + it("handles content array (multi-part messages) by joining strings", () => { + const line = '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]},"uuid":"u1"}'; + const parsed = parseTranscriptLine(line); + expect(parsed?.content).toBe("hello\nworld"); + }); +}); + +describe("readLatestTurn", () => { + it("returns the most recent user/assistant pair", async () => { + const turn = await readLatestTurn(FIXTURE); + expect(turn).not.toBeNull(); + expect(turn!.user).toBe("second question"); + expect(turn!.assistant).toBe("second answer"); + }); + + it("returns null for a missing file", async () => { + const turn = await readLatestTurn("/tmp/nonexistent-transcript-tdai.jsonl"); + expect(turn).toBeNull(); + }); + + it("returns null for an empty file", async () => { + const tmpPath = resolve(__dirname, "fixtures/empty.jsonl"); + const fs = await import("node:fs/promises"); + await fs.writeFile(tmpPath, ""); + try { + const turn = await readLatestTurn(tmpPath); + expect(turn).toBeNull(); + } finally { + await fs.unlink(tmpPath); + } + }); +}); diff --git a/claude-code-plugin/tsconfig.json b/claude-code-plugin/tsconfig.json new file mode 100644 index 0000000..49f4459 --- /dev/null +++ b/claude-code-plugin/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./", + "declaration": false, + "noEmitOnError": true, + "types": ["node"] + }, + "include": ["lib/**/*.ts", "tests/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/claude-code-plugin/tsdown.config.ts b/claude-code-plugin/tsdown.config.ts new file mode 100644 index 0000000..8b2983f --- /dev/null +++ b/claude-code-plugin/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./lib/hook.ts", "./lib/gateway-entry.ts"], + outDir: "./dist/lib", + format: "esm", + platform: "node", + clean: true, + fixedExtension: true, + dts: false, + sourcemap: false, + // Bundle internal lib files; do not bundle node builtins. + deps: { + neverBundle: (id) => id.startsWith("node:"), + }, +}); diff --git a/claude-code-plugin/vitest.e2e.config.ts b/claude-code-plugin/vitest.e2e.config.ts new file mode 100644 index 0000000..92a66f1 --- /dev/null +++ b/claude-code-plugin/vitest.e2e.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + pool: "forks", + include: ["claude-code-plugin/tests/**/*.e2e.test.ts"], + testTimeout: 120_000, + hookTimeout: 120_000, + }, +}); diff --git a/package.json b/package.json index 2d74158..46d7ac0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "build:cc-plugin": "tsdown -c claude-code-plugin/tsdown.config.ts", + "test:cc-plugin": "vitest run claude-code-plugin/tests/", + "build:all": "npm run build && npm run build:cc-plugin", + "test:cc-plugin:e2e": "vitest run -c claude-code-plugin/vitest.e2e.config.ts", "postinstall": "bash scripts/openclaw-after-tool-call-messages.patch.sh 2>/dev/null || true" }, "files": [ @@ -45,6 +49,12 @@ "scripts/openclaw-after-tool-call-messages.patch.sh", "scripts/setup-offload.sh", "hermes-plugin/", + "claude-code-plugin/plugin.json", + "claude-code-plugin/hooks/", + "claude-code-plugin/skills/", + "claude-code-plugin/dist/", + "claude-code-plugin/README.md", + "claude-code-plugin/README_CN.md", "openclaw.plugin.json", "README.md", "CHANGELOG.md", diff --git a/vitest.config.ts b/vitest.config.ts index 1f9ce29..96d8f64 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,11 @@ export default defineConfig({ test: { environment: "node", pool: "forks", - include: ["src/**/*.test.ts", "__tests__/**/*.test.ts"], + include: [ + "src/**/*.test.ts", + "__tests__/**/*.test.ts", + "claude-code-plugin/tests/**/*.test.ts", + ], exclude: ["dist/**", "node_modules/**", "**/*.e2e.test.ts"], testTimeout: 120_000, hookTimeout: 120_000, @@ -15,7 +19,7 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "html", "lcov"], - include: ["src/**/*.ts", "index.ts"], + include: ["src/**/*.ts", "index.ts", "claude-code-plugin/lib/**/*.ts"], exclude: [ "src/**/*.test.ts", "dist/**", From 517a377aff4e881d7e03a4e311da9ebb7b3eac12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 10:39:57 +0800 Subject: [PATCH 03/17] docs: changelog entry for Claude Code plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the cc plugin addition and the gateway optional Bearer token authentication addition. Signed-off-by: 李冠辰 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f2d346..cb0d80b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ --- +## [Unreleased] + +### 📦 新功能 + +- **Claude Code 插件**(`claude-code-plugin/`):通过 cc 官方 `/plugin install tdai-memory` 一键启用,不修改用户 `~/.claude/settings.json`。提供 3 个 hooks(`SessionStart` 异步预热、`UserPromptSubmit` 同步召回并通过 `additionalContext` 注入、`Stop` 异步捕获),3 个 slash skills(`/memory-search`、`/memory-status`、`/memory-clear-session`),以及一个总览 skill `tdai-memory`。Daemon 通过 `gateway-entry.ts` wrapper 绑定父 cc 进程生命周期。 + +### 🔧 兼容性 / 安全增强 + +- **Gateway 可选 Bearer Token 鉴权**:当设置 `TDAI_GATEWAY_TOKEN` 环境变量时,Gateway 要求所有非 OPTIONS 请求带 `Authorization: Bearer `。未设置时行为不变,与 Hermes 完全向后兼容。Claude Code 插件每次 spawn daemon 时生成随机 256-bit token 写入权限 0600 文件。 + +### 📚 文档 + +- `claude-code-plugin/README.md` 与 `README_CN.md`:安装、配置、数据布局、排障与安全模型完整说明。 + +--- + ## [0.3.4] - 2026-05-12 ### 🐛 修复 From 30e4b4e227c5688ba9da5b5fd727c143b65fad89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 11:27:34 +0800 Subject: [PATCH 04/17] feat(plugin): add Codex CLI compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Claude Code (v2026.4+) and OpenAI Codex CLI (v0.117+) implement the same hook protocol (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop) and read the same hooks.json schema. The plugin gains a parallel .codex-plugin/plugin.json manifest with Codex marketplace metadata (interface block: displayName, brandColor, capabilities, defaultPrompt). All other artifacts -- hooks/hooks.json, skills/*, lib/*, dist/* -- are shared verbatim between the two hosts. README EN/CN and CHANGELOG updated to reflect dual-host support. Signed-off-by: 李冠辰 --- CHANGELOG.md | 2 +- claude-code-plugin/.codex-plugin/plugin.json | 40 ++++++++++++++++++++ claude-code-plugin/README.md | 21 ++++++++-- claude-code-plugin/README_CN.md | 21 ++++++++-- 4 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 claude-code-plugin/.codex-plugin/plugin.json diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0d80b..3bd0d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### 📦 新功能 -- **Claude Code 插件**(`claude-code-plugin/`):通过 cc 官方 `/plugin install tdai-memory` 一键启用,不修改用户 `~/.claude/settings.json`。提供 3 个 hooks(`SessionStart` 异步预热、`UserPromptSubmit` 同步召回并通过 `additionalContext` 注入、`Stop` 异步捕获),3 个 slash skills(`/memory-search`、`/memory-status`、`/memory-clear-session`),以及一个总览 skill `tdai-memory`。Daemon 通过 `gateway-entry.ts` wrapper 绑定父 cc 进程生命周期。 +- **Claude Code + Codex CLI 插件**(`claude-code-plugin/`):通过 Claude Code `/plugin install tdai-memory` 或 Codex CLI marketplace 一键启用,不修改用户 `~/.claude/settings.json` 或 `~/.codex/config.toml`。提供 3 个 hooks(`SessionStart` 异步预热、`UserPromptSubmit` 同步召回并通过 `additionalContext` 注入、`Stop` 异步捕获),3 个 slash skills(`/memory-search`、`/memory-status`、`/memory-clear-session`),以及一个总览 skill `tdai-memory`。Daemon 通过 `gateway-entry.ts` wrapper 绑定父进程生命周期。插件携带双 manifest(`.claude-plugin/plugin.json` 与 `.codex-plugin/plugin.json`),共享同一份 `hooks/hooks.json` 与 `skills/` —— cc v2026.4+ 与 Codex CLI v0.117+ 实现了对齐的 hook 协议,因此一套源码同时服务两个宿主。 ### 🔧 兼容性 / 安全增强 diff --git a/claude-code-plugin/.codex-plugin/plugin.json b/claude-code-plugin/.codex-plugin/plugin.json new file mode 100644 index 0000000..633ce0b --- /dev/null +++ b/claude-code-plugin/.codex-plugin/plugin.json @@ -0,0 +1,40 @@ +{ + "name": "tdai-memory", + "version": "0.1.0", + "description": "Long-term + symbolic short-term memory for AI coding agents, powered by TencentDB Agent Memory.", + "author": { + "name": "李冠辰", + "email": "liguanchen@xiaomi.com" + }, + "homepage": "https://github.com/Tencent/TencentDB-Agent-Memory", + "repository": "https://github.com/Tencent/TencentDB-Agent-Memory", + "license": "MIT", + "keywords": [ + "memory", + "long-term-memory", + "short-term-memory", + "ai-memory", + "vector-search", + "sqlite-vec", + "persona", + "scene-extraction" + ], + "skills": "./skills/", + "interface": { + "displayName": "TDAI Memory", + "shortDescription": "Long-term + short-term memory for AI coding agents", + "longDescription": "Adds long-term memory and symbolic short-term memory to Codex CLI: automatic recall before every prompt (relevant past memories injected via additionalContext), automatic capture after every turn (L0 conversation written, L1/L2/L3 atoms/scenarios/persona extracted in the background), and manual control via skills (memory-search, memory-status, memory-clear-session). Memory is partitioned per project (hash of cwd) by default. The daemon runs locally on 127.0.0.1 with a Bearer-token-protected HTTP API (file mode 0600).", + "developerName": "TencentDB Agent Memory contributors", + "category": "Productivity", + "capabilities": [ + "Read", + "Write" + ], + "brandColor": "#3B82F6", + "defaultPrompt": [ + "Do you remember what we discussed about this project?", + "Search my memory for the migration plan we made last week", + "What were my preferences for the API design?" + ] + } +} diff --git a/claude-code-plugin/README.md b/claude-code-plugin/README.md index 7374f9c..f40d2d9 100644 --- a/claude-code-plugin/README.md +++ b/claude-code-plugin/README.md @@ -1,6 +1,8 @@ -# TencentDB Agent Memory — Claude Code Plugin +# TencentDB Agent Memory — Coding Agent Plugin -Long-term + symbolic short-term memory for [Claude Code](https://claude.com/claude-code), powered by [TencentDB Agent Memory](https://github.com/Tencent/TencentDB-Agent-Memory). +Long-term + symbolic short-term memory for [Claude Code](https://claude.com/claude-code) and [OpenAI Codex CLI](https://developers.openai.com/codex/cli), powered by [TencentDB Agent Memory](https://github.com/Tencent/TencentDB-Agent-Memory). + +The plugin ships dual manifests (`.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`) and reuses the same `hooks/hooks.json` and `skills/` — both Claude Code (v2026.4+) and Codex CLI (v0.117+) implement the same hook protocol, so a single source tree serves both hosts. [中文版](./README_CN.md) @@ -14,13 +16,24 @@ Long-term + symbolic short-term memory for [Claude Code](https://claude.com/clau ## Installation +### Claude Code + ```bash /plugin install tdai-memory ``` -That's it. No `~/.claude/settings.json` edits, no global config to track. +### Codex CLI + +```bash +codex plugin marketplace add +# then enable in the TUI: /plugin → toggle tdai-memory +``` + +(Once published to the Codex marketplace, this becomes a one-liner.) + +--- -The first time cc starts a session after installation, the plugin will spawn a local daemon (the existing TDAI Gateway) on port 8421–8430 with a randomly generated Bearer token. State persists under `${CLAUDE_PLUGIN_DATA}`. +No `~/.claude/settings.json` or `~/.codex/config.toml` mutation. The first time a session starts after installation, the plugin spawns a local daemon (the existing TDAI Gateway) on port 8421–8430 with a randomly generated Bearer token. State persists under `${CLAUDE_PLUGIN_DATA}`. ## Configuration diff --git a/claude-code-plugin/README_CN.md b/claude-code-plugin/README_CN.md index 774735f..cdaa2b2 100644 --- a/claude-code-plugin/README_CN.md +++ b/claude-code-plugin/README_CN.md @@ -1,6 +1,8 @@ -# TencentDB Agent Memory — Claude Code 插件 +# TencentDB Agent Memory — Coding Agent 插件 -为 [Claude Code](https://claude.com/claude-code) 提供长期记忆 + 符号化短期记忆,由 [TencentDB Agent Memory](https://github.com/Tencent/TencentDB-Agent-Memory) 驱动。 +为 [Claude Code](https://claude.com/claude-code) 与 [OpenAI Codex CLI](https://developers.openai.com/codex/cli) 提供长期记忆 + 符号化短期记忆,由 [TencentDB Agent Memory](https://github.com/Tencent/TencentDB-Agent-Memory) 驱动。 + +插件携带双 manifest(`.claude-plugin/plugin.json` 与 `.codex-plugin/plugin.json`),共享同一份 `hooks/hooks.json` 与 `skills/`。Claude Code(v2026.4+)与 Codex CLI(v0.117+)实现了同一份 hook 协议,因此一套源码同时服务两个宿主。 [English version](./README.md) @@ -14,13 +16,24 @@ ## 安装 +### Claude Code + ```bash /plugin install tdai-memory ``` -就这样。不需要改 `~/.claude/settings.json`,也没有全局配置需要维护。 +### Codex CLI + +```bash +codex plugin marketplace add +# 在 TUI 中启用:/plugin → 切换 tdai-memory +``` + +(一旦发布到 Codex marketplace,将变为一条命令安装。) + +--- -第一次启动 cc 会话时,插件会在 8421–8430 端口拉起一个本地 daemon(即现有的 TDAI Gateway),并生成随机 Bearer token。状态保存在 `${CLAUDE_PLUGIN_DATA}`。 +不需要改 `~/.claude/settings.json` 或 `~/.codex/config.toml`。第一次启动 session 时,插件会在 8421–8430 端口拉起一个本地 daemon(即现有的 TDAI Gateway),并生成随机 Bearer token。状态保存在 `${CLAUDE_PLUGIN_DATA}`。 ## 配置 From 2e4405ae16cc4e9ed8ed6ef2174621161f8a442a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 12:52:48 +0800 Subject: [PATCH 05/17] fix(plugin): use object author in cc manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's plugin loader strictly validates author to be an object with {name, email}. The shorthand string form was rejected with 'Invalid input: expected object, received string'. Align .claude-plugin/plugin.json with .codex-plugin/plugin.json which already used the object form. Discovered via 'claude plugin install tdai-memory@tdai-memory-dev' real-machine test. Signed-off-by: 李冠辰 --- claude-code-plugin/.claude-plugin/plugin.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/claude-code-plugin/.claude-plugin/plugin.json b/claude-code-plugin/.claude-plugin/plugin.json index 71c8cf5..adb5a4d 100644 --- a/claude-code-plugin/.claude-plugin/plugin.json +++ b/claude-code-plugin/.claude-plugin/plugin.json @@ -4,5 +4,8 @@ "description": "Long-term + symbolic short-term memory for Claude Code, powered by TencentDB Agent Memory.", "homepage": "https://github.com/Tencent/TencentDB-Agent-Memory", "license": "MIT", - "author": "李冠辰 " + "author": { + "name": "李冠辰", + "email": "liguanchen@xiaomi.com" + } } From ff0a5d51feeefb365ea2e7973af1c2a106949ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 13:12:34 +0800 Subject: [PATCH 06/17] refactor(plugin): spawn Gateway via npx tdai-memory-gateway bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cc and Codex plugin install copies only the plugin tree -- there is no node_modules with the plugin, so the previous gateway-entry.ts that imported `undici`, `yaml`, `@ai-sdk/openai` etc. failed with ERR_MODULE_NOT_FOUND on a real plugin install (despite all unit and e2e tests passing in-repo where node_modules was available). Move the Gateway entry into the npm package itself: - New src/gateway/cli.ts exposed as bin 'tdai-memory-gateway' (resolves to ./dist/src/gateway/cli.mjs after build) - Owns SIGTERM/SIGINT handling and optional TDAI_CC_PID liveness probe (60s interval, 5s graceful shutdown budget) - Plugin claude-code-plugin/lib/gateway-entry.ts deleted - Plugin lib/daemon.ts spawn now invokes 'npx tdai-memory-gateway' (TDAI_GATEWAY_COMMAND env var available to override for dev) - Plugin lib/hook.ts main() drops the CLAUDE_PLUGIN_ROOT-based gateway path resolution (no longer needed) - Plugin tsdown.config only bundles hook.ts (no npm deps) - Root tsdown.config adds src/gateway/cli.ts as an entry - package.json bin: tdai-memory-gateway -> ./dist/src/gateway/cli.mjs - package.json files: include .claude-plugin/ and .codex-plugin/ - README EN/CN: add prerequisite 'npm install -g @tencentdb-agent-memory/memory-tencentdb' and document the new TDAI_GATEWAY_COMMAND override - CHANGELOG entry for the new bin under '兼容性 / 安全增强' Real-machine verified: cc plugin install + reinstall, hook fires, SessionStart spawns daemon via 'npx tdai-memory-gateway', /health returns 200 with Bearer auth. Signed-off-by: 李冠辰 --- CHANGELOG.md | 1 + claude-code-plugin/README.md | 14 ++++++- claude-code-plugin/README_CN.md | 14 ++++++- claude-code-plugin/lib/daemon.ts | 40 +++++++++++-------- claude-code-plugin/lib/hook.ts | 14 ++----- claude-code-plugin/tsdown.config.ts | 6 ++- package.json | 6 ++- .../gateway-entry.ts => src/gateway/cli.ts | 27 ++++++++----- tsdown.config.ts | 2 +- 9 files changed, 79 insertions(+), 45 deletions(-) rename claude-code-plugin/lib/gateway-entry.ts => src/gateway/cli.ts (53%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd0d21..940768c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### 🔧 兼容性 / 安全增强 - **Gateway 可选 Bearer Token 鉴权**:当设置 `TDAI_GATEWAY_TOKEN` 环境变量时,Gateway 要求所有非 OPTIONS 请求带 `Authorization: Bearer `。未设置时行为不变,与 Hermes 完全向后兼容。Claude Code 插件每次 spawn daemon 时生成随机 256-bit token 写入权限 0600 文件。 +- **新增 `tdai-memory-gateway` bin**(`./dist/src/gateway/cli.mjs`):作为独立可执行 Gateway entry point,支持 `SIGTERM/SIGINT` 优雅关闭、可选父进程 PID liveness 探活(`TDAI_CC_PID` 环境变量)。供 Claude Code / Codex CLI 插件通过 `npx tdai-memory-gateway` 调用,无需把 npm 依赖打包进插件。 ### 📚 文档 diff --git a/claude-code-plugin/README.md b/claude-code-plugin/README.md index f40d2d9..ae154d9 100644 --- a/claude-code-plugin/README.md +++ b/claude-code-plugin/README.md @@ -16,6 +16,16 @@ The plugin ships dual manifests (`.claude-plugin/plugin.json` and `.codex-plugin ## Installation +### Prerequisite + +Install the gateway runtime (the `tdai-memory-gateway` bin) globally — the plugin spawns the daemon via `npx tdai-memory-gateway`: + +```bash +npm install -g @tencentdb-agent-memory/memory-tencentdb +``` + +This npm package contains the actual `TdaiGateway` (SQLite + sqlite-vec + LLM pipeline). The plugin itself is a thin shim that owns hooks, skills, and the per-session sessionKey — it does NOT bundle the heavy deps. + ### Claude Code ```bash @@ -33,7 +43,7 @@ codex plugin marketplace add --- -No `~/.claude/settings.json` or `~/.codex/config.toml` mutation. The first time a session starts after installation, the plugin spawns a local daemon (the existing TDAI Gateway) on port 8421–8430 with a randomly generated Bearer token. State persists under `${CLAUDE_PLUGIN_DATA}`. +No `~/.claude/settings.json` or `~/.codex/config.toml` mutation. The first time a session starts after installation, the plugin spawns the local daemon (via `npx tdai-memory-gateway`) on port 8421–8430 with a randomly generated Bearer token. State persists under `${CLAUDE_PLUGIN_DATA}`. ## Configuration @@ -43,7 +53,7 @@ The plugin reads three optional environment variables: |---|---|---| | `TDAI_SESSION_KEY` | `hash(cwd)` | Override the per-project memory partition | | `TDAI_GATEWAY_TOKEN` | auto-generated | Bearer token for daemon ↔ hook IPC | -| `TDAI_GATEWAY_ENTRY` | resolved from plugin | Path to the Gateway entry script | +| `TDAI_GATEWAY_COMMAND` | `npx` | Override daemon spawn command (advanced; e.g. `node /path/to/cli.mjs` for development) | Most users never need to set any of these. `TDAI_SESSION_KEY=shared-with-other-project` is the most common power-user override. diff --git a/claude-code-plugin/README_CN.md b/claude-code-plugin/README_CN.md index cdaa2b2..5a57184 100644 --- a/claude-code-plugin/README_CN.md +++ b/claude-code-plugin/README_CN.md @@ -16,6 +16,16 @@ ## 安装 +### 前置条件 + +先全局安装 gateway 运行时(提供 `tdai-memory-gateway` 命令)—— 插件通过 `npx tdai-memory-gateway` 启动 daemon: + +```bash +npm install -g @tencentdb-agent-memory/memory-tencentdb +``` + +该 npm 包含真正的 `TdaiGateway`(SQLite + sqlite-vec + LLM pipeline)。插件本身只是一层薄壳,提供 hook、skill 和 sessionKey 等绑定逻辑,不携带任何重型依赖。 + ### Claude Code ```bash @@ -33,7 +43,7 @@ codex plugin marketplace add --- -不需要改 `~/.claude/settings.json` 或 `~/.codex/config.toml`。第一次启动 session 时,插件会在 8421–8430 端口拉起一个本地 daemon(即现有的 TDAI Gateway),并生成随机 Bearer token。状态保存在 `${CLAUDE_PLUGIN_DATA}`。 +不需要改 `~/.claude/settings.json` 或 `~/.codex/config.toml`。第一次启动 session 时,插件通过 `npx tdai-memory-gateway` 在 8421–8430 端口拉起 daemon,并生成随机 Bearer token。状态保存在 `${CLAUDE_PLUGIN_DATA}`。 ## 配置 @@ -43,7 +53,7 @@ codex plugin marketplace add |---|---|---| | `TDAI_SESSION_KEY` | `hash(cwd)` | 覆盖项目级记忆分区 | | `TDAI_GATEWAY_TOKEN` | 自动生成 | daemon ↔ hook IPC 的 Bearer token | -| `TDAI_GATEWAY_ENTRY` | 自动解析 | Gateway 入口脚本路径 | +| `TDAI_GATEWAY_COMMAND` | `npx` | 覆盖 daemon 启动命令(高级用法;如 `node /path/to/cli.mjs` 用于本地开发) | 大多数用户都不需要设置任何变量。`TDAI_SESSION_KEY=shared-with-other-project` 是最常用的高级用法。 diff --git a/claude-code-plugin/lib/daemon.ts b/claude-code-plugin/lib/daemon.ts index 9ca498d..684f906 100644 --- a/claude-code-plugin/lib/daemon.ts +++ b/claude-code-plugin/lib/daemon.ts @@ -139,7 +139,7 @@ export class DaemonManager { }); } - async ensureRunning(ccPid: number, gatewayEntry: string): Promise { + async ensureRunning(ccPid: number): Promise { const existing = await readDaemonState(this.dataDir); if (existing) { let existingToken = ""; @@ -152,28 +152,36 @@ export class DaemonManager { return existing; } } - return this.spawn(ccPid, gatewayEntry); + return this.spawn(ccPid); } - async spawn(ccPid: number, gatewayEntry: string): Promise { + /** + * Spawn the Gateway daemon by invoking `npx tdai-memory-gateway`. + * + * The user must have `@tencentdb-agent-memory/memory-tencentdb` installed, + * either globally (`npm install -g`) or in the current project (which exposes + * the `tdai-memory-gateway` bin via npx's PATH resolution). + */ + async spawn(ccPid: number): Promise { const port = await this.findFreePort(); const tokenPath = await this.generateToken(); const token = await this.readToken(tokenPath); - const child: ChildProcess = spawn( - process.execPath, - [gatewayEntry], - { - env: { - ...process.env, - TDAI_GATEWAY_TOKEN: token, - TDAI_GATEWAY_PORT: String(port), - TDAI_CC_PID: String(ccPid), - }, - detached: true, - stdio: ["ignore", "ignore", "ignore"], + const command = process.env.TDAI_GATEWAY_COMMAND ?? "npx"; + const args = process.env.TDAI_GATEWAY_COMMAND + ? [] + : ["--yes", "tdai-memory-gateway"]; + + const child: ChildProcess = spawn(command, args, { + env: { + ...process.env, + TDAI_GATEWAY_TOKEN: token, + TDAI_GATEWAY_PORT: String(port), + TDAI_CC_PID: String(ccPid), }, - ); + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); child.unref(); if (!child.pid) { diff --git a/claude-code-plugin/lib/hook.ts b/claude-code-plugin/lib/hook.ts index e99f7b7..d7d4852 100644 --- a/claude-code-plugin/lib/hook.ts +++ b/claude-code-plugin/lib/hook.ts @@ -170,16 +170,10 @@ async function main(): Promise { let state = await readDaemonState(dataDir); if (event === "session-start" && !state) { - const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? ""; - const gatewayEntry = - process.env.TDAI_GATEWAY_ENTRY ?? - (pluginRoot ? join(pluginRoot, "dist/lib/gateway-entry.mjs") : ""); - if (gatewayEntry) { - try { - state = await mgr.ensureRunning(process.ppid, gatewayEntry); - } catch (err) { - await safeLog(logPath, `session-start: spawn failed: ${(err as Error).message}`); - } + try { + state = await mgr.ensureRunning(process.ppid); + } catch (err) { + await safeLog(logPath, `session-start: spawn failed: ${(err as Error).message}`); } } diff --git a/claude-code-plugin/tsdown.config.ts b/claude-code-plugin/tsdown.config.ts index 8b2983f..2fe228b 100644 --- a/claude-code-plugin/tsdown.config.ts +++ b/claude-code-plugin/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["./lib/hook.ts", "./lib/gateway-entry.ts"], + entry: ["./lib/hook.ts"], outDir: "./dist/lib", format: "esm", platform: "node", @@ -9,7 +9,9 @@ export default defineConfig({ fixedExtension: true, dts: false, sourcemap: false, - // Bundle internal lib files; do not bundle node builtins. + // Plugin only bundles its own hook entry (no npm deps in hook.ts). + // The actual Gateway daemon is spawned via `npx tdai-memory-gateway` + // from the user's globally installed @tencentdb-agent-memory/memory-tencentdb. deps: { neverBundle: (id) => id.startsWith("node:"), }, diff --git a/package.json b/package.json index 46d7ac0..6885453 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "bin": { "migrate-sqlite-to-tcvdb": "./bin/migrate-sqlite-to-tcvdb.mjs", "export-tencent-vdb": "./bin/export-tencent-vdb.mjs", - "read-local-memory": "./bin/read-local-memory.mjs" + "read-local-memory": "./bin/read-local-memory.mjs", + "tdai-memory-gateway": "./dist/src/gateway/cli.mjs" }, "exports": { ".": { @@ -49,7 +50,8 @@ "scripts/openclaw-after-tool-call-messages.patch.sh", "scripts/setup-offload.sh", "hermes-plugin/", - "claude-code-plugin/plugin.json", + "claude-code-plugin/.claude-plugin/", + "claude-code-plugin/.codex-plugin/", "claude-code-plugin/hooks/", "claude-code-plugin/skills/", "claude-code-plugin/dist/", diff --git a/claude-code-plugin/lib/gateway-entry.ts b/src/gateway/cli.ts similarity index 53% rename from claude-code-plugin/lib/gateway-entry.ts rename to src/gateway/cli.ts index 3c34dc2..c88cc28 100644 --- a/claude-code-plugin/lib/gateway-entry.ts +++ b/src/gateway/cli.ts @@ -1,15 +1,22 @@ +#!/usr/bin/env node /** - * Gateway entry script — wraps TdaiGateway with parent-process liveness - * binding for the Claude Code plugin daemon lifecycle. + * `tdai-memory-gateway` — standalone Gateway daemon entry. * - * Spawned by lib/daemon.ts with env: - * TDAI_GATEWAY_TOKEN — Bearer token (required by gateway middleware) - * TDAI_GATEWAY_PORT — Port to bind - * TDAI_DATA_DIR — Data root - * TDAI_CC_PID — Parent cc process pid; we self-exit when it dies + * Exposed as a `bin` in package.json so users can run: + * npx tdai-memory-gateway # from a project that depends on the package + * tdai-memory-gateway # after `npm install -g @tencentdb-agent-memory/memory-tencentdb` + * + * Reads config from environment variables (see src/gateway/config.ts): + * TDAI_GATEWAY_TOKEN Bearer token required by clients + * TDAI_GATEWAY_PORT port to bind (default 8420) + * TDAI_DATA_DIR data root + * TDAI_CC_PID (optional) parent process pid; daemon self-exits when it dies + * + * Designed for use by host-agnostic plugins (Claude Code, Codex CLI) that spawn + * the Gateway as a sidecar without bundling npm dependencies. */ -import { TdaiGateway } from "../../src/gateway/server.js"; +import { TdaiGateway } from "./server.js"; async function main(): Promise { const gateway = new TdaiGateway(); @@ -25,7 +32,7 @@ async function main(): Promise { new Promise((r) => setTimeout(r, 5_000)), ]); } catch { - // ignore — best effort + // best effort } process.exit(reason === "error" ? 1 : 0); }; @@ -51,6 +58,6 @@ async function main(): Promise { } main().catch((err) => { - process.stderr.write(`gateway-entry failed: ${String(err)}\n`); + process.stderr.write(`tdai-memory-gateway failed: ${String(err)}\n`); process.exit(1); }); diff --git a/tsdown.config.ts b/tsdown.config.ts index 16b0073..89b0e06 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,7 +11,7 @@ function collectExternalDependencies(): string[] { } export default defineConfig({ - entry: ["./index.ts"], + entry: ["./index.ts", "./src/gateway/cli.ts"], outDir: "./dist", format: "esm", platform: "node", From b7473e6be5b3b0a843bbe1502095c0a221081359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 17:35:14 +0800 Subject: [PATCH 07/17] fix(plugin): recall e2e fixes from real-device cc TUI validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3-tier recall fallback: /recall → /search/conversations → L0 jsonl direct search (covers FTS5-unavailable + no-embedding degraded mode) - Extend daemon health-check deadline 10s → 30s for slow cold-start - Merge multi-part assistant responses split by tool-use/result cycles - Switch handleStop to readAllTurns with MAX_CAPTURE_TURNS=10 cap - Distinguish real user prompts from system-injected entries via contentIsArray to prevent premature turn flush on Skill output - Add 800ms transcript flush delay before reading (race condition fix) - Improve L0 direct search: CJK 2-gram tokenization, case-insensitive matching, Chinese stop words, assistant-first ranking, dedup Tests: 51 pass (5 files) Signed-off-by: 李冠辰 --- claude-code-plugin/lib/daemon.ts | 46 ++++-- claude-code-plugin/lib/hook.ts | 165 +++++++++++++++++++- claude-code-plugin/lib/transcript.ts | 81 ++++++++-- claude-code-plugin/tests/hook.test.ts | 94 ++++++++++- claude-code-plugin/tests/transcript.test.ts | 100 +++++++++++- 5 files changed, 449 insertions(+), 37 deletions(-) diff --git a/claude-code-plugin/lib/daemon.ts b/claude-code-plugin/lib/daemon.ts index 684f906..1aa9dc4 100644 --- a/claude-code-plugin/lib/daemon.ts +++ b/claude-code-plugin/lib/daemon.ts @@ -148,8 +148,16 @@ export class DaemonManager { } catch { // fallthrough to spawn } - if (existingToken && (await this.healthCheck(existing.port, existingToken))) { - return existing; + if (existingToken) { + // First probe. + if (await this.healthCheck(existing.port, existingToken)) return existing; + // Daemon may still be coming up (another hook just spawned it). + // Wait briefly and retry once before deciding to respawn. + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + await sleep(500); + if (await this.healthCheck(existing.port, existingToken)) return existing; + } } } return this.spawn(ccPid); @@ -188,23 +196,35 @@ export class DaemonManager { throw new Error("Failed to spawn daemon: child has no pid"); } - const deadline = Date.now() + 10_000; + // Write state.json IMMEDIATELY so concurrent hooks (e.g. Stop firing + // before SessionStart's spawn finishes its health probe) see that a + // daemon is being brought up and can wait for it via ensureRunning's + // health-retry loop, instead of treating it as "no daemon". + const pendingState: DaemonState = { + pid: child.pid, + port, + ccPid, + startedAt: new Date().toISOString(), + tokenPath, + }; + await writeDaemonState(this.dataDir, pendingState); + + // Gateway cold-start needs to init SQLite + sqlite-vec + BM25 encoder + + // pipeline + LLM runner. On slower machines this can exceed 10s, so give + // it 30s. The hook is async (cc doesn't block on it) so the longer + // budget doesn't impact UX. + const deadline = Date.now() + 30_000; while (Date.now() < deadline) { if (await this.healthCheck(port, token, 500)) { - const state: DaemonState = { - pid: child.pid, - port, - ccPid, - startedAt: new Date().toISOString(), - tokenPath, - }; - await writeDaemonState(this.dataDir, state); - return state; + return pendingState; } await sleep(200); } - throw new Error(`Daemon did not become healthy on port ${port} within 10s`); + // Health probe timed out. Remove the pending state so subsequent hooks + // don't keep waiting on a daemon that never came up. + await clearDaemonState(this.dataDir); + throw new Error(`Daemon did not become healthy on port ${port} within 30s`); } } diff --git a/claude-code-plugin/lib/hook.ts b/claude-code-plugin/lib/hook.ts index d7d4852..056ae30 100644 --- a/claude-code-plugin/lib/hook.ts +++ b/claude-code-plugin/lib/hook.ts @@ -11,9 +11,9 @@ import { GatewayClient } from "./gateway-client.js"; import { getSessionKey } from "./session-key.js"; -import { readLatestTurn } from "./transcript.js"; +import { readAllTurns } from "./transcript.js"; import { DaemonManager, readDaemonState } from "./daemon.js"; -import { appendFile } from "node:fs/promises"; +import { appendFile, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; const MAX_INJECT_CHARS = 10_000; @@ -88,8 +88,34 @@ async function handleUserPromptSubmit(data: HookStdin, client: GatewayClient): P if (!prompt) return ""; const sessionKey = getSessionKey(cwd); - const result = await client.recall(prompt, sessionKey); - let context = result.context ?? ""; + + // Primary path: L1/L2/L3 recall (structured atoms + persona + scene). + const recall = await client.recall(prompt, sessionKey); + let context = recall.context ?? ""; + + // Fallback 1: daemon /search/conversations (FTS5 BM25 on L0 table). + if (!context) { + const conv = await client.searchConversations(prompt, { + limit: 3, + sessionKey, + }); + if (conv.total > 0 && conv.results) { + context = `## Past conversations (relevant to current prompt)\n\n${conv.results}`; + } + } + + // Fallback 2: direct L0 jsonl file scan. Covers the case where FTS5 is + // unavailable (e.g. Node.js built-in node:sqlite lacks fts5 module) AND + // no embedding service is configured. Reads $TDAI_DATA_DIR/conversations/ + // and does simple keyword matching — no ranking, but good enough to + // surface relevant history on day zero. + if (!context) { + const dataDir = process.env.TDAI_DATA_DIR; + if (dataDir) { + context = await searchL0JsonlDirect(join(dataDir, "conversations"), prompt, sessionKey, 3); + } + } + if (!context) return ""; if (context.length > MAX_INJECT_CHARS) { @@ -118,15 +144,32 @@ async function handleStop(data: HookStdin, client: GatewayClient): Promise setTimeout(r, 800)); + + const allTurns = await readAllTurns(data.transcript_path); + if (allTurns.length === 0) return ""; + + // Only capture the most recent turns to avoid flooding L0 with an + // entire long session's history. Earlier turns from the same session + // will be captured in subsequent Stop events if the user continues. + const MAX_CAPTURE_TURNS = 10; + const turns = allTurns.slice(-MAX_CAPTURE_TURNS); const cwd = data.cwd ?? process.cwd(); const sessionKey = getSessionKey(cwd); + const messages = turns.flatMap((t) => [ + { role: "user" as const, content: t.user }, + { role: "assistant" as const, content: t.assistant }, + ]); + + const lastTurn = turns[turns.length - 1]; await client.captureTurn({ - user_content: turn.user, - assistant_content: turn.assistant, + user_content: lastTurn.user, + assistant_content: lastTurn.assistant, + messages, session_key: sessionKey, session_id: data.session_id, }); @@ -152,6 +195,112 @@ async function handleClearSession(data: HookStdin, client: GatewayClient): Promi return `Cleared session buffer for: ${sessionKey}`; } +// ============================================================================ +// L0 jsonl direct search (last-resort fallback) +// ============================================================================ + +interface L0JsonlRecord { + sessionKey?: string; + role?: string; + content?: string; + recordedAt?: string; +} + +async function searchL0JsonlDirect( + convDir: string, + query: string, + sessionKey: string, + limit: number, +): Promise { + let files: string[]; + try { + files = (await readdir(convDir)).filter((f) => f.endsWith(".jsonl")).sort().reverse(); + } catch { + return ""; + } + if (files.length === 0) return ""; + + // Split CJK text into individual characters (1-gram) for matching, since + // we don't have a segmentation library here. Latin tokens use word split. + const CJK_STOP = new Set([ + "之前", "前聊", "聊的", "还记", "记得", "得么", "一下", "怎么", + "什么", "关于", "知道", "以前", "上次", "那个", "这个", "可以", + "我们", "你们", "他们", "就是", "不是", "有没", "没有", + ]); + const keywords: string[] = []; + for (const seg of query.toLowerCase().replace(/[^\w一-鿿]/g, " ").split(/\s+/)) { + if (!seg) continue; + if (/[一-鿿]/.test(seg)) { + for (let i = 0; i <= seg.length - 2; i++) { + const gram = seg.slice(i, i + 2); + if (!CJK_STOP.has(gram)) keywords.push(gram); + } + } else if (seg.length >= 2) { + keywords.push(seg); + } + } + if (keywords.length === 0) return ""; + + type Match = { role: string; content: string; recordedAt: string; hits: number }; + const matches: Match[] = []; + const seen = new Set(); + + for (const f of files) { + let raw: string; + try { + raw = await readFile(join(convDir, f), "utf-8"); + } catch { + continue; + } + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const rec = JSON.parse(line) as L0JsonlRecord; + if (rec.sessionKey !== sessionKey) continue; + const text = rec.content ?? ""; + const textLower = text.toLowerCase(); + const hits = keywords.filter((kw) => textLower.includes(kw)).length; + if (hits === 0) continue; + // Deduplicate identical content (e.g. repeated user prompts). + const fingerprint = text.slice(0, 120); + if (seen.has(fingerprint)) continue; + seen.add(fingerprint); + matches.push({ + role: rec.role ?? "unknown", + content: text.length > 2000 ? text.slice(0, 2000) + "…" : text, + recordedAt: rec.recordedAt ?? "", + hits, + }); + } catch { + // skip malformed lines + } + } + } + + if (matches.length === 0) return ""; + + // Rank: assistant messages first (more informative than user prompts), + // then by keyword hits (desc), then content length (desc). + const rolePriority = (r: string) => (r === "assistant" ? 1 : 0); + matches.sort( + (a, b) => + rolePriority(b.role) - rolePriority(a.role) || + b.hits - a.hits || + b.content.length - a.content.length, + ); + + const selected = matches.slice(0, limit); + const lines = [`Found ${selected.length} matching conversation(s):`, ""]; + for (const m of selected) { + lines.push("---"); + lines.push(`**[${m.role}]** ${m.recordedAt}`); + lines.push(""); + lines.push(m.content); + lines.push(""); + } + return `## Past conversations (relevant to current prompt)\n\n${lines.join("\n")}`; +} + // ============================================================================ // CLI entry — only runs when this file is executed directly via `node hook.js` // ============================================================================ diff --git a/claude-code-plugin/lib/transcript.ts b/claude-code-plugin/lib/transcript.ts index 2b61ceb..12acd50 100644 --- a/claude-code-plugin/lib/transcript.ts +++ b/claude-code-plugin/lib/transcript.ts @@ -10,6 +10,10 @@ export interface TranscriptEntry { type: "user" | "assistant" | string; role: string; content: string; + /** True when the raw message.content was an array (tool_result, skill + * output, multi-modal input). Used by readAllTurns to avoid treating + * injected system messages as real user prompts. */ + contentIsArray: boolean; uuid?: string; parentUuid?: string; timestamp?: string; @@ -48,6 +52,7 @@ export function parseTranscriptLine(line: string): TranscriptEntry | null { type, role, content, + contentIsArray: Array.isArray(message.content), uuid: typeof o.uuid === "string" ? o.uuid : undefined, parentUuid: typeof o.parentUuid === "string" ? o.parentUuid : undefined, timestamp: typeof o.timestamp === "string" ? o.timestamp : undefined, @@ -71,6 +76,12 @@ function extractContent(content: unknown): string | null { /** * Read the latest complete user+assistant turn from a transcript jsonl file. * Returns null if the file is missing, empty, or contains no complete turn. + * + * A single turn may span multiple transcript entries when the assistant + * response is split by tool-use / tool-result cycles. This function merges + * all assistant text blocks between the last real user prompt and the end + * of the file so the full response is captured — not just the first or + * last fragment. */ export async function readLatestTurn(path: string): Promise { let raw: string; @@ -82,21 +93,71 @@ export async function readLatestTurn(path: string): Promise { const lines = raw.split(/\r?\n/).filter((l) => l.length > 0); if (lines.length === 0) return null; - // Walk from the end backwards looking for assistant then user. - let assistant: string | null = null; + // Walk backwards collecting ALL assistant text blocks until we hit a + // real user prompt (tool_result entries return null from + // parseTranscriptLine, so they are silently skipped). + const assistantParts: string[] = []; let user: string | null = null; + for (let i = lines.length - 1; i >= 0; i--) { const entry = parseTranscriptLine(lines[i]); if (!entry) continue; - if (assistant === null && entry.role === "assistant") { - assistant = entry.content; - continue; + if (entry.role === "assistant") { + if (entry.content) assistantParts.unshift(entry.content); + } else if (entry.role === "user" && !entry.contentIsArray) { + // Only treat string-content user entries as real prompts. + // Array-content entries are tool_result / skill output / attachments. + if (assistantParts.length > 0) { + user = entry.content; + break; + } } - if (assistant !== null && user === null && entry.role === "user") { - user = entry.content; - break; + } + + if (user === null || assistantParts.length === 0) return null; + return { user, assistant: assistantParts.join("\n\n") }; +} + +/** + * Read ALL complete user+assistant turns from a transcript. Each turn + * merges multi-part assistant responses (split by tool cycles) into a + * single string, same as {@link readLatestTurn}. + */ +export async function readAllTurns(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf-8"); + } catch { + return []; + } + const lines = raw.split(/\r?\n/).filter((l) => l.length > 0); + if (lines.length === 0) return []; + + const turns: Turn[] = []; + let currentUser: string | null = null; + let assistantParts: string[] = []; + + for (const line of lines) { + const entry = parseTranscriptLine(line); + if (!entry) continue; + + if (entry.role === "user" && !entry.contentIsArray) { + // Only string-content user entries are real prompts. + // Array-content entries (tool_result, skill output) are skipped. + if (currentUser !== null && assistantParts.length > 0) { + turns.push({ user: currentUser, assistant: assistantParts.join("\n\n") }); + } + currentUser = entry.content; + assistantParts = []; + } else if (entry.role === "assistant" && entry.content) { + assistantParts.push(entry.content); } } - if (user === null || assistant === null) return null; - return { user, assistant }; + + // Flush final turn. + if (currentUser !== null && assistantParts.length > 0) { + turns.push({ user: currentUser, assistant: assistantParts.join("\n\n") }); + } + + return turns; } diff --git a/claude-code-plugin/tests/hook.test.ts b/claude-code-plugin/tests/hook.test.ts index 951f51c..1915844 100644 --- a/claude-code-plugin/tests/hook.test.ts +++ b/claude-code-plugin/tests/hook.test.ts @@ -40,13 +40,103 @@ describe("handleHook: user-prompt-submit", () => { expect(parsed.hookSpecificOutput.additionalContext).toContain("truncated"); }); - it("emits empty string when recall returns no context", async () => { + it("emits empty string when all fallbacks return nothing (no TDAI_DATA_DIR)", async () => { + const orig = process.env.TDAI_DATA_DIR; + delete process.env.TDAI_DATA_DIR; + try { + const client = makeFakeClient({ + recall: vi.fn(async () => ({ context: "" })), + searchConversations: vi.fn(async () => ({ results: "", total: 0 })), + } as Partial); + const stdin = JSON.stringify({ session_id: "s", cwd: "/tmp/p", prompt: "q" }); + const out = await handleHook("user-prompt-submit", { stdin, client }); + expect(out).toBe(""); + } finally { + if (orig !== undefined) process.env.TDAI_DATA_DIR = orig; + } + }); + + it("falls back to L0 jsonl direct search when daemon search returns nothing", async () => { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const os = await import("node:os"); + const tmpDir = path.join(os.tmpdir(), `tdai-hook-test-${Date.now()}`); + const convDir = path.join(tmpDir, "conversations"); + await fs.mkdir(convDir, { recursive: true }); + + const sessionKey = "abc123"; + const records = [ + JSON.stringify({ sessionKey, role: "user", content: "我用 Go 写 Kubernetes operator", recordedAt: "2026-05-15T06:00:00Z" }), + JSON.stringify({ sessionKey, role: "assistant", content: "K8s operator 用 Go 是主流", recordedAt: "2026-05-15T06:00:01Z" }), + JSON.stringify({ sessionKey: "other", role: "user", content: "unrelated stuff", recordedAt: "2026-05-15T06:00:02Z" }), + ]; + await fs.writeFile(path.join(convDir, "2026-05-15.jsonl"), records.join("\n")); + + const orig = process.env.TDAI_DATA_DIR; + process.env.TDAI_DATA_DIR = tmpDir; + try { + const client = makeFakeClient({ + recall: vi.fn(async () => ({ context: "" })), + searchConversations: vi.fn(async () => ({ results: "", total: 0 })), + } as Partial); + // sessionKey in getSessionKey("/tmp/p") won't match "abc123", so we + // need cwd that hashes to "abc123" — easier: just mock getSessionKey. + // Instead, directly use a prompt that matches and set cwd so sessionKey + // matches the records. We'll use TDAI_SESSION_KEY override. + const origSK = process.env.TDAI_SESSION_KEY; + process.env.TDAI_SESSION_KEY = sessionKey; + try { + const stdin = JSON.stringify({ session_id: "s", cwd: "/tmp/p", prompt: "K8s operator" }); + const out = await handleHook("user-prompt-submit", { stdin, client }); + expect(out).not.toBe(""); + const parsed = JSON.parse(out); + expect(parsed.hookSpecificOutput.additionalContext).toContain("Past conversations"); + expect(parsed.hookSpecificOutput.additionalContext).toContain("Kubernetes operator"); + // "unrelated stuff" from other session should NOT appear + expect(parsed.hookSpecificOutput.additionalContext).not.toContain("unrelated"); + } finally { + if (origSK !== undefined) process.env.TDAI_SESSION_KEY = origSK; + else delete process.env.TDAI_SESSION_KEY; + } + } finally { + if (orig !== undefined) process.env.TDAI_DATA_DIR = orig; + else delete process.env.TDAI_DATA_DIR; + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("falls back to L0 conversation search when /recall returns empty context", async () => { + const searchConversations = vi.fn(async () => ({ + results: "Found 1 matching message(s):\n---\n**[user]** ...", + total: 1, + })); const client = makeFakeClient({ recall: vi.fn(async () => ({ context: "" })), + searchConversations, + } as Partial); + const stdin = JSON.stringify({ session_id: "s", cwd: "/tmp/p", prompt: "k8s operator" }); + const out = await handleHook("user-prompt-submit", { stdin, client }); + const parsed = JSON.parse(out); + expect(parsed.hookSpecificOutput.hookEventName).toBe("UserPromptSubmit"); + expect(parsed.hookSpecificOutput.additionalContext).toContain("Past conversations"); + expect(parsed.hookSpecificOutput.additionalContext).toContain("Found 1 matching"); + // L0 fallback should be scoped to the current project (sessionKey). + const call = searchConversations.mock.calls[0]; + expect(call[1]?.sessionKey).toBeTruthy(); + expect(call[1]?.limit).toBe(3); + }); + + it("skips L0 fallback when /recall already returns context", async () => { + const searchConversations = vi.fn(async () => ({ results: "should-not-be-called", total: 1 })); + const client = makeFakeClient({ + recall: vi.fn(async () => ({ context: "primary-recall" })), + searchConversations, } as Partial); const stdin = JSON.stringify({ session_id: "s", cwd: "/tmp/p", prompt: "q" }); const out = await handleHook("user-prompt-submit", { stdin, client }); - expect(out).toBe(""); + const parsed = JSON.parse(out); + expect(parsed.hookSpecificOutput.additionalContext).toBe("primary-recall"); + expect(searchConversations).not.toHaveBeenCalled(); }); }); diff --git a/claude-code-plugin/tests/transcript.test.ts b/claude-code-plugin/tests/transcript.test.ts index dd15ded..23ccb85 100644 --- a/claude-code-plugin/tests/transcript.test.ts +++ b/claude-code-plugin/tests/transcript.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; -import { readLatestTurn, parseTranscriptLine } from "../lib/transcript.js"; +import { readLatestTurn, readAllTurns, parseTranscriptLine } from "../lib/transcript.js"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { writeFile, unlink } from "node:fs/promises"; const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURE = resolve(__dirname, "fixtures/transcript-sample.jsonl"); @@ -14,6 +15,7 @@ describe("parseTranscriptLine", () => { type: "user", role: "user", content: "hi", + contentIsArray: false, uuid: "u1", }); }); @@ -38,6 +40,13 @@ describe("parseTranscriptLine", () => { const line = '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]},"uuid":"u1"}'; const parsed = parseTranscriptLine(line); expect(parsed?.content).toBe("hello\nworld"); + expect(parsed?.contentIsArray).toBe(true); + }); + + it("marks string content as contentIsArray=false", () => { + const line = '{"type":"user","message":{"role":"user","content":"plain text"},"uuid":"u1"}'; + const parsed = parseTranscriptLine(line); + expect(parsed?.contentIsArray).toBe(false); }); }); @@ -56,13 +65,96 @@ describe("readLatestTurn", () => { it("returns null for an empty file", async () => { const tmpPath = resolve(__dirname, "fixtures/empty.jsonl"); - const fs = await import("node:fs/promises"); - await fs.writeFile(tmpPath, ""); + await writeFile(tmpPath, ""); try { const turn = await readLatestTurn(tmpPath); expect(turn).toBeNull(); } finally { - await fs.unlink(tmpPath); + await unlink(tmpPath); + } + }); + + it("merges multi-part assistant responses split by tool cycles", async () => { + const tmpPath = resolve(__dirname, "fixtures/multi-part.jsonl"); + const lines = [ + '{"type":"user","message":{"role":"user","content":"search deepseek"},"uuid":"u1"}', + '{"type":"assistant","message":{"role":"assistant","content":"Searching for DeepSeek info..."},"uuid":"a1"}', + '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"search results here"}]},"uuid":"tr1"}', + '{"type":"assistant","message":{"role":"assistant","content":"DeepSeek V4 is a large MoE model with 685B parameters."},"uuid":"a2"}', + ]; + await writeFile(tmpPath, lines.join("\n")); + try { + const turn = await readLatestTurn(tmpPath); + expect(turn).not.toBeNull(); + expect(turn!.user).toBe("search deepseek"); + expect(turn!.assistant).toContain("Searching for DeepSeek"); + expect(turn!.assistant).toContain("685B parameters"); + } finally { + await unlink(tmpPath); + } + }); +}); + +describe("readAllTurns", () => { + it("returns all turns from the fixture", async () => { + const turns = await readAllTurns(FIXTURE); + expect(turns).toHaveLength(2); + expect(turns[0].user).toBe("first question"); + expect(turns[0].assistant).toBe("first answer"); + expect(turns[1].user).toBe("second question"); + expect(turns[1].assistant).toBe("second answer"); + }); + + it("returns empty array for missing file", async () => { + const turns = await readAllTurns("/tmp/nonexistent.jsonl"); + expect(turns).toEqual([]); + }); + + it("does not split turn on skill output (array-content user entry)", async () => { + const tmpPath = resolve(__dirname, "fixtures/skill-output.jsonl"); + const lines = [ + '{"type":"user","message":{"role":"user","content":"search deepseek v4"},"uuid":"u1"}', + '{"type":"assistant","message":{"role":"assistant","content":"Searching with Tavily..."},"uuid":"a1"}', + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Skill"}]},"uuid":"a2"}', + '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"skill launched"}]},"uuid":"tr1"}', + '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Base directory for this skill: /path/to/skill\\n\\n# tavily search\\n\\nSearch the web..."}]},"uuid":"sk1"}', + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t2","name":"Bash"}]},"uuid":"a3"}', + '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t2","content":"search results here 15000 chars"}]},"uuid":"tr2"}', + '{"type":"assistant","message":{"role":"assistant","content":"## DeepSeek V4 Summary\\n\\nDeepSeek V4 launched with 1.6T parameters."},"uuid":"a4"}', + ]; + await writeFile(tmpPath, lines.join("\n")); + try { + const turns = await readAllTurns(tmpPath); + expect(turns).toHaveLength(1); + expect(turns[0].user).toBe("search deepseek v4"); + expect(turns[0].assistant).toContain("Searching with Tavily"); + expect(turns[0].assistant).toContain("DeepSeek V4 Summary"); + expect(turns[0].assistant).toContain("1.6T parameters"); + } finally { + await unlink(tmpPath); + } + }); + + it("merges multi-part assistant responses across tool cycles", async () => { + const tmpPath = resolve(__dirname, "fixtures/allturns-multi.jsonl"); + const lines = [ + '{"type":"user","message":{"role":"user","content":"q1"},"uuid":"u1"}', + '{"type":"assistant","message":{"role":"assistant","content":"a1"},"uuid":"a1"}', + '{"type":"user","message":{"role":"user","content":"q2"},"uuid":"u2"}', + '{"type":"assistant","message":{"role":"assistant","content":"part1"},"uuid":"a2"}', + '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"tool output"}]},"uuid":"tr1"}', + '{"type":"assistant","message":{"role":"assistant","content":"part2 with details"},"uuid":"a3"}', + ]; + await writeFile(tmpPath, lines.join("\n")); + try { + const turns = await readAllTurns(tmpPath); + expect(turns).toHaveLength(2); + expect(turns[0]).toEqual({ user: "q1", assistant: "a1" }); + expect(turns[1].user).toBe("q2"); + expect(turns[1].assistant).toContain("part1"); + expect(turns[1].assistant).toContain("part2 with details"); + } finally { + await unlink(tmpPath); } }); }); From feeb611968f2b6601e59c5598196cfa689b7a75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 21:35:41 +0800 Subject: [PATCH 08/17] =?UTF-8?q?fix(plugin):=20=E5=8A=A0=E5=9B=BA=20Gatew?= =?UTF-8?q?ay=20Bearer=20=E9=89=B4=E6=9D=83=E4=B8=8E=20daemon=20=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server: Bearer 比较改用 timing-safe + 大小写不敏感(RFC 6750 §2.1), 401 响应携带 WWW-Authenticate header,鉴权逻辑抽到 authorize() 方法 - cli: 拒绝非 loopback 的 TDAI_GATEWAY_HOST(需 TDAI_GATEWAY_ALLOW_REMOTE=1 显式打开);新增 TDAI_TOKEN_PATH 通过文件传 token 的入口,避免 token 通过 execve env block 暴露在 /proc//environ 或 ps -E - daemon: spawn 改用 TDAI_TOKEN_PATH 传递并显式删除 TDAI_GATEWAY_TOKEN; readToken 跳过 Windows 上失真的 0o077 mode 检查并校验文件 owner uid; ensureRunning 复用前校验 state.ccPid 与当前 ppid 一致,避免跨会话/ 跨用户错误复用旧 daemon - hook + skill: 新增 search-stdin 事件从 stdin 读 query,重写 memory-search/SKILL.md 用 heredoc 触发 Bash tool 调用,规避 cc 当前 对 \$ARGUMENTS 字面 replaceAll 导致的命令注入风险(anthropics/claude-code#16163) Signed-off-by: 李冠辰 --- claude-code-plugin/lib/daemon.ts | 37 ++++++++++++---- claude-code-plugin/lib/hook.ts | 16 +++++++ .../skills/memory-search/SKILL.md | 16 +++++-- src/gateway/cli.ts | 44 ++++++++++++++++++- src/gateway/server.ts | 41 ++++++++++++----- 5 files changed, 132 insertions(+), 22 deletions(-) diff --git a/claude-code-plugin/lib/daemon.ts b/claude-code-plugin/lib/daemon.ts index 1aa9dc4..7de71e4 100644 --- a/claude-code-plugin/lib/daemon.ts +++ b/claude-code-plugin/lib/daemon.ts @@ -78,9 +78,24 @@ export class DaemonManager { async readToken(tokenPath: string): Promise { const st = await stat(tokenPath); - if ((st.mode & 0o077) !== 0) { + // Windows' Node fs reports mode bits that don't map to POSIX rwx, so + // the 0o077 check would always fire and block Windows users entirely. + // Skip the bit-level check there and rely on the NTFS ACL the OS gave + // the file at create time. + if (process.platform !== "win32" && (st.mode & 0o077) !== 0) { throw new Error(`Token file permission too loose: ${tokenPath}`); } + // Owner check: refuse to read a token file we don't own. Guards the + // multi-user case where ~/.tdai-memory is on a shared FS and a peer + // UID could pre-create the file to phish the daemon. + if (process.platform !== "win32" && typeof process.getuid === "function") { + const uid = process.getuid(); + if (st.uid !== uid) { + throw new Error( + `Token file owner mismatch: expected uid=${uid}, got uid=${st.uid} for ${tokenPath}`, + ); + } + } const raw = await readFile(tokenPath, "utf-8"); return raw.trim(); } @@ -142,13 +157,17 @@ export class DaemonManager { async ensureRunning(ccPid: number): Promise { const existing = await readDaemonState(this.dataDir); if (existing) { + // Refuse to reuse a daemon spawned for a different cc instance. Without + // this check, a stale state.json from a previous user/session on a shared + // box could route this session's recall/capture to someone else's daemon. + const ccPidMatches = existing.ccPid === ccPid; let existingToken = ""; try { existingToken = await this.readToken(existing.tokenPath); } catch { // fallthrough to spawn } - if (existingToken) { + if (ccPidMatches && existingToken) { // First probe. if (await this.healthCheck(existing.port, existingToken)) return existing; // Daemon may still be coming up (another hook just spawned it). @@ -180,13 +199,15 @@ export class DaemonManager { ? [] : ["--yes", "tdai-memory-gateway"]; + // Pass the token by FILE PATH, not as an env var. execve() snapshots the + // initial environment block and exposes it via /proc//environ / + // `ps -E` to any peer process with the same UID — a token file gated by + // 0600 + owner check is a smaller attack surface. + const childEnv = { ...process.env, TDAI_GATEWAY_PORT: String(port), TDAI_CC_PID: String(ccPid), TDAI_TOKEN_PATH: tokenPath } as NodeJS.ProcessEnv; + delete childEnv.TDAI_GATEWAY_TOKEN; + const child: ChildProcess = spawn(command, args, { - env: { - ...process.env, - TDAI_GATEWAY_TOKEN: token, - TDAI_GATEWAY_PORT: String(port), - TDAI_CC_PID: String(ccPid), - }, + env: childEnv, detached: true, stdio: ["ignore", "ignore", "ignore"], }); diff --git a/claude-code-plugin/lib/hook.ts b/claude-code-plugin/lib/hook.ts index 056ae30..59af91c 100644 --- a/claude-code-plugin/lib/hook.ts +++ b/claude-code-plugin/lib/hook.ts @@ -24,6 +24,7 @@ export type HookEvent = | "post-tool-use" | "stop" | "search" + | "search-stdin" | "status" | "clear-session"; @@ -46,6 +47,8 @@ export async function handleHook(event: HookEvent, input: HookInput): Promise { + const query = rawStdin.trim(); + if (!query) return "Usage: pipe the query to stdin"; + const result = await client.searchMemories(query, { limit: 10 }); + return result.results || "No memories found."; +} + async function handleStatus(client: GatewayClient): Promise { const ok = await client.health(); return ok ? "TDAI memory daemon: healthy" : "TDAI memory daemon: unreachable"; diff --git a/claude-code-plugin/skills/memory-search/SKILL.md b/claude-code-plugin/skills/memory-search/SKILL.md index 9280bdc..4014a2b 100644 --- a/claude-code-plugin/skills/memory-search/SKILL.md +++ b/claude-code-plugin/skills/memory-search/SKILL.md @@ -4,8 +4,18 @@ description: Search long-term memory (TencentDB Agent Memory) for relevant past argument-hint: --- -Search TencentDB Agent Memory for: $ARGUMENTS +The user wants to search the long-term memory store for the following query: -!`node "${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs" search "$ARGUMENTS"` +$ARGUMENTS -Summarize the results above and answer the user's question. If no memories were found, say so plainly. +Run the search via the Bash tool. The plugin reads the query from **stdin** to keep user-controlled text outside any shell word-split / expansion stage (cc currently performs a literal `replaceAll` on `$ARGUMENTS`, so passing it as an argv element would expose a command-injection surface — see Anthropic GH issue #16163). + +Use a here-document with a long random sentinel: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/dist/lib/hook.mjs" search-stdin <<'__TDAI_QUERY_EOF__' + +__TDAI_QUERY_EOF__ +``` + +Then summarize the matching memories to answer the user's question. If no memories were returned, say so plainly. diff --git a/src/gateway/cli.ts b/src/gateway/cli.ts index c88cc28..500abcc 100644 --- a/src/gateway/cli.ts +++ b/src/gateway/cli.ts @@ -7,8 +7,12 @@ * tdai-memory-gateway # after `npm install -g @tencentdb-agent-memory/memory-tencentdb` * * Reads config from environment variables (see src/gateway/config.ts): - * TDAI_GATEWAY_TOKEN Bearer token required by clients + * TDAI_TOKEN_PATH path to a 0600 file holding the Bearer token (preferred — + * avoids leaking the token via /proc//environ / `ps -E`) + * TDAI_GATEWAY_TOKEN Bearer token (fallback for Hermes-style direct env passing) * TDAI_GATEWAY_PORT port to bind (default 8420) + * TDAI_GATEWAY_HOST bind host (default 127.0.0.1). Non-loopback values require + * TDAI_GATEWAY_ALLOW_REMOTE=1 to opt in (defence in depth). * TDAI_DATA_DIR data root * TDAI_CC_PID (optional) parent process pid; daemon self-exits when it dies * @@ -16,9 +20,47 @@ * the Gateway as a sidecar without bundling npm dependencies. */ +import { readFileSync } from "node:fs"; import { TdaiGateway } from "./server.js"; +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "::ffff:127.0.0.1"]); + +function assertSafeHost(): void { + const host = process.env.TDAI_GATEWAY_HOST?.trim(); + if (!host) return; + if (LOOPBACK_HOSTS.has(host)) return; + if (process.env.TDAI_GATEWAY_ALLOW_REMOTE === "1") return; + process.stderr.write( + `tdai-memory-gateway: refusing to bind TDAI_GATEWAY_HOST=${host} (non-loopback). ` + + `Set TDAI_GATEWAY_ALLOW_REMOTE=1 to opt in.\n`, + ); + process.exit(2); +} + +function loadTokenFromFile(): void { + const tokenPath = process.env.TDAI_TOKEN_PATH; + if (!tokenPath) return; + try { + const token = readFileSync(tokenPath, "utf-8").trim(); + if (!token) { + process.stderr.write(`tdai-memory-gateway: TDAI_TOKEN_PATH=${tokenPath} is empty\n`); + process.exit(2); + } + // Set on the in-process env object only — this does NOT mutate the + // execve() environment block, so /proc//environ / `ps -E` won't + // expose the token. + process.env.TDAI_GATEWAY_TOKEN = token; + } catch (err) { + process.stderr.write( + `tdai-memory-gateway: failed to read TDAI_TOKEN_PATH=${tokenPath}: ${String(err)}\n`, + ); + process.exit(2); + } +} + async function main(): Promise { + assertSafeHost(); + loadTokenFromFile(); const gateway = new TdaiGateway(); await gateway.start(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index c4d1f54..5f16f17 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -15,6 +15,7 @@ */ import http from "node:http"; +import { timingSafeEqual } from "node:crypto"; import { URL } from "node:url"; import { TdaiCore } from "../core/tdai-core.js"; import { StandaloneHostAdapter } from "../adapters/standalone/host-adapter.js"; @@ -184,16 +185,7 @@ export class TdaiGateway { return; } - // ----- BEGIN: Optional Bearer token auth (added for Claude Code plugin) ----- - const expectedToken = process.env.TDAI_GATEWAY_TOKEN; - if (expectedToken) { - const authHeader = req.headers.authorization; - if (authHeader !== `Bearer ${expectedToken}`) { - sendError(res, 401, "Unauthorized"); - return; - } - } - // ----- END: Optional Bearer token auth ----- + if (!this.authorize(req, res)) return; try { switch (`${method} ${pathname}`) { @@ -221,6 +213,35 @@ export class TdaiGateway { } } + /** + * Optional Bearer-token gate. When TDAI_GATEWAY_TOKEN (or a token file + * pointed to by TDAI_TOKEN_PATH, loaded by cli.ts into process.env) is set, + * every non-OPTIONS request must carry a matching `Authorization: Bearer + * ` header. Comparison is timing-safe and case-insensitive on the + * "Bearer" scheme keyword per RFC 6750 §2.1. + * + * Returns true if the request is authorized, false if a 401 has been sent. + */ + private authorize(req: http.IncomingMessage, res: http.ServerResponse): boolean { + const expectedToken = process.env.TDAI_GATEWAY_TOKEN; + if (!expectedToken) return true; + + const authHeader = req.headers.authorization ?? ""; + const match = /^Bearer\s+(\S+)\s*$/i.exec(authHeader); + const provided = match?.[1] ?? ""; + const expectedBuf = Buffer.from(expectedToken, "utf-8"); + const providedBuf = Buffer.from(provided, "utf-8"); + const ok = + expectedBuf.length > 0 && + providedBuf.length === expectedBuf.length && + timingSafeEqual(providedBuf, expectedBuf); + if (ok) return true; + + res.setHeader("WWW-Authenticate", 'Bearer realm="tdai-gateway"'); + sendError(res, 401, "Unauthorized"); + return false; + } + // ============================ // Route handlers // ============================ From c7f0da9d11f1e17fc7adb2e09cabdb97aedc8c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 21:40:54 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix(plugin):=20Stop=20hook=20=E6=94=B9?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E5=8F=91=E9=80=81=20+=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=20cursor=EF=BC=8C=E4=BF=AE=20L0=20=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前每次 Stop 都把最近 10 个 turn 全量 POST 给 /capture,Gateway 端 按 Layer 1(originalUserMessageCount 位置切片)与 Layer 2(afterTimestamp 游标)的两层去重都无法生效(CaptureRequest 不携带这两个字段),导致 长会话中前 N-1 个 turn 在每次 Stop 时被反复写入 L0,污染 FTS5 索引 与向量库。 修复: - 在 \$CLAUDE_PLUGIN_DATA/cursors/.json 持久化 lastSentIndex, Stop 时按 allTurns.slice(lastSent) 取增量;首次发送以 50 turn 封顶 防止历史 transcript 一次性 dump。cursor 文件使用 tmp+rename 原子写。 - 800ms 硬 sleep 改为 waitForTranscriptStable(2s):每 100ms 轮询 stat().size,连续两次相同字节数即视为 flush 完成;慢盘场景更稳。 - hook.test.ts 的 stop describe 增加 beforeEach/afterEach 把 CLAUDE_PLUGIN_DATA stub 到 mkdtemp 隔离 cursor 状态,避免测试间 cursor 文件污染导致首次 captureTurn 断言被吞掉。 Signed-off-by: 李冠辰 --- claude-code-plugin/lib/hook.ts | 96 +++++++++++++++++++++++---- claude-code-plugin/tests/hook.test.ts | 18 ++++- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/claude-code-plugin/lib/hook.ts b/claude-code-plugin/lib/hook.ts index 59af91c..22133c4 100644 --- a/claude-code-plugin/lib/hook.ts +++ b/claude-code-plugin/lib/hook.ts @@ -13,10 +13,12 @@ import { GatewayClient } from "./gateway-client.js"; import { getSessionKey } from "./session-key.js"; import { readAllTurns } from "./transcript.js"; import { DaemonManager, readDaemonState } from "./daemon.js"; -import { appendFile, readdir, readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { appendFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { homedir } from "node:os"; const MAX_INJECT_CHARS = 10_000; +const MAX_CAPTURE_TURNS = 50; export type HookEvent = | "session-start" @@ -147,28 +149,42 @@ async function handleStop(data: HookStdin, client: GatewayClient): Promise setTimeout(r, 800)); + // cc may trigger Stop before the last assistant block is flushed to disk. + // Poll the file size until two consecutive 100ms ticks see identical bytes, + // capped at 2s. Replaces a fragile 800ms hard sleep that still missed slow + // disks on real-machine validation. + await waitForTranscriptStable(data.transcript_path, 2_000); const allTurns = await readAllTurns(data.transcript_path); if (allTurns.length === 0) return ""; - // Only capture the most recent turns to avoid flooding L0 with an - // entire long session's history. Earlier turns from the same session - // will be captured in subsequent Stop events if the user continues. - const MAX_CAPTURE_TURNS = 10; - const turns = allTurns.slice(-MAX_CAPTURE_TURNS); + // Persist a per-session cursor so the next Stop only sends turns appended + // after this one. Without it, every Stop posts the latest N turns and the + // Gateway writes them to L0 again, duplicating long sessions across calls. + const dataDir = resolveDataDir(); + const cursorId = sanitizeCursorId( + data.session_id ?? (basename(data.transcript_path).replace(/\.jsonl$/, "") || "default"), + ); + const lastSent = await readCursor(dataDir, cursorId); + + let newTurns = allTurns.slice(lastSent); + if (newTurns.length === 0) return ""; + + // Bound the first capture so a pre-existing long transcript doesn't dump + // hundreds of turns in a single /capture request. + if (newTurns.length > MAX_CAPTURE_TURNS) { + newTurns = newTurns.slice(-MAX_CAPTURE_TURNS); + } const cwd = data.cwd ?? process.cwd(); const sessionKey = getSessionKey(cwd); - const messages = turns.flatMap((t) => [ + const messages = newTurns.flatMap((t) => [ { role: "user" as const, content: t.user }, { role: "assistant" as const, content: t.assistant }, ]); - const lastTurn = turns[turns.length - 1]; + const lastTurn = newTurns[newTurns.length - 1]; await client.captureTurn({ user_content: lastTurn.user, assistant_content: lastTurn.assistant, @@ -176,9 +192,65 @@ async function handleStop(data: HookStdin, client: GatewayClient): Promise { + const start = Date.now(); + let lastSize = -1; + let stableTicks = 0; + while (Date.now() - start < maxMs) { + try { + const st = await stat(path); + if (st.size === lastSize) { + stableTicks++; + if (stableTicks >= 2) return; + } else { + stableTicks = 0; + lastSize = st.size; + } + } catch { + // not yet written + } + await new Promise((r) => setTimeout(r, 100)); + } +} + +function resolveDataDir(): string { + return process.env.CLAUDE_PLUGIN_DATA ?? join(homedir(), ".tdai-memory"); +} + +function sanitizeCursorId(id: string): string { + return id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "default"; +} + +async function readCursor(dataDir: string, cursorId: string): Promise { + try { + const raw = await readFile(join(dataDir, "cursors", `${cursorId}.json`), "utf-8"); + const obj = JSON.parse(raw) as { lastSentIndex?: unknown }; + return typeof obj.lastSentIndex === "number" && obj.lastSentIndex >= 0 + ? obj.lastSentIndex + : 0; + } catch { + return 0; + } +} + +async function writeCursor(dataDir: string, cursorId: string, lastSentIndex: number): Promise { + const dir = join(dataDir, "cursors"); + await mkdir(dir, { recursive: true }); + const tmp = join(dir, `${cursorId}.json.tmp`); + const final = join(dir, `${cursorId}.json`); + await writeFile( + tmp, + JSON.stringify({ lastSentIndex, updatedAt: new Date().toISOString() }), + { mode: 0o600 }, + ); + // Atomic replace so a crashed write never corrupts the cursor file. + await rename(tmp, final); +} + async function handleSearch(args: string[], client: GatewayClient): Promise { const query = args.join(" ").trim(); if (!query) return "Usage: /memory-search "; diff --git a/claude-code-plugin/tests/hook.test.ts b/claude-code-plugin/tests/hook.test.ts index 1915844..ca7f250 100644 --- a/claude-code-plugin/tests/hook.test.ts +++ b/claude-code-plugin/tests/hook.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { handleHook } from "../lib/hook.js"; import type { GatewayClient, RecallResult } from "../lib/gateway-client.js"; @@ -141,6 +144,19 @@ describe("handleHook: user-prompt-submit", () => { }); describe("handleHook: stop", () => { + // Stop now persists a per-session cursor to $CLAUDE_PLUGIN_DATA/cursors/. + // Isolate it to a tmpdir per test so cursor state never leaks across runs + // (a previously-written cursor would make the next run see lastSent>0 and + // suppress the captureTurn call this test asserts on). + let cursorDir: string; + beforeEach(async () => { + cursorDir = await mkdtemp(join(tmpdir(), "tdai-stop-cursor-")); + vi.stubEnv("CLAUDE_PLUGIN_DATA", cursorDir); + }); + afterEach(async () => { + await rm(cursorDir, { recursive: true, force: true }); + }); + it("exits silently when stop_hook_active is true", async () => { const captureTurn = vi.fn(); const client = makeFakeClient({ From 606a48c538601c2163dfd9d8845adec8c7cb89d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 21:42:09 +0800 Subject: [PATCH 10/17] =?UTF-8?q?fix(plugin):=20=E4=BF=AE=E8=AE=A2=20CJK?= =?UTF-8?q?=20=E5=81=9C=E7=94=A8=E8=AF=8D=E9=9B=86=20+=20L0=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20streaming=20=E8=AF=BB=E5=8F=96=20+=20mtime=20?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CJK_STOP 此前包含 "我们/你们/他们/这个/那个/可以/有没/没有/就是/不是" 等普通双字实义词,"我们的部署方案"会被切成 [们的, 的部, 部署, 署方, 方案],丢失 "我们" 这一 anchor token,中文 query 召回严重退化。 只保留真正低信息量的疑问/连接片段(之前/记得/什么/怎么/请问 等)。 - searchL0JsonlDirect 从一次性 readFile 改为 readline streaming + createReadStream,避免长会话 jsonl(数十 MB)一次性载入内存触发 OOM。 - 文件遍历顺序从字符串排序+reverse(依赖 "YYYY-MM-DD.jsonl" 命名) 改为 mtime 倒序,对 cc transcript UUID 命名也工作正常。 Signed-off-by: 李冠辰 --- claude-code-plugin/lib/hook.ts | 90 ++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 30 deletions(-) diff --git a/claude-code-plugin/lib/hook.ts b/claude-code-plugin/lib/hook.ts index 22133c4..7992922 100644 --- a/claude-code-plugin/lib/hook.ts +++ b/claude-code-plugin/lib/hook.ts @@ -14,6 +14,8 @@ import { getSessionKey } from "./session-key.js"; import { readAllTurns } from "./transcript.js"; import { DaemonManager, readDaemonState } from "./daemon.js"; import { appendFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; import { basename, join } from "node:path"; import { homedir } from "node:os"; @@ -302,18 +304,37 @@ async function searchL0JsonlDirect( ): Promise { let files: string[]; try { - files = (await readdir(convDir)).filter((f) => f.endsWith(".jsonl")).sort().reverse(); + files = (await readdir(convDir)).filter((f) => f.endsWith(".jsonl")); } catch { return ""; } if (files.length === 0) return ""; - // Split CJK text into individual characters (1-gram) for matching, since - // we don't have a segmentation library here. Latin tokens use word split. + // Sort by mtime desc so newer conversations are scanned first. Filename + // ordering used to assume "YYYY-MM-DD.jsonl" naming, which broke for any + // other scheme (e.g. cc transcript UUIDs). + const withMtime = await Promise.all( + files.map(async (f) => { + try { + const st = await stat(join(convDir, f)); + return { name: f, mtime: st.mtimeMs }; + } catch { + return { name: f, mtime: 0 }; + } + }), + ); + withMtime.sort((a, b) => b.mtime - a.mtime); + const sortedFiles = withMtime.map((e) => e.name); + + // CJK 2-gram tokens, sans a small stop set. The previous list stopped + // common content-bearing pronouns ("我们/你们/这个/可以/有没/没有" etc.) + // which silently shredded recall for everyday Chinese queries — keep only + // genuinely low-signal interrogative / connective fragments here. const CJK_STOP = new Set([ - "之前", "前聊", "聊的", "还记", "记得", "得么", "一下", "怎么", - "什么", "关于", "知道", "以前", "上次", "那个", "这个", "可以", - "我们", "你们", "他们", "就是", "不是", "有没", "没有", + "之前", "前聊", "聊的", "还记", "记得", "得么", "得吗", + "一下", "怎么", "什么", "关于", "知道", "以前", "上次", + "如何", "为何", "为啥", "哪里", "哪些", "为什", + "请问", "请帮", "帮我", "麻烦", ]); const keywords: string[] = []; for (const seg of query.toLowerCase().replace(/[^\w一-鿿]/g, " ").split(/\s+/)) { @@ -333,35 +354,44 @@ async function searchL0JsonlDirect( const matches: Match[] = []; const seen = new Set(); - for (const f of files) { - let raw: string; + for (const f of sortedFiles) { + // Stream the file line-by-line: large jsonl (multi-MB) used to be + // readFile'd into memory in full, which OOM'd on long-running sessions. + let rl; try { - raw = await readFile(join(convDir, f), "utf-8"); + rl = createInterface({ + input: createReadStream(join(convDir, f), { encoding: "utf-8" }), + crlfDelay: Infinity, + }); } catch { continue; } - for (const line of raw.split("\n")) { - if (!line.trim()) continue; - try { - const rec = JSON.parse(line) as L0JsonlRecord; - if (rec.sessionKey !== sessionKey) continue; - const text = rec.content ?? ""; - const textLower = text.toLowerCase(); - const hits = keywords.filter((kw) => textLower.includes(kw)).length; - if (hits === 0) continue; - // Deduplicate identical content (e.g. repeated user prompts). - const fingerprint = text.slice(0, 120); - if (seen.has(fingerprint)) continue; - seen.add(fingerprint); - matches.push({ - role: rec.role ?? "unknown", - content: text.length > 2000 ? text.slice(0, 2000) + "…" : text, - recordedAt: rec.recordedAt ?? "", - hits, - }); - } catch { - // skip malformed lines + try { + for await (const line of rl) { + if (!line.trim()) continue; + try { + const rec = JSON.parse(line) as L0JsonlRecord; + if (rec.sessionKey !== sessionKey) continue; + const text = rec.content ?? ""; + const textLower = text.toLowerCase(); + const hits = keywords.filter((kw) => textLower.includes(kw)).length; + if (hits === 0) continue; + // Deduplicate identical content (e.g. repeated user prompts). + const fingerprint = text.slice(0, 120); + if (seen.has(fingerprint)) continue; + seen.add(fingerprint); + matches.push({ + role: rec.role ?? "unknown", + content: text.length > 2000 ? text.slice(0, 2000) + "…" : text, + recordedAt: rec.recordedAt ?? "", + hits, + }); + } catch { + // skip malformed lines + } } + } finally { + rl.close(); } } From 1f781c8918fcb1db664b09107da6d94ab2cfd407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 21:44:09 +0800 Subject: [PATCH 11/17] =?UTF-8?q?fix(plugin):=20daemon=20=E5=BC=95?= =?UTF-8?q?=E5=85=A5=20spawn=20lock=20+=20=E7=8A=B6=E6=80=81=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E5=86=99=20+=20=E8=B7=A8=E5=B9=B3=E5=8F=B0=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8E=20cwd=20=E6=98=BE=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensureRunning 拆出 reuseExisting 子流程;新增基于 O_CREAT|O_EXCL 的 spawn.lock,并发的 SessionStart/UserPromptSubmit/Stop hook 中只有 一个 hook 实际调用 spawn(),其余 hook 等候状态文件出现并复用, 从根本上避免双 daemon 与 token/端口错配。lock 超过 60s 视为陈旧 自动破除。 - writeDaemonState 改 tmp + rename 原子替换,并发读不会看到半写 JSON。 - spawn 时显式 cwd=dataDir、注入 TDAI_DATA_DIR,避免 Gateway 解析 数据目录受 hook 进程随机 cwd 影响导致跨会话数据分裂。 - spawn 的 stdio 把 stderr/stdout 重定向到 dataDir/daemon.log, daemon 冷启动失败时不再静默丢日志。 - hook.ts main() 数据目录解析改走 resolveDataDir()(os.homedir()), Windows 上不再 fallback 到 "." 写当前目录。 - cli.ts 父进程存活轮询从 60s 缩到 15s,cc 异常退出后旧 daemon 在新会话开启前更及时被回收。 Signed-off-by: 李冠辰 --- claude-code-plugin/lib/daemon.ts | 143 +++++++++++++++++++++++++------ claude-code-plugin/lib/hook.ts | 2 +- src/gateway/cli.ts | 5 +- 3 files changed, 121 insertions(+), 29 deletions(-) diff --git a/claude-code-plugin/lib/daemon.ts b/claude-code-plugin/lib/daemon.ts index 7de71e4..3711bf2 100644 --- a/claude-code-plugin/lib/daemon.ts +++ b/claude-code-plugin/lib/daemon.ts @@ -6,8 +6,8 @@ import { spawn, type ChildProcess } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdir, writeFile, readFile, stat, unlink } from "node:fs/promises"; -import { existsSync } from "node:fs"; +import { mkdir, writeFile, readFile, stat, unlink, open, rename } from "node:fs/promises"; +import { existsSync, openSync } from "node:fs"; import { join } from "node:path"; import http from "node:http"; import net from "node:net"; @@ -43,9 +43,11 @@ export async function readDaemonState(dataDir: string): Promise { await mkdir(dataDir, { recursive: true }); - await writeFile(join(dataDir, STATE_FILE), JSON.stringify(state, null, 2), { - mode: 0o600, - }); + // Atomic write: a concurrent reader never observes a half-written JSON. + const tmp = join(dataDir, `${STATE_FILE}.tmp`); + const final = join(dataDir, STATE_FILE); + await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); + await rename(tmp, final); } export async function clearDaemonState(dataDir: string): Promise { @@ -155,31 +157,95 @@ export class DaemonManager { } async ensureRunning(ccPid: number): Promise { - const existing = await readDaemonState(this.dataDir); - if (existing) { - // Refuse to reuse a daemon spawned for a different cc instance. Without - // this check, a stale state.json from a previous user/session on a shared - // box could route this session's recall/capture to someone else's daemon. - const ccPidMatches = existing.ccPid === ccPid; - let existingToken = ""; + const reuseExisting = async (): Promise => { + const existing = await readDaemonState(this.dataDir); + if (!existing) return null; + if (existing.ccPid !== ccPid) return null; + let token = ""; try { - existingToken = await this.readToken(existing.tokenPath); + token = await this.readToken(existing.tokenPath); } catch { - // fallthrough to spawn + return null; } - if (ccPidMatches && existingToken) { - // First probe. - if (await this.healthCheck(existing.port, existingToken)) return existing; - // Daemon may still be coming up (another hook just spawned it). - // Wait briefly and retry once before deciding to respawn. - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - await sleep(500); - if (await this.healthCheck(existing.port, existingToken)) return existing; - } + if (!token) return null; + if (await this.healthCheck(existing.port, token)) return existing; + // Daemon may still be coming up (another hook just spawned it). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + await sleep(500); + if (await this.healthCheck(existing.port, token)) return existing; } + return null; + }; + + const reused = await reuseExisting(); + if (reused) return reused; + + // O_CREAT|O_EXCL spawn lock — only one concurrent hook actually invokes + // spawn(). Other hooks block on it and recover the spawned state. + const lock = await this.acquireSpawnLock(); + if (!lock) { + // Lock held by a peer hook. Wait up to 35s for it to write state.json + // and bring the daemon up. + const deadline = Date.now() + 35_000; + while (Date.now() < deadline) { + await sleep(500); + const r = await reuseExisting(); + if (r) return r; + } + throw new Error("daemon spawn lock contention timed out"); + } + try { + // Re-check inside the lock — a peer might have finished between our + // first reuseExisting and acquireSpawnLock. + const r = await reuseExisting(); + if (r) return r; + return await this.spawn(ccPid); + } finally { + await lock.release(); + } + } + + /** + * Returns a held lock handle, or null if another process owns the lock. + * Stale locks (>60s old) are forcibly broken so a crashed hook never wedges + * the daemon-up path. + */ + private async acquireSpawnLock(): Promise<{ release(): Promise } | null> { + await mkdir(this.dataDir, { recursive: true }); + const lockPath = join(this.dataDir, "spawn.lock"); + const tryCreate = async (): Promise<{ release(): Promise } | null> => { + try { + const fh = await open(lockPath, "wx"); // O_CREAT|O_EXCL|O_WRONLY + await fh.write(`${process.pid}\n`); + await fh.close(); + return { + release: async () => { + try { + await unlink(lockPath); + } catch { + // already gone + } + }, + }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") return null; + throw err; + } + }; + const first = await tryCreate(); + if (first) return first; + try { + const st = await stat(lockPath); + if (Date.now() - st.mtimeMs > 60_000) { + await unlink(lockPath).catch(() => {}); + return tryCreate(); + } + } catch { + // race: lock disappeared, retry once + return tryCreate(); } - return this.spawn(ccPid); + return null; } /** @@ -203,13 +269,36 @@ export class DaemonManager { // initial environment block and exposes it via /proc//environ / // `ps -E` to any peer process with the same UID — a token file gated by // 0600 + owner check is a smaller attack surface. - const childEnv = { ...process.env, TDAI_GATEWAY_PORT: String(port), TDAI_CC_PID: String(ccPid), TDAI_TOKEN_PATH: tokenPath } as NodeJS.ProcessEnv; + // + // Also pin TDAI_DATA_DIR explicitly: without it the gateway resolves its + // data dir against process.cwd() of the spawning hook, which can be any + // arbitrary user directory and would split data across cwds. + const childEnv = { + ...process.env, + TDAI_GATEWAY_PORT: String(port), + TDAI_CC_PID: String(ccPid), + TDAI_TOKEN_PATH: tokenPath, + TDAI_DATA_DIR: process.env.TDAI_DATA_DIR ?? this.dataDir, + } as NodeJS.ProcessEnv; delete childEnv.TDAI_GATEWAY_TOKEN; + // Redirect stderr (and stdout) into daemon.log so cold-start crashes are + // not swallowed silently. detached + unref keeps the daemon alive past + // the hook process exit; the log fds are independent of our stdio. + await mkdir(this.dataDir, { recursive: true }); + const logPath = join(this.dataDir, "daemon.log"); + let logFd: number | "ignore" = "ignore"; + try { + logFd = openSync(logPath, "a"); + } catch { + // fall back to discarding stderr if we can't open the log + } + const child: ChildProcess = spawn(command, args, { env: childEnv, + cwd: this.dataDir, detached: true, - stdio: ["ignore", "ignore", "ignore"], + stdio: ["ignore", logFd, logFd], }); child.unref(); diff --git a/claude-code-plugin/lib/hook.ts b/claude-code-plugin/lib/hook.ts index 7992922..c0297c0 100644 --- a/claude-code-plugin/lib/hook.ts +++ b/claude-code-plugin/lib/hook.ts @@ -427,7 +427,7 @@ async function main(): Promise { const event = (process.argv[2] ?? "") as HookEvent; const args = process.argv.slice(3); - const dataDir = process.env.CLAUDE_PLUGIN_DATA ?? join(process.env.HOME ?? ".", ".tdai-memory"); + const dataDir = resolveDataDir(); const logPath = join(dataDir, "hook.log"); try { diff --git a/src/gateway/cli.ts b/src/gateway/cli.ts index 500abcc..24b6915 100644 --- a/src/gateway/cli.ts +++ b/src/gateway/cli.ts @@ -84,6 +84,9 @@ async function main(): Promise { const ccPid = parseInt(process.env.TDAI_CC_PID ?? "0", 10); if (Number.isFinite(ccPid) && ccPid > 0) { + // Poll every 15s — short enough that a vanished cc doesn't keep the + // daemon alive long enough to collide with a fresh hook spawning a + // replacement, long enough that the syscall load stays negligible. const timer = setInterval(() => { try { process.kill(ccPid, 0); @@ -94,7 +97,7 @@ async function main(): Promise { void shutdown("parent-exit"); } } - }, 60_000); + }, 15_000); timer.unref(); } } From 1a2e1fe4d8ed689dd51a662b623aa4f70c912b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 21:45:28 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fix(plugin):=20GatewayClient=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=BF=BD=E5=8A=A0=20hook.log=20+=20memory-status=20?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=97=A5=E5=BF=97=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayClient 之前所有 catch 块都是空 catch,captureTurn / recall / search 静默返回空结果,用户根本无法知道 daemon 是否在工作。 - GatewayClientConfig 新增 logPath:每次 HTTP error / 非 200 / JSON.parse 失败时按 ISO 时间戳 + method + path + 错误片段追加一行到 hook.log, 自身追加失败时彻底静默,不影响 hook 返回。 - hook.ts main() 构造 client 时传入 logPath = dataDir/hook.log。 - handleStatus 输出 hook.log / daemon.log 路径,用户在 /memory-status 即可定位排障文件。 Signed-off-by: 李冠辰 --- claude-code-plugin/lib/gateway-client.ts | 76 +++++++++++++++++++----- claude-code-plugin/lib/hook.ts | 7 ++- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/claude-code-plugin/lib/gateway-client.ts b/claude-code-plugin/lib/gateway-client.ts index 47ba1c0..403e954 100644 --- a/claude-code-plugin/lib/gateway-client.ts +++ b/claude-code-plugin/lib/gateway-client.ts @@ -1,16 +1,21 @@ /** * HTTP client for the TDAI Gateway, with Bearer token authentication and * silent-failure semantics suitable for cc hook handlers (any error returns - * an empty / no-op response rather than throwing). + * an empty / no-op response rather than throwing). Failures are also + * appended to an optional log file so the daemon's health can be diagnosed + * via /memory-status without re-attaching a debugger. */ import http from "node:http"; +import { appendFile } from "node:fs/promises"; import { URL } from "node:url"; export interface GatewayClientConfig { baseUrl: string; token: string; timeoutMs?: number; + /** If set, every fallthrough error is appended here as one line. */ + logPath?: string; } export interface RecallResult { @@ -42,18 +47,40 @@ export class GatewayClient { private baseUrl: URL; private token: string; private timeoutMs: number; + private logPath?: string; constructor(config: GatewayClientConfig) { this.baseUrl = new URL(config.baseUrl); this.token = config.token; this.timeoutMs = config.timeoutMs ?? 5_000; + this.logPath = config.logPath; } - async health(): Promise { + private async logFailure(method: string, path: string, detail: string): Promise { + if (!this.logPath) return; try { - const { status } = await this.request("GET", "/health"); - return status === 200; + await appendFile( + this.logPath, + `[${new Date().toISOString()}] gateway-client ${method} ${path}: ${detail}\n`, + ); } catch { + // unable to log — nothing else we can do from a hook handler + } + } + + private describeStatus(status: number, body: string): string { + const trimmed = body.length > 200 ? body.slice(0, 200) + "…" : body; + return `HTTP ${status} ${trimmed}`; + } + + async health(): Promise { + try { + const { status, body } = await this.request("GET", "/health"); + if (status === 200) return true; + await this.logFailure("GET", "/health", this.describeStatus(status, body)); + return false; + } catch (err) { + await this.logFailure("GET", "/health", err instanceof Error ? err.message : String(err)); return false; } } @@ -64,14 +91,18 @@ export class GatewayClient { query, session_key: sessionKey, }); - if (status !== 200) return { context: "" }; + if (status !== 200) { + await this.logFailure("POST", "/recall", this.describeStatus(status, body)); + return { context: "" }; + } const parsed = JSON.parse(body) as RecallResult; return { context: parsed.context ?? "", strategy: parsed.strategy, memory_count: parsed.memory_count, }; - } catch { + } catch (err) { + await this.logFailure("POST", "/recall", err instanceof Error ? err.message : String(err)); return { context: "" }; } } @@ -79,9 +110,13 @@ export class GatewayClient { async captureTurn(payload: CaptureTurnPayload): Promise { try { const { status, body } = await this.request("POST", "/capture", payload); - if (status !== 200) return null; + if (status !== 200) { + await this.logFailure("POST", "/capture", this.describeStatus(status, body)); + return null; + } return JSON.parse(body) as CaptureTurnResult; - } catch { + } catch (err) { + await this.logFailure("POST", "/capture", err instanceof Error ? err.message : String(err)); return null; } } @@ -97,9 +132,13 @@ export class GatewayClient { type: opts?.type, scene: opts?.scene, }); - if (status !== 200) return { results: "", total: 0 }; + if (status !== 200) { + await this.logFailure("POST", "/search/memories", this.describeStatus(status, body)); + return { results: "", total: 0 }; + } return JSON.parse(body) as SearchResult; - } catch { + } catch (err) { + await this.logFailure("POST", "/search/memories", err instanceof Error ? err.message : String(err)); return { results: "", total: 0 }; } } @@ -114,18 +153,25 @@ export class GatewayClient { limit: opts?.limit, session_key: opts?.sessionKey, }); - if (status !== 200) return { results: "", total: 0 }; + if (status !== 200) { + await this.logFailure("POST", "/search/conversations", this.describeStatus(status, body)); + return { results: "", total: 0 }; + } return JSON.parse(body) as SearchResult; - } catch { + } catch (err) { + await this.logFailure("POST", "/search/conversations", err instanceof Error ? err.message : String(err)); return { results: "", total: 0 }; } } async sessionEnd(sessionKey: string): Promise { try { - await this.request("POST", "/session/end", { session_key: sessionKey }); - } catch { - // silent + const { status, body } = await this.request("POST", "/session/end", { session_key: sessionKey }); + if (status !== 200) { + await this.logFailure("POST", "/session/end", this.describeStatus(status, body)); + } + } catch (err) { + await this.logFailure("POST", "/session/end", err instanceof Error ? err.message : String(err)); } } diff --git a/claude-code-plugin/lib/hook.ts b/claude-code-plugin/lib/hook.ts index c0297c0..6f16f4c 100644 --- a/claude-code-plugin/lib/hook.ts +++ b/claude-code-plugin/lib/hook.ts @@ -275,7 +275,11 @@ async function handleSearchStdin(rawStdin: string, client: GatewayClient): Promi async function handleStatus(client: GatewayClient): Promise { const ok = await client.health(); - return ok ? "TDAI memory daemon: healthy" : "TDAI memory daemon: unreachable"; + const dataDir = resolveDataDir(); + const hookLog = join(dataDir, "hook.log"); + const daemonLog = join(dataDir, "daemon.log"); + const header = ok ? "TDAI memory daemon: healthy" : "TDAI memory daemon: unreachable"; + return `${header}\nhook log: ${hookLog}\ndaemon log: ${daemonLog}`; } async function handleClearSession(data: HookStdin, client: GatewayClient): Promise { @@ -454,6 +458,7 @@ async function main(): Promise { baseUrl: `http://127.0.0.1:${state.port}`, token, timeoutMs: event === "user-prompt-submit" ? 4_000 : 10_000, + logPath, }); const out = await handleHook(event, { stdin, client, args }); From 2dbca67367f8a923e06aa1a4b5ec0232283cb27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 21:47:58 +0800 Subject: [PATCH 13/17] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=A8=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E7=9F=A9=E9=98=B5=E3=80=81Stop=20cursor=20=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E4=B8=8E=20ccPid=20=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth.test.ts(5 → 14): - 鉴权对所有 POST 业务端点(/recall, /capture, /search/*, /session/end, /seed)的覆盖矩阵,避免回归把鉴权挪进个别 case 分支后逃逸; - Bearer scheme 关键字大小写 4 个变体(Bearer/bearer/BEARER/BeArEr) 按 RFC 6750 §2.1 全部应放行; - 7 种 mangled Authorization 头(Basic、空、带尾巴、prefix/suffix tamper) 全部期望 401; - 401 响应携带 WWW-Authenticate: Bearer realm=… 校验。 hook.test.ts stop(2 → 5): - 第二次 Stop 只发新 turn(cursor 增量); - 无新 turn 时 captureTurn 不被调用; - 60 turn 长 transcript 首次 capture 在 MAX_CAPTURE_TURNS=50 截断, 并取 LAST 50(user_content/assistant_content 是 q59/a59)。 daemon.test.ts: - ensureRunning 拒绝 ccPid 不匹配的旧 state.json,强制走 spawn 路径。 Signed-off-by: 李冠辰 --- claude-code-plugin/tests/daemon.test.ts | 34 +++++++ claude-code-plugin/tests/hook.test.ts | 119 ++++++++++++++++++++++++ src/gateway/__tests__/auth.test.ts | 49 +++++++++- 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/claude-code-plugin/tests/daemon.test.ts b/claude-code-plugin/tests/daemon.test.ts index f376b05..aaf98ec 100644 --- a/claude-code-plugin/tests/daemon.test.ts +++ b/claude-code-plugin/tests/daemon.test.ts @@ -113,3 +113,37 @@ describe("DaemonManager probe", () => { expect(await mgr.probe()).toBe(false); }); }); + +describe("DaemonManager ensureRunning ccPid mismatch", () => { + // Confirms reuseExisting refuses a state.json whose ccPid differs from the + // caller's ccPid — guards against picking up a daemon spawned by a different + // cc instance on a shared box. + it("does NOT reuse a daemon recorded for a foreign ccPid", async () => { + const tokenPath = join(dataDir, "token"); + await writeFile(tokenPath, "secret-foreign", { mode: 0o600 }); + await writeDaemonState(dataDir, { + pid: 12345, + port: 18999, // nothing actually listening here + ccPid: 999_999, // some other cc + startedAt: "2026-05-15T10:00:00Z", + tokenPath, + }); + + const mgr = new DaemonManager({ dataDir, portStart: 18500, portEnd: 18510 }); + // Stub spawn to a thin marker so we don't actually fork a daemon. + let spawnCalls = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mgr as any).spawn = async () => { + spawnCalls++; + return { + pid: 1, + port: 18500, + ccPid: process.pid, + startedAt: new Date().toISOString(), + tokenPath, + }; + }; + await mgr.ensureRunning(process.pid); + expect(spawnCalls).toBe(1); + }); +}); diff --git a/claude-code-plugin/tests/hook.test.ts b/claude-code-plugin/tests/hook.test.ts index ca7f250..564a709 100644 --- a/claude-code-plugin/tests/hook.test.ts +++ b/claude-code-plugin/tests/hook.test.ts @@ -204,6 +204,125 @@ describe("handleHook: stop", () => { await fs.unlink(tmp); } }); + + it("only sends new turns on the second Stop (cursor incremental capture)", async () => { + // Two-turn transcript, fire Stop once. Then append a third turn and fire + // Stop again. The second call must POST only the new turn — without the + // cursor a long session would re-write every turn on each Stop. + const captureTurn = vi.fn(async () => null); + const client = makeFakeClient({ + captureTurn, + } as Partial); + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const os = await import("node:os"); + const tmp = path.join(os.tmpdir(), `tx-cursor-${Date.now()}.jsonl`); + const lines = [ + '{"type":"user","message":{"role":"user","content":"q1"},"uuid":"u1"}', + '{"type":"assistant","message":{"role":"assistant","content":"a1"},"uuid":"a1"}', + '{"type":"user","message":{"role":"user","content":"q2"},"uuid":"u2"}', + '{"type":"assistant","message":{"role":"assistant","content":"a2"},"uuid":"a2"}', + ]; + await fs.writeFile(tmp, lines.join("\n")); + try { + const stdin = JSON.stringify({ + session_id: "cursor-test", + transcript_path: tmp, + cwd: "/tmp/proj", + stop_hook_active: false, + }); + await handleHook("stop", { stdin, client }); + expect(captureTurn).toHaveBeenCalledTimes(1); + const first = captureTurn.mock.calls[0][0]; + expect(first.messages).toHaveLength(4); // 2 turns × (user + assistant) + + // Append a third turn and fire Stop again. + await fs.appendFile( + tmp, + "\n" + + [ + '{"type":"user","message":{"role":"user","content":"q3"},"uuid":"u3"}', + '{"type":"assistant","message":{"role":"assistant","content":"a3"},"uuid":"a3"}', + ].join("\n"), + ); + await handleHook("stop", { stdin, client }); + expect(captureTurn).toHaveBeenCalledTimes(2); + const second = captureTurn.mock.calls[1][0]; + // Cursor should have skipped the first 2 turns — only q3/a3 sent. + expect(second.messages).toHaveLength(2); + expect(second.user_content).toBe("q3"); + expect(second.assistant_content).toBe("a3"); + } finally { + await fs.unlink(tmp); + } + }); + + it("skips captureTurn when no new turns since last cursor", async () => { + const captureTurn = vi.fn(async () => null); + const client = makeFakeClient({ + captureTurn, + } as Partial); + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const os = await import("node:os"); + const tmp = path.join(os.tmpdir(), `tx-nochange-${Date.now()}.jsonl`); + await fs.writeFile( + tmp, + [ + '{"type":"user","message":{"role":"user","content":"q"},"uuid":"u"}', + '{"type":"assistant","message":{"role":"assistant","content":"a"},"uuid":"a"}', + ].join("\n"), + ); + try { + const stdin = JSON.stringify({ + session_id: "nochange-test", + transcript_path: tmp, + cwd: "/tmp/proj", + stop_hook_active: false, + }); + await handleHook("stop", { stdin, client }); + await handleHook("stop", { stdin, client }); + // Second Stop sees the same transcript → cursor already at end → no call. + expect(captureTurn).toHaveBeenCalledTimes(1); + } finally { + await fs.unlink(tmp); + } + }); + + it("caps first capture at MAX_CAPTURE_TURNS (50) when transcript is long", async () => { + const captureTurn = vi.fn(async () => null); + const client = makeFakeClient({ + captureTurn, + } as Partial); + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const os = await import("node:os"); + const tmp = path.join(os.tmpdir(), `tx-cap-${Date.now()}.jsonl`); + const lines: string[] = []; + for (let i = 0; i < 60; i++) { + lines.push(`{"type":"user","message":{"role":"user","content":"q${i}"},"uuid":"u${i}"}`); + lines.push(`{"type":"assistant","message":{"role":"assistant","content":"a${i}"},"uuid":"a${i}"}`); + } + await fs.writeFile(tmp, lines.join("\n")); + try { + const stdin = JSON.stringify({ + session_id: "cap-test", + transcript_path: tmp, + cwd: "/tmp/proj", + stop_hook_active: false, + }); + await handleHook("stop", { stdin, client }); + expect(captureTurn).toHaveBeenCalledTimes(1); + const call = captureTurn.mock.calls[0][0]; + // Capped at 50 turns × (user + assistant) = 100 messages. + expect(call.messages).toHaveLength(100); + // Cap takes the LAST 50 turns; lastTurn is q59/a59. + expect(call.user_content).toBe("q59"); + expect(call.assistant_content).toBe("a59"); + } finally { + await fs.unlink(tmp); + } + }); }); describe("handleHook: post-tool-use", () => { diff --git a/src/gateway/__tests__/auth.test.ts b/src/gateway/__tests__/auth.test.ts index d538bce..083d3d6 100644 --- a/src/gateway/__tests__/auth.test.ts +++ b/src/gateway/__tests__/auth.test.ts @@ -6,10 +6,11 @@ async function request( port: number, path: string, headers: Record = {}, -): Promise<{ status: number; body: string }> { + method = "GET", +): Promise<{ status: number; body: string; wwwAuth: string | undefined }> { return new Promise((resolve, reject) => { const req = http.request( - { host: "127.0.0.1", port, path, method: "GET", headers }, + { host: "127.0.0.1", port, path, method, headers }, (res) => { const chunks: Buffer[] = []; res.on("data", (c) => chunks.push(c)); @@ -17,6 +18,7 @@ async function request( resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8"), + wwwAuth: res.headers["www-authenticate"] as string | undefined, }), ); }, @@ -68,6 +70,49 @@ describe("Gateway optional Bearer token", () => { expect(res.status).toBe(200); }); + it("includes WWW-Authenticate header on 401 per RFC 6750 §3", async () => { + const res = await request(PORT, "/health"); + expect(res.status).toBe(401); + expect(res.wwwAuth).toMatch(/^Bearer\s+realm=/); + }); + + it("accepts case-insensitive 'Bearer' scheme keyword per RFC 6750 §2.1", async () => { + for (const scheme of ["Bearer", "bearer", "BEARER", "BeArEr"]) { + const res = await request(PORT, "/health", { + Authorization: `${scheme} ${TOKEN}`, + }); + expect(res.status, `scheme=${scheme}`).toBe(200); + } + }); + + it("rejects mangled Authorization headers", async () => { + const cases = [ + `Basic ${TOKEN}`, + `Bearer`, + `Bearer `, + `Bearer ${TOKEN} extra`, + ``, + `Bearer ${TOKEN}x`, + `Bearer x${TOKEN}`, + ]; + for (const h of cases) { + const res = await request(PORT, "/health", { Authorization: h }); + expect(res.status, `auth=${JSON.stringify(h)}`).toBe(401); + } + }); + + it.each([ + ["POST", "/recall"], + ["POST", "/capture"], + ["POST", "/search/memories"], + ["POST", "/search/conversations"], + ["POST", "/session/end"], + ["POST", "/seed"], + ])("enforces auth on %s %s (no token → 401)", async (method, path) => { + const res = await request(PORT, path, {}, method); + expect(res.status).toBe(401); + }); + it("allows OPTIONS preflight without token (CORS)", async () => { return new Promise((resolve, reject) => { const req = http.request( From d1bf429e71c392855ea56ae6bcc2e7b323f90a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 21:50:25 +0800 Subject: [PATCH 14/17] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E6=9C=AC?= =?UTF-8?q?=E8=BD=AE=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=E4=B8=8E=20hook?= =?UTF-8?q?=20=E8=A1=8C=E4=B8=BA=E5=88=B0=20CHANGELOG=20+=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG Unreleased 段补充本轮 6 个 commit 的安全增强(timing-safe Bearer、Bearer scheme 大小写、WWW-Authenticate、host 校验、TOKEN_PATH、 spawn lock、ccPid 校验、state.json 原子写、Windows 兼容)、修复 (L0 重复写入 / CJK 召回退化 / 800ms 改轮询 / streaming 读 jsonl / silent-failure 日志)、测试补全(auth 矩阵 / cursor 增量 / ccPid 校验)。 - claude-code-plugin/README.md + README_CN.md:环境变量表新增 TDAI_TOKEN_PATH / TDAI_GATEWAY_HOST / TDAI_GATEWAY_ALLOW_REMOTE / TDAI_GATEWAY_CORS_ORIGIN;Data location 新增 spawn.lock / cursors/ / daemon.log;排障章节加 daemon.log 索引;安全模型重写覆盖 timing-safe、 token 文件传递、heredoc 绕开 \$ARGUMENTS、Windows ACL 兜底等要点。 Signed-off-by: 李冠辰 --- CHANGELOG.md | 24 +++++++++++++++++++++--- claude-code-plugin/README.md | 25 ++++++++++++++++++------- claude-code-plugin/README_CN.md | 25 ++++++++++++++++++------- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 940768c..cd9ba70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,30 @@ ### 🔧 兼容性 / 安全增强 -- **Gateway 可选 Bearer Token 鉴权**:当设置 `TDAI_GATEWAY_TOKEN` 环境变量时,Gateway 要求所有非 OPTIONS 请求带 `Authorization: Bearer `。未设置时行为不变,与 Hermes 完全向后兼容。Claude Code 插件每次 spawn daemon 时生成随机 256-bit token 写入权限 0600 文件。 -- **新增 `tdai-memory-gateway` bin**(`./dist/src/gateway/cli.mjs`):作为独立可执行 Gateway entry point,支持 `SIGTERM/SIGINT` 优雅关闭、可选父进程 PID liveness 探活(`TDAI_CC_PID` 环境变量)。供 Claude Code / Codex CLI 插件通过 `npx tdai-memory-gateway` 调用,无需把 npm 依赖打包进插件。 +- **Gateway 可选 Bearer Token 鉴权**:当设置 `TDAI_GATEWAY_TOKEN` 环境变量时,Gateway 要求所有非 OPTIONS 请求带 `Authorization: Bearer `。未设置时行为不变,与 Hermes 完全向后兼容。Claude Code 插件每次 spawn daemon 时生成随机 256-bit token 写入权限 0600 文件。Bearer 字符串比较升级为 `crypto.timingSafeEqual`,Scheme 关键字按 RFC 6750 §2.1 大小写不敏感匹配(`Bearer`/`bearer`/`BEARER` 均可),401 响应携带 `WWW-Authenticate: Bearer realm="tdai-gateway"`。 +- **Token 通过文件路径(`TDAI_TOKEN_PATH`)传递给 daemon 子进程**,不再注入到 `TDAI_GATEWAY_TOKEN` 环境变量。后者会随 execve() 写入子进程初始 environment block,使 token 暴露于 `/proc//environ` 与 `ps -E`;改为文件传递后只剩 0o600 token 文件这一面,daemon 加载时还会校验文件 owner uid。 +- **daemon 主机绑定加固**:cli.ts 启动时拒绝非 loopback 的 `TDAI_GATEWAY_HOST`,除非显式 `TDAI_GATEWAY_ALLOW_REMOTE=1` 打开开关;防止误把记忆端口曝露到 LAN/公网。 +- **新增 `tdai-memory-gateway` bin**(`./dist/src/gateway/cli.mjs`):作为独立可执行 Gateway entry point,支持 `SIGTERM/SIGINT` 优雅关闭、可选父进程 PID liveness 探活(`TDAI_CC_PID` 环境变量,轮询间隔 15s)。供 Claude Code / Codex CLI 插件通过 `npx tdai-memory-gateway` 调用,无需把 npm 依赖打包进插件。 +- **daemon 进程管理重写**:基于 `O_CREAT|O_EXCL` 的 `spawn.lock` 互斥,并发触发的 SessionStart / UserPromptSubmit / Stop hook 中只有一个会真正 spawn,其余复用结果,根本性解决双 daemon / 端口与 token 错配问题;`state.json` 改 tmp + rename 原子写;`ensureRunning` 复用旧 daemon 前校验 `state.ccPid` 与当前 cc 一致,避免跨用户/跨会话错用旧 daemon;spawn 时显式设置 `cwd` 与 `TDAI_DATA_DIR` 注入,避免数据目录受 hook 进程 cwd 漂移影响;token 文件权限校验在 Windows 上跳过 `0o077` 位检测(Node `fs` 在 Win 下返回固定 mode 会误报),改用 NTFS ACL。 +- **`$ARGUMENTS` 命令注入面收敛**:cc 当前对 SKILL.md ``!`...` `` 块内的 `$ARGUMENTS` 执行字面 `replaceAll`,用户输入 `foo"; curl evil; "` 可注入到 shell(详见 anthropics/claude-code#16163)。重写 `memory-search/SKILL.md` 去掉 ``!`...` `` bash 块,改为引导 Claude 以 heredoc 通过 Bash 工具向 `hook.mjs search-stdin` 的 stdin 喂查询,用户输入不再经过 shell 词法解析。 + +### 🐛 修复 + +- **Stop hook 反复重写 L0**:之前每次 Stop 都向 `/capture` 全量发送最近 10 个 turn,而 Gateway 端 `originalUserMessageCount` 位置切片与 `afterTimestamp` 游标都缺失(`CaptureRequest` 不携带这两个字段),导致长会话前 N 个 turn 在每次 Stop 时反复写入 L0,污染 FTS5 与向量索引。改为基于 `$CLAUDE_PLUGIN_DATA/cursors/.json` 持久化的 `lastSentIndex` 取增量,首次发送以 50 turn 封顶,cursor 文件 tmp + rename 原子写。 +- **CJK 召回退化**:底层 2-gram 停用词表此前包含 `我们/你们/他们/这个/那个/可以/有没/没有/就是/不是` 等普通双字实义词,"我们的部署方案" 被切成 `[们的, 的部, 部署, 署方, 方案]`、丢失 "我们" 锚点 token,中文查询召回受损。停用词表缩到真正低信息量的疑问/连接片段。 +- **transcript 等待逻辑**:Stop hook 等待 cc 落盘从硬 sleep(800ms) 改为 `waitForTranscriptStable(2s)`:每 100ms 轮询 `stat().size`,连续两次相同字节数即视为 flush 完成;慢盘场景更稳。 +- **L0 jsonl 直查内存压力**:`searchL0JsonlDirect` 从 `readFile` 整体加载改为 `readline + createReadStream` 流式扫描,避免长会话 jsonl 触发 OOM;文件遍历从字符串排序+reverse(依赖 `YYYY-MM-DD.jsonl` 命名)改为 mtime 倒序,对 cc UUID 命名也工作正常。 +- **GatewayClient silent-failure 可观测**:所有 catch 块新增 `logPath` 失败追加,handleStatus 在 `/memory-status` 输出 `hook.log` / `daemon.log` 路径;daemon spawn 的 stdio stderr 重定向到 `daemon.log` 替代静默丢弃。 + +### ✅ 测试 + +- `auth.test.ts`:从 5 个 case 扩展到 14 个,覆盖鉴权对所有 POST 业务端点的矩阵、Bearer scheme 大小写、mangled Authorization 头、`WWW-Authenticate` 响应。 +- `hook.test.ts`:新增 cursor 增量、无新 turn 跳过 captureTurn、`MAX_CAPTURE_TURNS=50` 边界 3 个 case,且把 stop describe 整体 stub `CLAUDE_PLUGIN_DATA` 到 mkdtemp 隔离 cursor 状态。 +- `daemon.test.ts`:新增 `ensureRunning` 拒绝 ccPid 不匹配旧 state 的回归。 ### 📚 文档 -- `claude-code-plugin/README.md` 与 `README_CN.md`:安装、配置、数据布局、排障与安全模型完整说明。 +- `claude-code-plugin/README.md` 与 `README_CN.md`:安装、配置、数据布局、排障与安全模型完整说明,新增 `TDAI_TOKEN_PATH` / `TDAI_GATEWAY_ALLOW_REMOTE` / `TDAI_GATEWAY_CORS_ORIGIN` / Windows 兼容性说明。 --- diff --git a/claude-code-plugin/README.md b/claude-code-plugin/README.md index ae154d9..d81f0ac 100644 --- a/claude-code-plugin/README.md +++ b/claude-code-plugin/README.md @@ -47,22 +47,29 @@ No `~/.claude/settings.json` or `~/.codex/config.toml` mutation. The first time ## Configuration -The plugin reads three optional environment variables: +The plugin reads these optional environment variables: | Variable | Default | Purpose | |---|---|---| | `TDAI_SESSION_KEY` | `hash(cwd)` | Override the per-project memory partition | -| `TDAI_GATEWAY_TOKEN` | auto-generated | Bearer token for daemon ↔ hook IPC | +| `TDAI_TOKEN_PATH` | auto-generated 0o600 file | Path to a file the daemon reads the Bearer token from (preferred over `TDAI_GATEWAY_TOKEN`; the env-var form puts the token into `/proc//environ` and `ps -E`) | +| `TDAI_GATEWAY_TOKEN` | unset | Bearer token via env (fallback for the Hermes sidecar mode) | +| `TDAI_GATEWAY_HOST` | `127.0.0.1` | Daemon bind host. Non-loopback values are refused unless `TDAI_GATEWAY_ALLOW_REMOTE=1` is set, to avoid exposing the memory port to the LAN. | +| `TDAI_GATEWAY_ALLOW_REMOTE` | unset | Opt-in switch required to bind a non-loopback `TDAI_GATEWAY_HOST` | +| `TDAI_GATEWAY_CORS_ORIGIN` | unset | When set, enables CORS with the given Origin; the default disables CORS so cross-origin pages cannot probe the daemon's port. | | `TDAI_GATEWAY_COMMAND` | `npx` | Override daemon spawn command (advanced; e.g. `node /path/to/cli.mjs` for development) | Most users never need to set any of these. `TDAI_SESSION_KEY=shared-with-other-project` is the most common power-user override. ## Data location -- `${CLAUDE_PLUGIN_DATA}/state.json` — daemon PID + port -- `${CLAUDE_PLUGIN_DATA}/token` — Bearer token (chmod 600) +- `${CLAUDE_PLUGIN_DATA}/state.json` — daemon PID + port (tmp+rename atomic) +- `${CLAUDE_PLUGIN_DATA}/token` — Bearer token (chmod 600, owner-uid checked) +- `${CLAUDE_PLUGIN_DATA}/spawn.lock` — O_CREAT|O_EXCL daemon-spawn mutex (stale after 60s) +- `${CLAUDE_PLUGIN_DATA}/cursors/.json` — per-cc-session `lastSentIndex` so Stop only POSTs new turns - `${CLAUDE_PLUGIN_DATA}/memory-tdai/` — SQLite + sqlite-vec database, scene blocks, persona snapshots -- `${CLAUDE_PLUGIN_DATA}/hook.log` — hook diagnostic log +- `${CLAUDE_PLUGIN_DATA}/hook.log` — hook diagnostic log (gateway-client failures, etc.) +- `${CLAUDE_PLUGIN_DATA}/daemon.log` — daemon stderr/stdout (cold-start crashes, etc.) ## How it works @@ -77,7 +84,7 @@ All hook handlers fail silently (writing to `hook.log`) — memory is never on t ## Troubleshooting **`/memory-status` says "unreachable"**: -- Check `${CLAUDE_PLUGIN_DATA}/hook.log` for the most recent error +- Check `${CLAUDE_PLUGIN_DATA}/hook.log` (gateway-client request failures) and `${CLAUDE_PLUGIN_DATA}/daemon.log` (daemon cold-start crashes) - Restart your cc session — the SessionStart hook re-probes and re-spawns the daemon **Multiple cc terminals on the same project**: @@ -89,7 +96,11 @@ All hook handlers fail silently (writing to `hook.log`) — memory is never on t ## Security model -The daemon listens only on `127.0.0.1` and requires a Bearer token on every request. The token is generated freshly at each spawn and stored at `${CLAUDE_PLUGIN_DATA}/token` with permission 0600. Any process that cannot read that file cannot read your memories. +- The daemon listens only on `127.0.0.1` by default. Non-loopback `TDAI_GATEWAY_HOST` is refused unless `TDAI_GATEWAY_ALLOW_REMOTE=1` is also set. +- Every request requires `Authorization: Bearer `. Comparison is timing-safe; the scheme keyword is RFC 6750 §2.1 case-insensitive; 401 responses include `WWW-Authenticate: Bearer realm="tdai-gateway"`. +- The token is generated freshly at each daemon spawn, written to `${CLAUDE_PLUGIN_DATA}/token` (chmod 600), and passed to the daemon child process **by file path** (`TDAI_TOKEN_PATH`) rather than as an env var, so the token does not surface via `/proc//environ` or `ps -E`. Token-file owner is checked against the current uid on read. +- The `memory-search` skill passes the user query to the daemon over **stdin** via a heredoc, never as a shell argv element — this avoids the literal-`replaceAll` `$ARGUMENTS` injection surface in cc (anthropics/claude-code#16163). +- On Windows the 0o077 mode check is skipped (Node's `fs` returns fixed mode bits there); the OS-provided NTFS ACL on the token file is relied on instead. ## Building from source diff --git a/claude-code-plugin/README_CN.md b/claude-code-plugin/README_CN.md index 5a57184..91c2b9a 100644 --- a/claude-code-plugin/README_CN.md +++ b/claude-code-plugin/README_CN.md @@ -47,22 +47,29 @@ codex plugin marketplace add ## 配置 -插件读取三个可选环境变量: +插件读取以下可选环境变量: | 变量 | 默认值 | 作用 | |---|---|---| | `TDAI_SESSION_KEY` | `hash(cwd)` | 覆盖项目级记忆分区 | -| `TDAI_GATEWAY_TOKEN` | 自动生成 | daemon ↔ hook IPC 的 Bearer token | +| `TDAI_TOKEN_PATH` | 自动生成的 0o600 文件 | daemon 从该文件读取 Bearer token(优于 `TDAI_GATEWAY_TOKEN`,后者会把 token 写进 `/proc//environ` 与 `ps -E` 可见的环境块) | +| `TDAI_GATEWAY_TOKEN` | 未设置 | 通过环境变量传 Bearer token(Hermes sidecar 模式的兼容方式) | +| `TDAI_GATEWAY_HOST` | `127.0.0.1` | daemon 绑定地址。非 loopback 值需同时设置 `TDAI_GATEWAY_ALLOW_REMOTE=1`,否则启动被拒,防止误把记忆端口曝露到 LAN。 | +| `TDAI_GATEWAY_ALLOW_REMOTE` | 未设置 | 显式开关,允许 daemon 绑定非 loopback host | +| `TDAI_GATEWAY_CORS_ORIGIN` | 未设置 | 设置时按给定 Origin 启用 CORS;默认不启用,避免跨源页面探测 daemon 端口。 | | `TDAI_GATEWAY_COMMAND` | `npx` | 覆盖 daemon 启动命令(高级用法;如 `node /path/to/cli.mjs` 用于本地开发) | 大多数用户都不需要设置任何变量。`TDAI_SESSION_KEY=shared-with-other-project` 是最常用的高级用法。 ## 数据位置 -- `${CLAUDE_PLUGIN_DATA}/state.json` — daemon PID + 端口 -- `${CLAUDE_PLUGIN_DATA}/token` — Bearer token(chmod 600) +- `${CLAUDE_PLUGIN_DATA}/state.json` — daemon PID + 端口(tmp+rename 原子写) +- `${CLAUDE_PLUGIN_DATA}/token` — Bearer token(chmod 600,读取时校验 owner uid) +- `${CLAUDE_PLUGIN_DATA}/spawn.lock` — O_CREAT|O_EXCL daemon 启动互斥锁(60s 后视为陈旧) +- `${CLAUDE_PLUGIN_DATA}/cursors/.json` — 每个 cc 会话的 `lastSentIndex`,Stop hook 增量发送依赖 - `${CLAUDE_PLUGIN_DATA}/memory-tdai/` — SQLite + sqlite-vec 数据、场景块、画像快照 -- `${CLAUDE_PLUGIN_DATA}/hook.log` — hook 排障日志 +- `${CLAUDE_PLUGIN_DATA}/hook.log` — hook 排障日志(gateway-client 请求失败等) +- `${CLAUDE_PLUGIN_DATA}/daemon.log` — daemon stderr/stdout(冷启动 crash 等) ## 工作原理 @@ -77,7 +84,7 @@ cc 回复 → Stop hook → POST /capture → L0 + L1/L2/L3 流 ## 排障 **`/memory-status` 显示 "unreachable"**: -- 看 `${CLAUDE_PLUGIN_DATA}/hook.log` 最近的错误 +- 看 `${CLAUDE_PLUGIN_DATA}/hook.log`(gateway-client 请求失败)与 `${CLAUDE_PLUGIN_DATA}/daemon.log`(daemon 冷启动 crash) - 重启 cc 会话——SessionStart hook 会重新探活并 spawn daemon **多个 cc 终端开同一个项目**: @@ -89,7 +96,11 @@ cc 回复 → Stop hook → POST /capture → L0 + L1/L2/L3 流 ## 安全模型 -Daemon 仅监听 `127.0.0.1`,每个请求都需要 Bearer token。Token 在每次 spawn 时新生成,存放在 `${CLAUDE_PLUGIN_DATA}/token`,权限 0600。读不到这个文件的进程读不到你的记忆。 +- Daemon 默认仅监听 `127.0.0.1`。非 loopback `TDAI_GATEWAY_HOST` 必须同时设置 `TDAI_GATEWAY_ALLOW_REMOTE=1` 才允许绑定。 +- 每个请求都需要 `Authorization: Bearer `。比较使用 `crypto.timingSafeEqual`,scheme 关键字按 RFC 6750 §2.1 大小写不敏感;401 响应携带 `WWW-Authenticate: Bearer realm="tdai-gateway"`。 +- Token 在每次 daemon spawn 时新生成,写入 `${CLAUDE_PLUGIN_DATA}/token`(chmod 600),通过 **文件路径** `TDAI_TOKEN_PATH` 传给 daemon 子进程,而不是注入到子进程环境变量——避免 token 出现在 `/proc//environ` 与 `ps -E`。daemon 读取 token 时还会校验文件 owner uid 与当前进程一致。 +- `memory-search` skill 通过 heredoc 把用户 query 喂到 daemon stdin,而不是作为 shell argv 元素拼接——绕开 cc 当前对 `$ARGUMENTS` 的字面 `replaceAll` 注入面(anthropics/claude-code#16163)。 +- Windows 下跳过 0o077 mode 位校验(Node `fs` 在 Win 下返回固定 mode 位会误报),改为依赖 OS 给 token 文件的 NTFS ACL。 ## 源码构建 From babcabc48b4e262a6507991a6d5127e839fd3daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 21:57:21 +0800 Subject: [PATCH 15/17] =?UTF-8?q?chore:=20gitignore=20=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md 用于在仓库内向 Claude Code 声明本项目的协作约定 (DCO 签名、改动范围、测试要求等),但内容是贡献者本地视角, 不应作为仓库历史的一部分。 Signed-off-by: 李冠辰 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8f5228c..44f3297 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,8 @@ test-offload-sessions.sh # Local development notes (contributor-only, not shipped) docs/superpowers/ +# Per-developer Claude Code project instructions (contributor-only) +CLAUDE.md + # Plugin build output claude-code-plugin/dist/ From 088edc24d1e3f613761521f431dea8f221de271b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 23:03:19 +0800 Subject: [PATCH 16/17] =?UTF-8?q?fix(plugin):=20=E8=A1=A5=E5=85=A8=20.code?= =?UTF-8?q?x-plugin=20manifest=20=E7=9A=84=20hooks=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=B9=B6=E6=A0=87=E6=B3=A8=E4=B8=8A=E6=B8=B8=E5=B7=B2=E7=9F=A5?= =?UTF-8?q?=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex CLI 与 Claude Code 的 plugin-local hooks 加载机制不同:cc 走"约定俗成路径" (自动加载 hooks/hooks.json),Codex 则强制从 manifest 的 hooks 字段读取 (codex-rs/core-plugins/src/manifest.rs::RawPluginManifest)。补上 "hooks": "./hooks/hooks.json" 字段后,三个 hook 与现有 ${CLAUDE_PLUGIN_ROOT} 环境变量在 Codex 侧均原生兼容(discovery.rs 注入 CLAUDE_PLUGIN_ROOT backcompat alias,同时配 PLUGIN_ROOT 新名)。 同时在 README EN/CN 与 CHANGELOG 标注:Codex CLI ≤ v0.130 通过 source_type=local marketplace 安装时,受上游 openai/codex#22078 影响 skills/hooks 不会暴露到 session;插件这一侧 manifest 已就绪,等上游修复或本插件正式发布到 git marketplace 即恢复。 Signed-off-by: 李冠辰 --- CHANGELOG.md | 2 ++ claude-code-plugin/.codex-plugin/plugin.json | 1 + claude-code-plugin/README.md | 2 ++ claude-code-plugin/README_CN.md | 2 ++ 4 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9ba70..bddb8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - **transcript 等待逻辑**:Stop hook 等待 cc 落盘从硬 sleep(800ms) 改为 `waitForTranscriptStable(2s)`:每 100ms 轮询 `stat().size`,连续两次相同字节数即视为 flush 完成;慢盘场景更稳。 - **L0 jsonl 直查内存压力**:`searchL0JsonlDirect` 从 `readFile` 整体加载改为 `readline + createReadStream` 流式扫描,避免长会话 jsonl 触发 OOM;文件遍历从字符串排序+reverse(依赖 `YYYY-MM-DD.jsonl` 命名)改为 mtime 倒序,对 cc UUID 命名也工作正常。 - **GatewayClient silent-failure 可观测**:所有 catch 块新增 `logPath` 失败追加,handleStatus 在 `/memory-status` 输出 `hook.log` / `daemon.log` 路径;daemon spawn 的 stdio stderr 重定向到 `daemon.log` 替代静默丢弃。 +- **Codex CLI plugin 端 hooks 注册补全**:`.codex-plugin/plugin.json` 之前只声明了 `"skills": "./skills/"`,缺 `"hooks": "./hooks/hooks.json"` —— Codex CLI 与 Claude Code 不同,plugin-local hooks 不走"约定俗成路径",而是强制从 manifest 的 `hooks` 字段读取(见 `codex-rs/core-plugins/src/manifest.rs::RawPluginManifest`)。补上字段后,已声明的 `SessionStart`/`UserPromptSubmit`/`Stop` 三个 hook 与现有 `${CLAUDE_PLUGIN_ROOT}` env var 在 Codex 侧均原生兼容(Codex `hooks/src/engine/discovery.rs` 注入了 `CLAUDE_PLUGIN_ROOT` backcompat alias,同时配 `PLUGIN_ROOT` 新名)。 ### ✅ 测试 @@ -36,6 +37,7 @@ ### 📚 文档 - `claude-code-plugin/README.md` 与 `README_CN.md`:安装、配置、数据布局、排障与安全模型完整说明,新增 `TDAI_TOKEN_PATH` / `TDAI_GATEWAY_ALLOW_REMOTE` / `TDAI_GATEWAY_CORS_ORIGIN` / Windows 兼容性说明。 +- `claude-code-plugin/README.md` 与 `README_CN.md`:Codex CLI 安装段下新增"已知限制"小节,标注 Codex CLI ≤ v0.130 通过 `source_type = "local"` marketplace 安装时受上游 [openai/codex#22078](https://github.com/openai/codex/issues/22078) 影响,`skills/` 与 `hooks/` 不会暴露到 session;插件这一侧 manifest + hook 协议已就绪,等上游修复或本插件正式发布到 `source_type = "git"` marketplace 后即恢复。 --- diff --git a/claude-code-plugin/.codex-plugin/plugin.json b/claude-code-plugin/.codex-plugin/plugin.json index 633ce0b..6527bc6 100644 --- a/claude-code-plugin/.codex-plugin/plugin.json +++ b/claude-code-plugin/.codex-plugin/plugin.json @@ -20,6 +20,7 @@ "scene-extraction" ], "skills": "./skills/", + "hooks": "./hooks/hooks.json", "interface": { "displayName": "TDAI Memory", "shortDescription": "Long-term + short-term memory for AI coding agents", diff --git a/claude-code-plugin/README.md b/claude-code-plugin/README.md index d81f0ac..a2cd8be 100644 --- a/claude-code-plugin/README.md +++ b/claude-code-plugin/README.md @@ -41,6 +41,8 @@ codex plugin marketplace add (Once published to the Codex marketplace, this becomes a one-liner.) +> **Known limitation (Codex CLI ≤ v0.130).** Installing this plugin from a `source_type = "local"` marketplace is currently affected by Codex upstream issue [openai/codex#22078](https://github.com/openai/codex/issues/22078): the plugin manifest is parsed and the plugin is toggleable in `/plugin`, but the declared `skills/` and `hooks/hooks.json` are not exposed to the running session. This is a Codex-side discovery bug unrelated to the plugin itself — our `.codex-plugin/plugin.json` already declares both `skills` and `hooks`, and Codex accepts the existing hook event names + `${CLAUDE_PLUGIN_ROOT}` env var via its `hooks/src/engine/discovery.rs` backcompat path. Workaround: use Claude Code for now, or wait for the upcoming `source_type = "git"` marketplace publication. + --- No `~/.claude/settings.json` or `~/.codex/config.toml` mutation. The first time a session starts after installation, the plugin spawns the local daemon (via `npx tdai-memory-gateway`) on port 8421–8430 with a randomly generated Bearer token. State persists under `${CLAUDE_PLUGIN_DATA}`. diff --git a/claude-code-plugin/README_CN.md b/claude-code-plugin/README_CN.md index 91c2b9a..e2ccded 100644 --- a/claude-code-plugin/README_CN.md +++ b/claude-code-plugin/README_CN.md @@ -41,6 +41,8 @@ codex plugin marketplace add (一旦发布到 Codex marketplace,将变为一条命令安装。) +> **已知限制(Codex CLI ≤ v0.130)。** 通过 `source_type = "local"` 的本地 marketplace 安装本插件,目前受 Codex 上游 issue [openai/codex#22078](https://github.com/openai/codex/issues/22078) 影响:插件 manifest 能被正确解析、`/plugin` 列表里可见并可切换,但声明的 `skills/` 与 `hooks/hooks.json` 不会暴露到运行中的 session。这是 Codex 侧的 plugin discovery bug,与本插件无关——我们的 `.codex-plugin/plugin.json` 已经同时声明了 `skills` 与 `hooks`,且现有 hook 事件名与 `${CLAUDE_PLUGIN_ROOT}` 环境变量在 Codex `hooks/src/engine/discovery.rs` 的 backcompat 分支里已经支持。绕过办法:暂用 Claude Code 安装,或等本插件发布到 `source_type = "git"` marketplace。 + --- 不需要改 `~/.claude/settings.json` 或 `~/.codex/config.toml`。第一次启动 session 时,插件通过 `npx tdai-memory-gateway` 在 8421–8430 端口拉起 daemon,并生成随机 Bearer token。状态保存在 `${CLAUDE_PLUGIN_DATA}`。 From 33e37962272948d469b9cf46934882127fff672a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 15 May 2026 23:27:23 +0800 Subject: [PATCH 17/17] =?UTF-8?q?docs:=20=E6=8A=8A=20Codex=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E7=8A=B6=E6=80=81=E4=BB=8E"=E5=B7=B2=E5=B0=B1?= =?UTF-8?q?=E7=BB=AA"=E9=99=8D=E7=BA=A7=E4=B8=BA"=E4=B8=89=E5=B1=82=20bloc?= =?UTF-8?q?ker=20=E9=83=A8=E5=88=86=E9=98=BB=E5=A1=9E"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前文档与 CHANGELOG 把 Codex 适配描述为"hook 协议对齐 / manifest + hook 已就绪 / 等 #22078 修复后即恢复"——这是过度乐观,掩盖了两个 plugin 端的隐藏 blocker。 实际状态: 1. discovery 层(上游阻塞):openai/codex#22078,hook 根本不触发; 2. async 行为层(Codex 未实现):Codex 解析 `async` 字段但 hook engine 中 没有任何代码消费 `r#async`,HookRunSummary 硬编码 Sync;cc 端 SessionStart/ Stop 的 `async: true + timeout: 30` 在 Codex 上会变成同步 30s 阻塞; 3. transcript 解析层(plugin 端未适配):Codex rollout jsonl schema `{timestamp,type,payload}` 与 cc transcript `{type,message,sessionId,...}` 完全不同,当前 lib/transcript.ts 仅解析 cc 格式,即使 Stop 触发也会静默 生成空 capture。 README EN/CN 重写 "Codex CLI 当前状态:部分阻塞" 小节列出三层 blocker;顶部 "双宿主对齐"陈述降级为 "schema 层对齐 + Codex 部分阻塞";CHANGELOG Unreleased 对应条目同步调整。Codex 适配的真正运行时验证留到上游 #22078 修复后做(届时 预计追加 hooks/codex-hooks.json 差异化 timeout 与 Codex transcript parser)。 Signed-off-by: 李冠辰 --- CHANGELOG.md | 11 ++++++++--- claude-code-plugin/README.md | 12 ++++++++++-- claude-code-plugin/README_CN.md | 12 ++++++++++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bddb8b3..38c2f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### 📦 新功能 -- **Claude Code + Codex CLI 插件**(`claude-code-plugin/`):通过 Claude Code `/plugin install tdai-memory` 或 Codex CLI marketplace 一键启用,不修改用户 `~/.claude/settings.json` 或 `~/.codex/config.toml`。提供 3 个 hooks(`SessionStart` 异步预热、`UserPromptSubmit` 同步召回并通过 `additionalContext` 注入、`Stop` 异步捕获),3 个 slash skills(`/memory-search`、`/memory-status`、`/memory-clear-session`),以及一个总览 skill `tdai-memory`。Daemon 通过 `gateway-entry.ts` wrapper 绑定父进程生命周期。插件携带双 manifest(`.claude-plugin/plugin.json` 与 `.codex-plugin/plugin.json`),共享同一份 `hooks/hooks.json` 与 `skills/` —— cc v2026.4+ 与 Codex CLI v0.117+ 实现了对齐的 hook 协议,因此一套源码同时服务两个宿主。 +- **Claude Code + Codex CLI 插件**(`claude-code-plugin/`):通过 Claude Code `/plugin install tdai-memory` 或 Codex CLI marketplace 一键启用,不修改用户 `~/.claude/settings.json` 或 `~/.codex/config.toml`。提供 3 个 hooks(`SessionStart` 异步预热、`UserPromptSubmit` 同步召回并通过 `additionalContext` 注入、`Stop` 异步捕获),3 个 slash skills(`/memory-search`、`/memory-status`、`/memory-clear-session`),以及一个总览 skill `tdai-memory`。Daemon 通过 `gateway-entry.ts` wrapper 绑定父进程生命周期。插件携带双 manifest(`.claude-plugin/plugin.json` 与 `.codex-plugin/plugin.json`),共享同一份 `hooks/hooks.json` 与 `skills/`。Claude Code(v2026.4+)是当前的一等宿主,端到端完整可用;Codex CLI(v0.130+)在 schema 层(hook 事件名、handler config 字段、`${CLAUDE_PLUGIN_ROOT}` 环境变量)已对齐,但当前部分阻塞——三层 blocker 详见 `claude-code-plugin/README.md`(discovery 层 [openai/codex#22078](https://github.com/openai/codex/issues/22078)、`async` 行为层 Codex 未实现、transcript 解析层只支持 cc 格式)。 ### 🔧 兼容性 / 安全增强 @@ -26,7 +26,7 @@ - **transcript 等待逻辑**:Stop hook 等待 cc 落盘从硬 sleep(800ms) 改为 `waitForTranscriptStable(2s)`:每 100ms 轮询 `stat().size`,连续两次相同字节数即视为 flush 完成;慢盘场景更稳。 - **L0 jsonl 直查内存压力**:`searchL0JsonlDirect` 从 `readFile` 整体加载改为 `readline + createReadStream` 流式扫描,避免长会话 jsonl 触发 OOM;文件遍历从字符串排序+reverse(依赖 `YYYY-MM-DD.jsonl` 命名)改为 mtime 倒序,对 cc UUID 命名也工作正常。 - **GatewayClient silent-failure 可观测**:所有 catch 块新增 `logPath` 失败追加,handleStatus 在 `/memory-status` 输出 `hook.log` / `daemon.log` 路径;daemon spawn 的 stdio stderr 重定向到 `daemon.log` 替代静默丢弃。 -- **Codex CLI plugin 端 hooks 注册补全**:`.codex-plugin/plugin.json` 之前只声明了 `"skills": "./skills/"`,缺 `"hooks": "./hooks/hooks.json"` —— Codex CLI 与 Claude Code 不同,plugin-local hooks 不走"约定俗成路径",而是强制从 manifest 的 `hooks` 字段读取(见 `codex-rs/core-plugins/src/manifest.rs::RawPluginManifest`)。补上字段后,已声明的 `SessionStart`/`UserPromptSubmit`/`Stop` 三个 hook 与现有 `${CLAUDE_PLUGIN_ROOT}` env var 在 Codex 侧均原生兼容(Codex `hooks/src/engine/discovery.rs` 注入了 `CLAUDE_PLUGIN_ROOT` backcompat alias,同时配 `PLUGIN_ROOT` 新名)。 +- **Codex CLI plugin 端 hooks 注册补全**:`.codex-plugin/plugin.json` 之前只声明了 `"skills": "./skills/"`,缺 `"hooks": "./hooks/hooks.json"` —— Codex CLI 与 Claude Code 不同,plugin-local hooks 不走"约定俗成路径",而是强制从 manifest 的 `hooks` 字段读取(见 `codex-rs/core-plugins/src/manifest.rs::RawPluginManifest`)。补上字段后 schema 层全部对齐:`SessionStart`/`UserPromptSubmit`/`Stop` 事件名、`command`/`timeout`/`statusMessage` handler 字段、`${CLAUDE_PLUGIN_ROOT}` 环境变量在 Codex 端都能解析(discovery.rs 注入了 `CLAUDE_PLUGIN_ROOT` backcompat alias,同时配 `PLUGIN_ROOT` 新名)。**注意 schema 层兼容 ≠ runtime 行为对齐**:Codex 解析 `async` 字段但实际硬编码为 sync 执行(`HookExecutionMode::Sync`,`core/src/hook_runtime.rs` 与 `hooks/src/engine/` 都没有消费 `r#async` 字段的代码),与 cc 的真异步行为不同;详见 README 中的 Codex 状态说明。 ### ✅ 测试 @@ -37,7 +37,12 @@ ### 📚 文档 - `claude-code-plugin/README.md` 与 `README_CN.md`:安装、配置、数据布局、排障与安全模型完整说明,新增 `TDAI_TOKEN_PATH` / `TDAI_GATEWAY_ALLOW_REMOTE` / `TDAI_GATEWAY_CORS_ORIGIN` / Windows 兼容性说明。 -- `claude-code-plugin/README.md` 与 `README_CN.md`:Codex CLI 安装段下新增"已知限制"小节,标注 Codex CLI ≤ v0.130 通过 `source_type = "local"` marketplace 安装时受上游 [openai/codex#22078](https://github.com/openai/codex/issues/22078) 影响,`skills/` 与 `hooks/` 不会暴露到 session;插件这一侧 manifest + hook 协议已就绪,等上游修复或本插件正式发布到 `source_type = "git"` marketplace 后即恢复。 +- `claude-code-plugin/README.md` 与 `README_CN.md`:Codex CLI 安装段下重写 "Codex CLI 当前状态:部分阻塞" 小节,披露与 cc 对等之前的三层 blocker: + 1. **Discovery 层(上游阻塞)**:`source_type = "local"` 安装受上游 [openai/codex#22078](https://github.com/openai/codex/issues/22078) 影响,manifest 解析正常但 `skills/` 与 `hooks/hooks.json` 在运行时被静默丢弃,hook 根本不会触发; + 2. **`async` 行为层(Codex 不实现)**:Codex 解析 `async` 字段但 `HookRunSummary` 硬编码 `HookExecutionMode::Sync`,cc 端 `SessionStart`/`Stop` 上的 `async: true + timeout: 30` 在 Codex 修复 #22078 后会变成同步 30s 阻塞;计划用单独 `hooks/codex-hooks.json` 差异化 timeout(待办); + 3. **Transcript 解析层(plugin 端未适配)**:Codex rollout jsonl schema `{timestamp, type, payload}` 与 cc transcript `{type, message, sessionId, parentUuid, …}` 完全不同,当前 `lib/transcript.ts` 仅解析 cc 格式,即使 Stop 在 Codex 上触发也会静默生成空 capture;Codex parser 是后续工作,等 #22078 修复后基于真实 Codex session 实现。 + + 当前 Codex 上真正能用的部分:manifest 解析、`/plugin` 可见可切换、`lib/daemon.ts` 宿主无关 daemon spawn 与 cc 共用同一段代码。同时同步降级 README 与 CHANGELOG 顶部"双宿主对齐"的过度乐观表述。 --- diff --git a/claude-code-plugin/README.md b/claude-code-plugin/README.md index a2cd8be..6af134e 100644 --- a/claude-code-plugin/README.md +++ b/claude-code-plugin/README.md @@ -2,7 +2,7 @@ Long-term + symbolic short-term memory for [Claude Code](https://claude.com/claude-code) and [OpenAI Codex CLI](https://developers.openai.com/codex/cli), powered by [TencentDB Agent Memory](https://github.com/Tencent/TencentDB-Agent-Memory). -The plugin ships dual manifests (`.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`) and reuses the same `hooks/hooks.json` and `skills/` — both Claude Code (v2026.4+) and Codex CLI (v0.117+) implement the same hook protocol, so a single source tree serves both hosts. +The plugin ships dual manifests (`.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`) and reuses the same `hooks/hooks.json` and `skills/`. Claude Code (v2026.4+) and Codex CLI (v0.130+) share the hook protocol at the schema layer (event names, handler config fields, `${CLAUDE_PLUGIN_ROOT}` env var). **Claude Code is the first-class target today; Codex CLI is partially blocked** — see the [Codex CLI](#codex-cli) install section below for current status. [中文版](./README_CN.md) @@ -41,7 +41,15 @@ codex plugin marketplace add (Once published to the Codex marketplace, this becomes a one-liner.) -> **Known limitation (Codex CLI ≤ v0.130).** Installing this plugin from a `source_type = "local"` marketplace is currently affected by Codex upstream issue [openai/codex#22078](https://github.com/openai/codex/issues/22078): the plugin manifest is parsed and the plugin is toggleable in `/plugin`, but the declared `skills/` and `hooks/hooks.json` are not exposed to the running session. This is a Codex-side discovery bug unrelated to the plugin itself — our `.codex-plugin/plugin.json` already declares both `skills` and `hooks`, and Codex accepts the existing hook event names + `${CLAUDE_PLUGIN_ROOT}` env var via its `hooks/src/engine/discovery.rs` backcompat path. Workaround: use Claude Code for now, or wait for the upcoming `source_type = "git"` marketplace publication. +> **Codex CLI status (≤ v0.130): partially blocked.** Three layered blockers separate the current Codex experience from Claude Code parity: +> +> 1. **Plugin discovery (upstream blocker).** `source_type = "local"` marketplace installs are affected by Codex issue [openai/codex#22078](https://github.com/openai/codex/issues/22078): the manifest parses, the plugin appears in `/plugin` and is toggleable, but the declared `skills/` and `hooks/hooks.json` are silently dropped at runtime. Hooks never fire on Codex today. +> +> 2. **`async` hook field is parsed but not honored.** Codex deserializes the `async` field on hook commands (`codex-rs/config/src/hook_config.rs::HookHandlerConfig::Command`), but no code in `core/src/hook_runtime.rs` or `hooks/src/engine/` consumes it — `HookRunSummary` is hardcoded to `HookExecutionMode::Sync`. Our `SessionStart` and `Stop` hooks declare `async: true, timeout: 30`. Once #22078 ships, this means a Codex session start will block synchronously on first-run daemon spawn, and every Stop will block on capture. Planned mitigation: a separate `hooks/codex-hooks.json` referenced from `.codex-plugin/plugin.json` with shorter timeouts. +> +> 3. **`lib/transcript.ts` only parses the Claude Code transcript format.** Codex records sessions to `~/.codex/sessions///
/rollout-*.jsonl` with shape `{timestamp, type: "session_meta" | …, payload: {…}}`, completely different from Claude Code's `{type, message, sessionId, parentUuid, …}`. Even if Stop fired on Codex, capture would silently produce empty turns. A Codex JSONL parser is planned once #22078 lets us validate end-to-end against a live Codex session. +> +> **What works today on Codex:** `.codex-plugin/plugin.json` is parsed correctly, the plugin is visible and toggleable in `/plugin`, and the shared daemon spawn / discovery logic in `lib/daemon.ts` is host-agnostic (same code path as Claude Code). Use Claude Code for actual memory functionality; track #22078 for the upstream fix. --- diff --git a/claude-code-plugin/README_CN.md b/claude-code-plugin/README_CN.md index e2ccded..5d5c761 100644 --- a/claude-code-plugin/README_CN.md +++ b/claude-code-plugin/README_CN.md @@ -2,7 +2,7 @@ 为 [Claude Code](https://claude.com/claude-code) 与 [OpenAI Codex CLI](https://developers.openai.com/codex/cli) 提供长期记忆 + 符号化短期记忆,由 [TencentDB Agent Memory](https://github.com/Tencent/TencentDB-Agent-Memory) 驱动。 -插件携带双 manifest(`.claude-plugin/plugin.json` 与 `.codex-plugin/plugin.json`),共享同一份 `hooks/hooks.json` 与 `skills/`。Claude Code(v2026.4+)与 Codex CLI(v0.117+)实现了同一份 hook 协议,因此一套源码同时服务两个宿主。 +插件携带双 manifest(`.claude-plugin/plugin.json` 与 `.codex-plugin/plugin.json`),共享同一份 `hooks/hooks.json` 与 `skills/`。Claude Code(v2026.4+)与 Codex CLI(v0.130+)在 **schema 层**对齐 hook 协议(事件名、handler 配置字段、`${CLAUDE_PLUGIN_ROOT}` 环境变量)。**Claude Code 是当前的一等宿主,Codex CLI 部分阻塞** —— 当前状态详见下方 [Codex CLI](#codex-cli) 安装段。 [English version](./README.md) @@ -41,7 +41,15 @@ codex plugin marketplace add (一旦发布到 Codex marketplace,将变为一条命令安装。) -> **已知限制(Codex CLI ≤ v0.130)。** 通过 `source_type = "local"` 的本地 marketplace 安装本插件,目前受 Codex 上游 issue [openai/codex#22078](https://github.com/openai/codex/issues/22078) 影响:插件 manifest 能被正确解析、`/plugin` 列表里可见并可切换,但声明的 `skills/` 与 `hooks/hooks.json` 不会暴露到运行中的 session。这是 Codex 侧的 plugin discovery bug,与本插件无关——我们的 `.codex-plugin/plugin.json` 已经同时声明了 `skills` 与 `hooks`,且现有 hook 事件名与 `${CLAUDE_PLUGIN_ROOT}` 环境变量在 Codex `hooks/src/engine/discovery.rs` 的 backcompat 分支里已经支持。绕过办法:暂用 Claude Code 安装,或等本插件发布到 `source_type = "git"` marketplace。 +> **Codex CLI 当前状态(≤ v0.130):部分阻塞。** 距离与 Claude Code 完全对等还有三层阻塞: +> +> 1. **Plugin discovery(上游阻塞)。** `source_type = "local"` marketplace 安装受 Codex issue [openai/codex#22078](https://github.com/openai/codex/issues/22078) 影响:manifest 能被解析、`/plugin` 中可见并可切换,但声明的 `skills/` 与 `hooks/hooks.json` 在运行时被静默丢弃。今天 Codex 上 hook 根本不会触发。 +> +> 2. **`async` hook 字段被解析但未实际生效。** Codex 在 `codex-rs/config/src/hook_config.rs::HookHandlerConfig::Command` 中反序列化 `async` 字段,但 `core/src/hook_runtime.rs` 与 `hooks/src/engine/` 没有消费它的代码——`HookRunSummary` 硬编码为 `HookExecutionMode::Sync`。我们的 `SessionStart` 与 `Stop` hook 标了 `async: true, timeout: 30`。等 #22078 修复后,这意味着:Codex session 首次启动会同步阻塞等 daemon spawn,每次 Stop 会同步阻塞等 capture。计划绕过办法:单独拷一份 `hooks/codex-hooks.json` 给 `.codex-plugin/plugin.json` 引用,配较短 timeout。 +> +> 3. **`lib/transcript.ts` 只解析 Claude Code transcript 格式。** Codex 把 session 录到 `~/.codex/sessions///
/rollout-*.jsonl`,形态是 `{timestamp, type: "session_meta" | …, payload: {…}}`,跟 cc 的 `{type, message, sessionId, parentUuid, …}` 完全是两套 schema。即使 Stop 在 Codex 上触发了,capture 也只会静默生成空 turn。Codex JSONL parser 是后续工作,等 #22078 修复让我们能基于真实 Codex session 做端到端验证后再实现。 +> +> **当前真正能用的部分:** `.codex-plugin/plugin.json` 解析正常、插件在 `/plugin` 中可见可切换、`lib/daemon.ts` 的 daemon spawn / discovery 逻辑是宿主无关的(与 cc 共用同一段代码)。当前阶段记忆功能请用 Claude Code,Codex 那边追 #22078 上游修复。 ---