mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-03 04:38:25 +08:00
fix(server): include URL in ctx_fetch_and_index cache key
getSourceMeta(label) returned the meta from any prior fetch with the same label, so two distinct URLs sharing a label silently returned the cached first response instead of fetching the second. Composes the cache key from label+url so legitimate cache hits still work but cross-URL label reuse no longer serves stale content.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Cache-key / storage-label composition for ctx_fetch_and_index.
|
||||
*
|
||||
* Two distinct URLs that share a user-supplied `source` label MUST NOT collide
|
||||
* in the cache (or in FTS5 storage, since indexing dedups by label). Compose
|
||||
* `${source}::${url}` whenever a `source` is explicitly provided so cache
|
||||
* lookup, dedup, and re-indexing are all per-(source,url). When no `source`
|
||||
* is provided the URL itself is the unique key — no composition needed.
|
||||
*
|
||||
* `ctx_search(source: "Docs")` continues to work because LIKE-mode source
|
||||
* filtering matches on the substring "Docs" inside "Docs::https://…".
|
||||
*/
|
||||
export function composeFetchCacheKey(source: string | undefined, url: string): string {
|
||||
return source === undefined ? url : `${source}::${url}`;
|
||||
}
|
||||
+14
-7
@@ -12,6 +12,7 @@ import { request as httpsRequest } from "node:https";
|
||||
import { z } from "zod";
|
||||
import { PolyglotExecutor } from "./executor.js";
|
||||
import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs, type SearchResult, type IndexResult } from "./store.js";
|
||||
import { composeFetchCacheKey } from "./fetch-cache.js";
|
||||
import {
|
||||
readBashPolicies,
|
||||
evaluateCommandDenyOnly,
|
||||
@@ -1601,11 +1602,13 @@ server.registerTool(
|
||||
}),
|
||||
},
|
||||
async ({ url, source, force }) => {
|
||||
// TTL cache: if source was indexed within 24h, return cached hint
|
||||
// TTL cache: if source was indexed within 24h, return cached hint.
|
||||
// Cache key composes (source, url) so two distinct URLs sharing the same
|
||||
// `source` label do not collide — they each get their own cache slot.
|
||||
if (!force) {
|
||||
const store = getStore();
|
||||
const label = source ?? url;
|
||||
const meta = store.getSourceMeta(label);
|
||||
const cacheKey = composeFetchCacheKey(source, url);
|
||||
const meta = store.getSourceMeta(cacheKey);
|
||||
if (meta) {
|
||||
const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
|
||||
const ageMs = Date.now() - indexedAt.getTime();
|
||||
@@ -1687,15 +1690,19 @@ server.registerTool(
|
||||
|
||||
trackIndexed(Buffer.byteLength(markdown));
|
||||
|
||||
// Route to the appropriate indexing strategy based on Content-Type
|
||||
// Route to the appropriate indexing strategy based on Content-Type.
|
||||
// Storage label includes URL via composeFetchCacheKey so two URLs sharing
|
||||
// a `source` label do not overwrite each other; ctx_search() still finds
|
||||
// both via LIKE-mode source filter on the `source` substring.
|
||||
const storageLabel = composeFetchCacheKey(source, url);
|
||||
let indexed: IndexResult;
|
||||
if (header === "__CM_CT__:json") {
|
||||
indexed = store.indexJSON(markdown, source ?? url);
|
||||
indexed = store.indexJSON(markdown, storageLabel);
|
||||
} else if (header === "__CM_CT__:text") {
|
||||
indexed = store.indexPlainText(markdown, source ?? url);
|
||||
indexed = store.indexPlainText(markdown, storageLabel);
|
||||
} else {
|
||||
// HTML (default) — content is already converted to markdown
|
||||
indexed = store.index({ content: markdown, source: source ?? url });
|
||||
indexed = store.index({ content: markdown, source: storageLabel });
|
||||
}
|
||||
|
||||
// Build preview — first ~3KB of markdown for immediate use
|
||||
|
||||
@@ -1965,3 +1965,77 @@ describe("getSessionDir uses pre-detection when adapter not yet detected", () =>
|
||||
expect(detectIdx).toBeLessThan(claudeIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ctx_fetch_and_index cache-key collision (Fix 6/10)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Bug: cache key was `source ?? url`, so two distinct URLs sharing a `source`
|
||||
// label silently returned the cached first response instead of fetching the
|
||||
// second. Fix composes the cache key from label+url for cache lookup.
|
||||
|
||||
describe("ctx_fetch_and_index cache key includes URL (Fix 6/10)", () => {
|
||||
test("composeFetchCacheKey: same label + different URLs produce different keys", async () => {
|
||||
const { composeFetchCacheKey } = await import("../../src/fetch-cache.js");
|
||||
const k1 = composeFetchCacheKey("Docs", "https://x.com/a");
|
||||
const k2 = composeFetchCacheKey("Docs", "https://y.com/b");
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
test("composeFetchCacheKey: same label + same URL → same key (legitimate cache hit)", async () => {
|
||||
const { composeFetchCacheKey } = await import("../../src/fetch-cache.js");
|
||||
const k1 = composeFetchCacheKey("Docs", "https://x.com/a");
|
||||
const k2 = composeFetchCacheKey("Docs", "https://x.com/a");
|
||||
expect(k1).toBe(k2);
|
||||
});
|
||||
|
||||
test("server.ts uses composeFetchCacheKey for cache lookup (no bare-label collision)", () => {
|
||||
const serverSrc = readFileSync(
|
||||
resolve(__dirname, "../../src/server.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
// Locate the ctx_fetch_and_index handler block
|
||||
const block = serverSrc.match(
|
||||
/registerTool\(\s*"ctx_fetch_and_index"[\s\S]*?^\);/m,
|
||||
);
|
||||
expect(block, "ctx_fetch_and_index handler not found").not.toBeNull();
|
||||
const body = block![0];
|
||||
|
||||
// Cache lookup must call getSourceMeta with a key composed from label+url,
|
||||
// not the bare label/url. The fix uses composeFetchCacheKey().
|
||||
const lookupCall = body.match(
|
||||
/getSourceMeta\(\s*([^)]+)\s*\)/,
|
||||
);
|
||||
expect(lookupCall, "getSourceMeta call missing").not.toBeNull();
|
||||
const arg = lookupCall![1];
|
||||
// Must NOT be the bare `label` variable (that was the bug).
|
||||
expect(arg.trim()).not.toBe("label");
|
||||
// Must reference both the label and the url, ideally via composeFetchCacheKey.
|
||||
expect(body).toContain("composeFetchCacheKey");
|
||||
});
|
||||
|
||||
test("ContentStore: per-(label,url) keys do not collide on getSourceMeta", () => {
|
||||
const store = new ContentStore(":memory:");
|
||||
// Simulate two distinct URLs sharing a user-supplied "source" label,
|
||||
// but stored under composed keys per the fix.
|
||||
const URL_A = "https://example.com/a";
|
||||
const URL_B = "https://example.com/b";
|
||||
const labelA = `Docs::${URL_A}`;
|
||||
const labelB = `Docs::${URL_B}`;
|
||||
|
||||
store.index({ content: "# A\nContent A unique alpha", source: labelA });
|
||||
// Before fix: a second cache lookup with the bare "Docs" label would
|
||||
// hit A's meta and short-circuit. After fix: lookup uses labelB → miss.
|
||||
expect(store.getSourceMeta(labelB)).toBeNull();
|
||||
// Cache hit for the same (label,url) still works.
|
||||
expect(store.getSourceMeta(labelA)).not.toBeNull();
|
||||
|
||||
// Now index B and verify both remain searchable independently.
|
||||
store.index({ content: "# B\nContent B unique bravo", source: labelB });
|
||||
const aResults = store.search("alpha", 5, labelA);
|
||||
const bResults = store.search("bravo", 5, labelB);
|
||||
expect(aResults.length).toBeGreaterThan(0);
|
||||
expect(bResults.length).toBeGreaterThan(0);
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user