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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project are recorded here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow
[SemVer](https://semver.org/).

## 0.1.1 — 2026-10-15 (hotfix: relaxed recall rung, ADR-0102)

### Fixed

- **recall 自然语言提问取不回已存证据(ADR-0102 D1)**:`recall_memory` 的内部记忆与项目索引两层都只走严格 FTS5 梯级(全词 AND + 整句),宿主自然语言提问几乎从不在单行里凑齐全部词,存储中的证据取不回。现严格梯级不足 `limit` 时追加放宽梯级(实词 OR + 前缀、同一 BM25/新鲜度排序,严格命中保持优先);覆盖 `searchMemory` / `searchMemoryMultiQuery` / `ProjectIndexStore.search` 三条路径。回归:`packages/store/test/fts5-relaxed-recall.test.ts`、`apps/plugin/test/project-index-relaxed.test.ts`。

### Changed

- 全包版本 0.1.0 → 0.1.1(9 package.json + plugin.json + ship-gate release pin + provider clientInfo 同 commit)。本版同时承载下列 Unreleased(ADR-0094~0101)条目。

## Unreleased — ADR-0101 r100: 元验证封顶(封顶滤尺三交集 + 修层/新层判据 + 坐席 Addendum + tau 递归/AST 前门 + deferred 触发器)

### Added
Expand Down
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1872,3 +1872,8 @@ closeout 在 main 上含活链 Stack 行且其命名 branch 的 origin ref 缺

### Masking-Surfaced(掩盖浮面)
runner 对 open seat 下探针报 no-kill 出具名 masking info 行——哪个坐席掩盖哪个锚,每跑可见。_Avoid_: 泛泛一行 info;坐席掩盖保持隐形。来源:R100 D-004。

## Hotfix 0.1.1 — Terms

### Relaxed Recall Rung(召回放宽梯级)
recall 的第二级 FTS5 查询:严格梯级(ADR-0023 D2:全词 AND + 整句)命中不足 limit 时才启用,实词 OR + 前缀匹配、同一 BM25 排序,结果追加在严格命中之后、按行身份去重。目的:宿主以自然语言提问时不再因「无单行含全部词」而取不回已存证据。ADR-0102 D1。_Avoid_: 不用放宽梯级替代严格梯级(精确查询的排序与优先级不变);不把它当语义召回(向量/语义臂另见 ADR-0037)。
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anysearch-cli/cli",
"version": "0.1.0",
"version": "0.1.1",
"bin": {
"ans": "dist/index.js"
},
Expand Down
2 changes: 1 addition & 1 deletion apps/dsh-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anysearch-cli/dsh-plugin",
"version": "0.1.0",
"version": "0.1.1",
"private": false,
"publishConfig": {
"access": "public",
Expand Down
2 changes: 1 addition & 1 deletion apps/dsh-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ const channels = new Map<string, Promise<McpChannel | null>>();
async function mcpHandshake(base: string, token: string): Promise<McpChannel | null> {
const init = await callServer(base + '/mcp', token, {
jsonrpc: '2.0', id: ++rpcSeq, method: 'initialize',
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'anysearch-dsh-plugin', version: '0.1.0' } },
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'anysearch-dsh-plugin', version: '0.1.1' } },
}, { headers: { Accept: MCP_ACCEPT }, timeoutMs: 15_000 });
const negotiated = (init?.result as { protocolVersion?: unknown } | undefined)?.protocolVersion;
if (typeof negotiated !== 'string' || negotiated.length === 0) return null;
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anysearch-cli/mcp",
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"bin": {
"ans-mcp": "dist/index.cjs"
Expand Down
2 changes: 1 addition & 1 deletion apps/plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "anysearch",
"version": "0.1.0",
"version": "0.1.1",
"description": "AnySearch hooks + MCP integration for Claude Code (experimental)",
"author": {
"name": "Xxx91n"
Expand Down
2 changes: 1 addition & 1 deletion apps/plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anysearch-cli/plugin",
"version": "0.1.0",
"version": "0.1.1",
"exports": {
".": {
"types": "./src/index.ts",
Expand Down
14 changes: 12 additions & 2 deletions apps/plugin/src/store/project-index-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Written by hooks layer via IPC to long-running MCP server (ADR-0009 Decision 1+2).

import Database from "better-sqlite3";
import { fts5EscapeQuery } from "@anysearch-cli/store";
import { fts5EscapeQuery, fts5RelaxedQuery, relaxedTerms, coversMajority, appendRelaxed } from "@anysearch-cli/store";

export interface ProjectIndexEntry {
rowid: number;
Expand Down Expand Up @@ -133,7 +133,17 @@ export class ProjectIndexStore {

search(query: string, limit = 10): ProjectIndexHit[] {
const safeQuery = this.fts5Escape(query);
return this.stmts.search.all(safeQuery, limit) as ProjectIndexHit[];
const strict = this.stmts.search.all(safeQuery, limit) as ProjectIndexHit[];
// ADR-0102 D1: host recall questions are natural language — relax when the strict rung under-fills.
if (strict.length >= limit) return strict;
const relaxed = fts5RelaxedQuery(query);
if (!relaxed) return strict;
// `p.rowid` on an INTEGER PRIMARY KEY table comes back under the PK's name (`id`), so dedupe on it.
const idOf = (h: ProjectIndexHit) => (h as unknown as { id?: number }).id ?? h.rowid;
const terms = relaxedTerms(query);
const wide = (this.stmts.search.all(relaxed, limit * 4) as ProjectIndexHit[])
.filter((h) => coversMajority(terms, `${h.title ?? ""} ${h.snippet ?? ""} ${h.url ?? ""}`));
return appendRelaxed(strict, wide, limit, idOf);
}

purgeProject(projectPath: string): number {
Expand Down
42 changes: 42 additions & 0 deletions apps/plugin/test/project-index-relaxed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// ADR-0102 D1: the project index (host-recallable memory) gets the same relaxed rung.
// Repro: the host indexed NBA Finals results in session 1, then asked a natural-language
// question in session 2; the strict AND rung returned 0 although 85 rows were indexed.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ProjectIndexStore } from "../src/index.js";

function seeded() {
const dir = mkdtempSync(join(tmpdir(), "ans-pi-relax-"));
const store = new ProjectIndexStore(join(dir, "pi.db"));
const rows = [
["Knicks win 2026 NBA Finals", "https://www.nba.com/news/knicks-win-2026-finals", "New York beat San Antonio 4-1 to claim the championship"],
["Brunson named NBA Finals MVP", "https://www.espn.com/nba/story/brunson-finals-mvp", "Jalen Brunson scored 45 in Game 5"],
["NBA preseason schedule", "https://example.com/nba-schedule", "Preseason games start in October"],
["Tomato pruning basics", "https://example.com/gardening", "Prune suckers early in the season"],
];
rows.forEach(([title, url, snippet], i) => store.indexEntry({ projectPath: dir, toolName: "search_web", title, url, snippet, source: "tavily", contentHash: "h" + i }));
return { store, dir };
}

test("project index: a natural-language follow-up question recalls indexed evidence (was 0)", () => {
const { store, dir } = seeded();
try {
const hits = store.search("latest NBA champion NBA Finals MVP", 5);
assert.ok(hits.length >= 2, `expected the two NBA rows, got ${hits.length}`);
assert.ok(hits.every((h) => /Knicks|Brunson/.test(h.title)), "one-word near-miss rows stay out");
} finally { store.close(); rmSync(dir, { recursive: true, force: true }); }
});

test("project index: strict matches keep priority; unrelated queries stay empty", () => {
const { store, dir } = seeded();
try {
const top = store.search("Brunson Finals MVP", 1);
assert.equal(top.length, 1);
assert.match(top[0]!.title, /Brunson/);
assert.equal(store.search("quantum zebra telescope", 5).length, 0);
assert.equal(store.search("NBA ticket refund policy", 5).length, 0, "single shared entity word is not evidence");
} finally { store.close(); rmSync(dir, { recursive: true, force: true }); }
});
51 changes: 51 additions & 0 deletions docs/adr/0102-hotfix-0-1-1-relaxed-recall-rung.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR-0102: Hotfix 0.1.1 — 召回放宽梯级(Relaxed Recall Rung)

## Status

Accepted (hotfix 0.1.1, 2026-10-15). Ledger: `.scratch/hotfix-0.1.1/decision-ledger.md` (D-001~D-002). Amends ADR-0023 D2 (FTS5 query tokenization) for the recall paths only; ADR-0009 D4 (two-stage recall) unchanged.

## Context

ADR-0023 D2 tokenizes a query into `(w1* AND w2* … AND wN*) OR "full phrase"`. That is precise for short keyword queries, but a host agent asks `recall_memory` in natural language. A host-in-loop run (Codex host, 2026-10-15) showed the effect:

- **Session 1** researched the 2026 NBA Finals. Its results reached both memory layers: 111 rows in `retrieval_results`, 85 rows in the project index.
- **Session 2** asked a follow-up in a new session: `recall_memory("latest NBA champion NBA Finals MVP")`.
- **Result:** 0 hits in both layers, because no single stored row contains all five terms. The same terms joined with OR hit 85/111.
- **Consequence:** the host re-searched from scratch, so the stored research was unreachable.

The defect is in the query builder, not in storage or ranking.

## Decision

### D1 严格优先、欠满放宽(strict rung first, relaxed rung on under-fill)

- **Strict rung:** unchanged — `fts5EscapeQuery`, i.e. ADR-0023 D2. Its hits keep their order and priority.
- **When to relax:** only when the strict rung returns fewer than `limit` rows.
- **Relaxed rung:** `fts5RelaxedQuery`.
- Same sanitisation (CWE-20) and the same ≤8-term cap.
- Function words are dropped.
- The remaining content words are OR-ed with prefix match (`w*`).
- Rows are ranked by the same BM25 expression; the internal layer also keeps `freshness_factor`, keyed on the strict string so decay behaviour is identical.
- **Merge:** relaxed rows are appended after the strict rows, de-duplicated by row identity (`appendRelaxed`), up to `limit`.
- **No relaxing:** when fewer than two content words remain, `fts5RelaxedQuery` returns `null`.
- **Applied to all three FTS recall paths:**
- `SqliteSessionStore.searchMemory` (single query);
- `searchMemoryMultiQuery` (per variant, before RRF);
- `ProjectIndexStore.search` (project index, Stage 2).

### D2 版本

All packages move to 0.1.1, published through the normal `release.yml` path (pre-tag → tag → OIDC publish).

## Consequences

- A natural-language recall question now reaches stored evidence. Regression tests reproduce the 0-hit case first:
- `packages/store/test/fts5-relaxed-recall.test.ts`
- `apps/plugin/test/project-index-relaxed.test.ts`
- Queries with no content-word overlap still return nothing (negative test), so abstain behaviour is unchanged.
- Golden/eval runs whose strict rung already fills `limit` are unaffected by construction.
- Noted while fixing: `ProjectIndexStore`'s `SELECT p.rowid` returns the PK under its declared name `id`, not `rowid`, so `ProjectIndexHit.rowid` is undefined at runtime.
- Dedupe in the project-index path therefore keys on `id`.
- The SQL and the `/recall` JSON shape are left unchanged.
- The type mismatch is recorded here, not fixed (no consumer reads it).
- Out of scope: semantic/vector recall arms, query rewrite (ADR-0023 D1) and fusion weights (ADR-0045).
3 changes: 2 additions & 1 deletion docs/adr/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Architecture decisions

<!-- BEGIN ADR-INDEX (generated by scripts/gen-adr-index.mjs — do not edit by hand) -->
The complete numbered record lives in `docs/adr/` — ADR-0001 through ADR-0101.
The complete numbered record lives in `docs/adr/` — ADR-0001 through ADR-0102.

| ADR | Title |
|-----|-------|
Expand Down Expand Up @@ -106,4 +106,5 @@ The complete numbered record lives in `docs/adr/` — ADR-0001 through ADR-0101.
| [0099](0099-architecture-grill-round-98-fifth-morphology.md) | Grill Round 98 — 第五形态:装饰性机器约束检测(闭锚注册表 + 行为消费验证 + 两枚自指钉) |
| [0100](0100-architecture-grill-round-99-vocab-guards-open-surface-expansion.md) | Grill Round 99 — vocab-guards 扩域至全 scripts/(逐模块注册表 + glob 顶层枚举 + 逐模块证伪) |
| [0101](0101-architecture-grill-round-100-meta-validation-cap.md) | Grill Round 100 — 元验证封顶(封顶滤尺三交集 + 修层/新层判据 + 坐席 Addendum + tau 递归/AST 前门 + deferred 触发器) |
| [0102](0102-hotfix-0-1-1-relaxed-recall-rung.md) | Hotfix 0.1.1 — 召回放宽梯级(Relaxed Recall Rung) |
<!-- END ADR-INDEX -->
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "anysearch-cli",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"packageManager": "pnpm@11.24.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/embedding/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anysearch-cli/embedding",
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"exports": {
".": "./src/index.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/kernel/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anysearch-cli/kernel",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/retriever/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anysearch-cli/retriever",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/retriever/src/providers/anysearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export class AnySearchProvider implements SearchProvider {
const res = await this.rpc("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "anysearch-cli", version: "0.1.0" },
clientInfo: { name: "anysearch-cli", version: "0.1.1" },
}, signal);
const negotiated = res["protocolVersion"];
if (typeof negotiated === "string" && negotiated) this.protocolVersion = negotiated;
Expand Down
2 changes: 1 addition & 1 deletion packages/store/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anysearch-cli/store",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"exports": {
Expand Down
68 changes: 64 additions & 4 deletions packages/store/src/fts5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,57 @@ export function fts5EscapeQuery(query: string, maxTerms = 8): string {
return "(" + prefixClause + ') OR "' + phrase + '"';
}

// ADR-0102 D1: relaxed recall rung. A natural-language recall question ("latest NBA champion NBA Finals MVP")
// rarely has every term inside one stored row, so the strict AND rung above returns zero rows and evidence
// that IS in memory becomes unreachable. Callers run the strict rung first and, only when it under-fills
// the limit, append the relaxed rung: content words (function words dropped) OR-ed with prefix match,
// ranked by the same BM25 expression. Strict hits keep their order and priority.
const RELAX_STOPWORDS = new Set([
"a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "from", "by", "with", "about", "as",
"is", "are", "was", "were", "be", "been", "being", "it", "its", "this", "that", "these", "those",
"what", "which", "who", "whom", "whose", "when", "where", "why", "how", "do", "does", "did",
"can", "could", "would", "should", "will", "i", "you", "we", "they", "he", "she", "me", "my", "our", "your", "their",
]);

// Content words of a query (sanitised like the strict rung, function words dropped, ≤ maxTerms).
export function relaxedTerms(query: string, maxTerms = 8): string[] {
const words = query.replace(SANITIZE_RE, " ").trim().split(/\s+/).filter(Boolean).map((w) => w.toLowerCase());
return [...new Set(words.filter((w) => !RELAX_STOPWORDS.has(w)))].slice(0, maxTerms);
}

// Returns null when relaxing cannot add a majority-covered row the strict rung missed
// (fewer than three content words: a strict majority of 1–2 terms is all of them).
export function fts5RelaxedQuery(query: string, maxTerms = 8): string | null {
const content = relaxedTerms(query, maxTerms);
if (content.length < 3) return null;
return content.map((w) => w + "*").join(" OR ");
}

// ADR-0102 D1 coverage floor: a relaxed row is kept only if it covers a STRICT MAJORITY of the
// query's content words (prefix match on its indexed text). A row that shares just one entity word
// with the question ("cacheflush release timetable" vs "cacheflush interval") is not evidence —
// the golden unanswerable cases (un_*) require such near-miss distractors to stay out of the FTS arm.
export function coversMajority(terms: string[], text: string): boolean {
if (terms.length === 0) return false;
const toks = text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
let matched = 0;
for (const t of terms) if (toks.some((k) => k.startsWith(t))) matched++;
return matched * 2 > terms.length;
}

// Append relaxed-rung rows after strict-rung rows, skipping duplicates, up to limit.
// key: row identity (default rowid; the project index returns its PK as `id`).
export function appendRelaxed<T>(strict: T[], relaxed: T[], limit: number, key: (h: T) => unknown = (h) => (h as { rowid?: unknown }).rowid): T[] {
const out = strict.slice(0, limit);
const seen = new Set(out.map(key));
for (const h of relaxed) {
if (out.length >= limit) break;
const k = key(h);
if (!seen.has(k)) { seen.add(k); out.push(h); }
}
return out;
}

// ADR-0023 D2 (Q2=B): multi-query + RRF (k=60) fusion for consumer queries.
// queries: 3 variants from S1 rewrite ([original, v1, v2]) — index 0 is always the raw user query (fail-open).
// Deduplication in rrfScores is list-local: two variants returning the same doc don't double-count when fused.
Expand All @@ -44,14 +95,23 @@ export async function searchMemoryMultiQuery<THit extends { rowid: number }>(
if (safe === '""') continue;
// ADR-0030: mirror of stmts.searchAllResults: freshness_factor UDF must be registered (see registerFreshnessFactorFunction).
// Params: (query_for_decay, fts_match_query, limit) — same string passed twice for both ? slots.
const hits = store.dbQuery<THit>(
const sql =
"SELECT r.id as rowid, r.session_id as sessionId, r.title as role, r.snippet as content, " +
"freshness_factor(bm25(retrieval_results_fts), r.created_at, r.last_accessed, r.access_count, r.title, r.url, ?, r.pinned) as rank " +
"FROM retrieval_results_fts JOIN retrieval_results r ON r.id = retrieval_results_fts.rowid " +
"WHERE retrieval_results_fts MATCH ? AND (r.valid_until IS NULL) AND (r.quarantine IS NULL) AND r.archived = 0 " +
"ORDER BY rank LIMIT ?",
safe, safe, limit,
);
"ORDER BY rank LIMIT ?";
let hits = store.dbQuery<THit>(sql, safe, safe, limit);
// ADR-0102 D1: relaxed rung only when the strict rung under-fills (decay keyed on the same strict string).
if (hits.length < limit) {
const relaxed = fts5RelaxedQuery(q.trim());
if (relaxed) {
const terms = relaxedTerms(q.trim());
const wide = store.dbQuery<THit & { role?: string; content?: string }>(sql, safe, relaxed, limit * 4)
.filter((h) => coversMajority(terms, `${h.role ?? ""} ${h.content ?? ""}`));
hits = appendRelaxed(hits, wide, limit);
}
}
if (hits.length > 0) {
// RRF input key must be a stable per-doc id — rowid identifies retrieval_results rows.
lists.push(hits.map((h) => String(h.rowid)));
Expand Down
Loading
Loading