Skip to content
Closed
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
31 changes: 31 additions & 0 deletions .changeset/websearch-junk-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'tenjin-cli': minor
---

Stop the WebSearch hook from turning every web search into a publish obligation.
A recorded MISS is a promise: the Stop hook asks the agent to publish it back or
close it, and the server row feeds `/trending`'s questions tier. Most web
searches are not marketplace questions ("Cluely launch video who made it",
"land-book.com website inspiration 2026"), so the ledger filled with demand
nobody has and the nag stopped meaning anything.

A MISS whose query matches one of four narrow patterns (social and news
artifacts, design inspiration, curated-list shopping, stock media) is no longer
written to `~/.tenjin/searches.json`, so it raises no Stop-hook line. It is still
POSTed to `/api/agent/search` exactly as before, so the query still counts as
demand telemetry. A `CANDIDATES` response is always recorded and always hinted
whatever the patterns say: if the marketplace surfaced answers, the question was
marketplace-shaped by definition.

The patterns are conservative on purpose, because the two errors are not
symmetric: missing junk costs one stale line in a batch the agent already skims,
while suppressing a real question costs the finding it would have become. Over
1094 unique real WebSearch queries the four flag 6.2%. The rejected alternative
was a syntactic "operator soup" test (bare `OR`, quoted fragments, `site:`),
which flags 11.3% of the same corpus and takes durable technical questions with
it, `"dvh" caniuse baseline "widely available" 2023 date OR "2024" OR "2025"`
among them.

Hook script version 18. Existing installs keep the v17 behavior until `tenjin
install` is re-run, which rewrites the scripts. No new config key:
`tenjin config set hooks.searchMode off` already disarms the whole hook.
64 changes: 64 additions & 0 deletions src/lib/hook-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,70 @@ describe('WebSearch hook: recording into the one store', () => {
});
});

// A recorded MISS is a promise the Stop hook makes the agent keep, so both
// directions are pinned with real queries out of this machine's transcripts.
describe('WebSearch hook: the junk gate on a MISS', () => {
const MISS_BODY = {
schemaVersion: 2,
searchId: '66666666-6666-4666-8666-666666666666',
decision: 'MISS',
calibration: 'ok',
};

it.each([
'Cluely launch video who made it agency freelancer',
'land-book.com editorial paper design website inspiration 2026',
'Veo 3 Sora product demo b-roll hype vs actually useful marketers 2026 reddit',
'minimal.gallery curated.design siteinspire best of 2025 editorial serif typography website',
'scroll driven storytelling "how it works" animation site awwwards 2026',
'x402 protocol Discord Telegram community awesome-x402 list builders',
])('records no obligation for %s', async (query) => {
const { baseUrl, hits } = await serveJson(() => ({ status: 200, json: MISS_BODY }));
await writeConfig({ baseUrl });
const run = await runScript(websearchHookScript(dataDir), webSearchInput(query));

expect(run.code).toBe(0);
expect(run.stderr).toBe('');
expect(run.stdout).toBe('');
expect(await storedSearches()).toEqual([]);
// The server row still exists; only the local obligation is dropped.
expect(hits()).toBe(1);
});

it.each([
'iCloud Private Relay Sec-Fetch headers stripped OR service worker Sec-Fetch-Dest empty prefetch Sec-Purpose',
'"dvh" caniuse baseline "widely available" 2023 date OR "2024" OR "2025"',
'execCommand copy "not allowed" OR "SecurityError" async setTimeout requires user gesture same as clipboard API',
'llms.txt best practices 2026 examples Anthropic Stripe Vercel',
'best article extraction API 2026 Mozilla Readability Postlight Mercury Parser archived Diffbot Firecrawl Jina Reader Exa',
'pgvector pg_upgrade "extension" issue discussion install same version new cluster before upgrade',
'Anthropic MCP tool description best practices writing effective tools for agents',
])('keeps the obligation for %s', async (query) => {
const { baseUrl } = await serveJson(() => ({ status: 200, json: MISS_BODY }));
await writeConfig({ baseUrl });
const run = await runScript(websearchHookScript(dataDir), webSearchInput(query));

expect(run.code).toBe(0);
expect(run.stderr).toBe('');
const [entry] = await storedSearches();
expect(entry).toMatchObject({ question: query, decision: 'MISS', source: 'websearch-hook' });
});

// OWNER-LOCKED: answers surfaced means the question was marketplace-shaped.
it('records and hints a flagged query the marketplace answered', async () => {
const { baseUrl } = await serveJson((_body, base) => ({ status: 200, json: hit(base) }));
await writeConfig({ baseUrl });
const query = 'Cluely launch video who made it agency freelancer';
const run = await runScript(websearchHookScript(dataDir), webSearchInput(query));

expect(run.code).toBe(0);
expect(injected(run)).toContain('tenjin inspect');
const [entry] = await storedSearches();
expect(entry).toMatchObject({ question: query, decision: 'CANDIDATES' });
expect(entry?.candidates).toHaveLength(1);
});
});

