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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

### 新增
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 12 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 56 additions & 1 deletion tests/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions tests/touch-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down