diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b804bd..2fb9fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,32 @@ more like an iteration counter than a strict SemVer major). --- +## [0.3.3] — 2026-05-22 + +### Fixed + +- **`recall_query` MCP path missed telemetry instrumentation.** v0.3.2 added + `appendRecallTelemetry` to the HTTP `GET /memory/query` route but not to the + MCP `recall_query` tool handler (`src/mcp/tools.ts`). Since most clients + (Claude Code, Claude Desktop) reach the daemon over MCP rather than direct + HTTP, the telemetry log (`~/.ccrecall/recall-query.log.jsonl`) only captured + ship smoke-test traffic — actual user calls were silently dropped. Fix: + `recallQueryHandler` now calls `appendRecallTelemetry` with + `hitCount = emittedIds.length` (post-budget, matching HTTP semantics) and + `projectId = null` (the MCP schema does not carry a project parameter). + Regression coverage added in `tests/mcp.test.ts` using + `CCRECALL_RECALL_TELEMETRY_PATH` to isolate the test log. + +### Notes + +- Anyone observing the v0.3.2 7-day hit-rate window should treat the original + 5/28 deadline as void and restart from v0.3.3 ship date (new target: 6/04). + Samples gathered during the bug window only reflect HTTP-direct traffic and + cannot be extrapolated to the population — cold-rate estimates from that + window are not reliable evidence for v0.4.0 batch decisions. + +--- + ## [0.3.2] — 2026-05-21 ### Added diff --git a/CHANGELOG_ZH.md b/CHANGELOG_ZH.md index 29e43ee..b0a6115 100644 --- a/CHANGELOG_ZH.md +++ b/CHANGELOG_ZH.md @@ -8,6 +8,30 @@ ccRecall 的重要版本變更記錄在這裡。 --- +## [0.3.3] — 2026-05-22 + +### 修正 + +- **`recall_query` MCP path 漏 instrument telemetry。** v0.3.2 只在 HTTP + `GET /memory/query` route 加 `appendRecallTelemetry`,沒同步加到 MCP + `recall_query` tool handler(`src/mcp/tools.ts`)。大多數 client + (Claude Code、Claude Desktop)是透過 MCP 而非直接 HTTP 連 daemon,所以 + telemetry log(`~/.ccrecall/recall-query.log.jsonl`)實際只記到 ship + smoke-test 流量,正常 user 呼叫全被靜默丟掉。修法:`recallQueryHandler` + 現在會呼叫 `appendRecallTelemetry`,`hitCount = emittedIds.length` + (post-budget,跟 HTTP 語意一致)、`projectId = null`(MCP schema 未帶 + project 參數)。Regression test 加在 `tests/mcp.test.ts`,用 + `CCRECALL_RECALL_TELEMETRY_PATH` 隔離 test log。 + +### 備註 + +- 在觀察 v0.3.2 7 天 hit-rate window 的人,原訂 5/28 deadline 應視為失效, + 從 v0.3.3 ship 日重新起算(新目標:6/04)。Bug 期內取得的樣本只反映 + HTTP-direct 流量,無法外推到母體 — 該期間推估的 cold-rate 數值不是 + v0.4.0 batch 決策的可靠證據。 + +--- + ## [0.3.2] — 2026-05-21 ### 新增 diff --git a/package.json b/package.json index 2d369cd..be581b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tznthou/ccrecall", - "version": "0.3.2", + "version": "0.3.3", "description": "AI memory service for Claude Code — on-demand context injection with metacognition", "type": "module", "main": "dist/index.js", diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 20140b8..2d34e48 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -12,6 +12,7 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_PER_ROW_CHAR_CAP, } from '../core/token-budget.js' +import { appendRecallTelemetry } from '../core/recall-telemetry.js' // Reserve tokens for trailer + possible unmatched-keyword note before // selecting memory rows, so final text respects the maxTokens target. @@ -88,13 +89,23 @@ export function recallQueryHandler( memoryService: MemoryService, args: { query: string; limit?: number; maxTokens?: number }, ): McpTextResult { + const limit = args.limit ?? 10 try { - const memories = db.queryMemories(args.query, args.limit ?? 10) + const memories = db.queryMemories(args.query, limit) const { text, emittedIds } = formatMemories(memories, args.query, args.maxTokens) // Phase 4c: touch only memories that actually reached the caller. // Budget-dropped rows are not "surfaced" — bumping their access_count // would skew decay / compression toward unused content. memoryService.touch(emittedIds) + // hitCount uses emittedIds (post-budget) to match HTTP /memory/query + // semantics — telemetry records what reached the caller, not raw DB hits. + appendRecallTelemetry({ + query: args.query, + hitCount: emittedIds.length, + projectId: null, + limit, + maxTokens: args.maxTokens ?? null, + }) return textResult(text) } catch (err) { return textError('Error querying memories', err) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8df8b2d..ea02d73 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, readFileSync } from 'node:fs' import path from 'node:path' import os from 'node:os' import { Database } from '../src/core/database.js' @@ -13,16 +13,22 @@ describe('MCP recall_query handler', () => { let tmpDir: string let db: Database let svc: MemoryService + let originalTelemetryPath: string | undefined beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), 'ccrecall-mcp-test-')) db = new Database(path.join(tmpDir, 'test.db')) svc = new MemoryService(db) + // Redirect telemetry to tmp so tests never touch ~/.ccrecall/. + originalTelemetryPath = process.env.CCRECALL_RECALL_TELEMETRY_PATH + process.env.CCRECALL_RECALL_TELEMETRY_PATH = path.join(tmpDir, 'recall-query.log.jsonl') }) afterEach(() => { db.close() rmSync(tmpDir, { recursive: true, force: true }) + if (originalTelemetryPath === undefined) delete process.env.CCRECALL_RECALL_TELEMETRY_PATH + else process.env.CCRECALL_RECALL_TELEMETRY_PATH = originalTelemetryPath }) it('returns empty-result message when no memories match', () => { @@ -74,6 +80,49 @@ describe('MCP recall_query handler', () => { const lines = result.content[0].text.split('\n').filter(Boolean) expect(lines.length).toBe(10) }) + + // Regression: MCP path bypassed appendRecallTelemetry in v0.3.2, leaving + // recall-query.log.jsonl populated only by direct HTTP /memory/query calls. + it('writes telemetry row on hit', () => { + const logPath = path.join(tmpDir, 'recall-query.log.jsonl') + const original = process.env.CCRECALL_RECALL_TELEMETRY_PATH + process.env.CCRECALL_RECALL_TELEMETRY_PATH = logPath + try { + db.saveMemory({ + sessionId: null, + messageId: null, + content: 'apache license decision body', + type: 'decision', + confidence: 1, + }) + recallQueryHandler(db, svc, { query: 'apache', limit: 5 }) + const lines = readFileSync(logPath, 'utf8').trim().split('\n') + expect(lines.length).toBe(1) + const row = JSON.parse(lines[0]) + expect(row.query).toBe('apache') + expect(row.hitCount).toBe(1) + expect(row.limit).toBe(5) + } finally { + if (original === undefined) delete process.env.CCRECALL_RECALL_TELEMETRY_PATH + else process.env.CCRECALL_RECALL_TELEMETRY_PATH = original + } + }) + + it('writes telemetry row with hitCount=0 on miss', () => { + const logPath = path.join(tmpDir, 'recall-query.log.jsonl') + const original = process.env.CCRECALL_RECALL_TELEMETRY_PATH + process.env.CCRECALL_RECALL_TELEMETRY_PATH = logPath + try { + recallQueryHandler(db, svc, { query: 'nonexistent-zzzz' }) + const lines = readFileSync(logPath, 'utf8').trim().split('\n') + expect(lines.length).toBe(1) + const row = JSON.parse(lines[0]) + expect(row.hitCount).toBe(0) + } finally { + if (original === undefined) delete process.env.CCRECALL_RECALL_TELEMETRY_PATH + else process.env.CCRECALL_RECALL_TELEMETRY_PATH = original + } + }) }) describe('formatMemories', () => { @@ -147,16 +196,22 @@ describe('MCP recall_save handler', () => { let tmpDir: string let db: Database let svc: MemoryService + let originalTelemetryPath: string | undefined beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), 'ccrecall-mcp-save-')) db = new Database(path.join(tmpDir, 'test.db')) svc = new MemoryService(db) + // One test in this block calls recallQueryHandler; redirect telemetry. + originalTelemetryPath = process.env.CCRECALL_RECALL_TELEMETRY_PATH + process.env.CCRECALL_RECALL_TELEMETRY_PATH = path.join(tmpDir, 'recall-query.log.jsonl') }) afterEach(() => { db.close() rmSync(tmpDir, { recursive: true, force: true }) + if (originalTelemetryPath === undefined) delete process.env.CCRECALL_RECALL_TELEMETRY_PATH + else process.env.CCRECALL_RECALL_TELEMETRY_PATH = originalTelemetryPath }) it('saves a memory and returns confirmation with id', () => { diff --git a/tests/touch-integration.test.ts b/tests/touch-integration.test.ts index 3f85958..332ad74 100644 --- a/tests/touch-integration.test.ts +++ b/tests/touch-integration.test.ts @@ -11,16 +11,22 @@ import { sessionParams } from './fixtures/helpers.js' let tmpDir: string let db: Database let svc: MemoryService +let originalTelemetryPath: string | undefined beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), 'ccrecall-touch-')) db = new Database(path.join(tmpDir, 'test.db')) svc = new MemoryService(db) + // Redirect telemetry so handler tests never touch ~/.ccrecall/. + originalTelemetryPath = process.env.CCRECALL_RECALL_TELEMETRY_PATH + process.env.CCRECALL_RECALL_TELEMETRY_PATH = path.join(tmpDir, 'recall-query.log.jsonl') }) afterEach(() => { db.close() rmSync(tmpDir, { recursive: true, force: true }) + if (originalTelemetryPath === undefined) delete process.env.CCRECALL_RECALL_TELEMETRY_PATH + else process.env.CCRECALL_RECALL_TELEMETRY_PATH = originalTelemetryPath }) function accessCount(id: number): number {