/**
* The mirror guard.
*
Expand Down
29 changes: 27 additions & 2 deletions src/lib/hook-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
} from './client-meta';

/** Bumped when a body changes; the installer rewrites a script whose text drifts. */
export const HOOK_SCRIPT_VERSION = 17;
export const HOOK_SCRIPT_VERSION = 18;

export const WEBSEARCH_HOOK_FILE = 'tenjin-websearch.mjs';
export const STOP_HOOK_FILE = 'tenjin-stop.mjs';
Expand Down Expand Up @@ -89,6 +89,19 @@ const STORE_MAX_ENTRIES = 50;
* Prunes the per-session weak-batch stamps on the same schedule. */
const NAG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;

/**
* Queries a MISS must not turn into a Stop-hook obligation. Conservative by
* construction, because an unsure query is a real one: these flag 6.2% of 1094
* real queries off this machine, where a syntactic "operator soup" rule (bare
* OR, quoted fragments, `site:`) flagged 11.3% and took durable ones with it.
*/
const JUNK_QUERY_RES: RegExp[] = [
/\b(tweet|twitter\.com|x\.com|reddit|subreddit|discord|podcast|hacker ?news|linkedin|youtube|instagram|announcement thread|launch (tweet|thread|video))\b/i,
/\b(inspiration|dribbble|awwwards|mockup|font pairing|color palette|hero (section|animation)|portfolio site|landing page (example|reference|inspiration))\b/i,
/\b(awesome-[a-z-]+|best of 20\d\d|curated (list|gallery)|showcase (site|gallery)|top \d+ (sites|tools|examples))\b/i,
/\b(stock photo|free image|icon set|illustration pack|unsplash)\b/i,
];

/** The one-liner `remind` mode emits instead of sending the query anywhere. */
export const REMIND_LINE =
'Tenjin (a marketplace of tested, paid answers) may already have this: `tenjin search "<question>" --json` is free and anonymous.';
Expand Down Expand Up @@ -450,6 +463,14 @@ export function websearchHookScript(dataDir: string): string {
return `${prelude(dataDir, WATCHDOG_MS)}${userAgentSource()}
const LOCK_PATH = SEARCH_STORE + '.lock';

const JUNK_RES = [
${JUNK_QUERY_RES.map((re) => ` ${re.toString()},`).join('\n')}
];

function isJunkQuery(question) {
return JUNK_RES.some((re) => re.test(question));
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/**
Expand Down Expand Up @@ -647,7 +668,11 @@ async function main() {
}
// BEFORE any emit, because emit exits the process. A MISS recorded here is what
// the Stop hook later finds; a HIT is what a purchase attributes back to.
await recordSearch(body.searchId, question, decision, stored, sessionIdOf(input));
// The junk gate drops the LOCAL entry only; the POST above already counted the
// demand. CANDIDATES overrides it: answers surfaced means the question fit.
if (decision === 'CANDIDATES' || !isJunkQuery(question)) {
await recordSearch(body.searchId, question, decision, stored, sessionIdOf(input));
}

if (decision !== 'CANDIDATES') return quiet();

Expand Down