From 04a9ae52c57525a32a76742bec164663073c6601 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 27 Aug 2026 04:52:42 +0000 Subject: [PATCH] fix: resolve #14 (source-repo fallback) + #15 (synonyms & relevance gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #14 — ac-watch.mjs, ac-fts.mjs, agent-search-lite.mjs no longer silently fall back to the package source repo when cwd has no initialized project. They now mirror ac.mjs (#13): print a bilingual error and exit 1. Removes the data-pollution risk (npx cache / package install getting user entries). #15a — add business/domain synonym cluster to search.synonyms: billing <-> payments <-> charge <-> invoice <-> 결제 (bidirectional, merged with existing jwt/auth/bug pairs). #15b — add a relevance gate so weak/no-match queries stop padding unrelated entries. New search.minHitWeight (default 2) + --min-weight N CLI flag; when the best match is below threshold, search-lite returns a bilingual guidance message ('관련 결과 없음 — 상위 레벨로 확장 검색 필요 / no related results; expand to a higher level') instead of padding. Verified: node --check on all 3 tools; e2e-workflow.mjs 6/6; project-less cwd repro exits 1 with bilingual error and leaves source agent-context/ untouched. --- agent-context.config.json | 42 +++++++++++++++++++++++++++++++++++++ tools/ac-fts.mjs | 18 ++++++++++++---- tools/ac-watch.mjs | 18 ++++++++++++---- tools/agent-search-lite.mjs | 35 +++++++++++++++++++++---------- 4 files changed, 94 insertions(+), 19 deletions(-) diff --git a/agent-context.config.json b/agent-context.config.json index 91e3e1c..11c5a1a 100644 --- a/agent-context.config.json +++ b/agent-context.config.json @@ -187,6 +187,7 @@ "autoAssign": true, "tokenBudget": 2000, "hierarchical": true, + "minHitWeight": 2, "order": [ "post-it", "memo", @@ -221,6 +222,47 @@ "pagination": [ "페이지네이션", "cursor" + ], + "billing": [ + "payments", + "charge", + "invoice", + "결제" + ], + "payments": [ + "billing", + "charge", + "invoice", + "결제", + "payment" + ], + "payment": [ + "billing", + "payments", + "charge", + "invoice", + "결제" + ], + "charge": [ + "billing", + "payments", + "invoice", + "결제", + "payment" + ], + "invoice": [ + "billing", + "payments", + "charge", + "결제", + "payment" + ], + "결제": [ + "billing", + "payments", + "charge", + "invoice", + "payment" ] } } diff --git a/tools/ac-fts.mjs b/tools/ac-fts.mjs index 74b6083..d72421f 100644 --- a/tools/ac-fts.mjs +++ b/tools/ac-fts.mjs @@ -12,13 +12,23 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; +// Issue #14 fix: require an initialized project in cwd; never silently fall back +// to the source repo (that would pollute the package install / npx cache). +function resolveProjectRoot() { + const cwd = process.cwd(); + if (existsSync(join(cwd, 'agent-context.config.json')) || existsSync(join(cwd, 'agent-context'))) + return join(cwd, 'agent-context'); + process.stderr.write("agent-context가 초기화되지 않았습니다. 먼저 'agent-context-init.mjs --yes' 를 실행하세요.\n"); + process.stderr.write("(agent-context not initialized in cwd; run 'agent-context-init.mjs --yes' first.)\n"); + process.stderr.write("cwd: " + cwd + "\n"); + process.exit(1); +} +const ROOT = resolveProjectRoot(); const CONFIG = (() => { - for (const p of [join(process.cwd(),'agent-context.config.json'), new URL('../agent-context.config.json', import.meta.url).pathname]) - if (existsSync(p)) return JSON.parse(readFileSync(p,'utf8')); + const p = join(process.cwd(), 'agent-context.config.json'); + if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')); return {}; })(); -const ROOT = (existsSync(join(process.cwd(),'agent-context')) ? join(process.cwd(),'agent-context') - : new URL('../agent-context', import.meta.url).pathname); const INDEX_PATH = join(ROOT, 'index.json'); const PRIVATE = CONFIG.privateMirror || '.agent-context-runtime'; const DB_DIR = join(process.cwd(), PRIVATE); diff --git a/tools/ac-watch.mjs b/tools/ac-watch.mjs index 937a0a0..888c6c7 100644 --- a/tools/ac-watch.mjs +++ b/tools/ac-watch.mjs @@ -19,13 +19,23 @@ import { execFileSync } from 'node:child_process'; import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs'; import { join } from 'node:path'; +// Issue #14 fix: require an initialized project in cwd; never silently fall back +// to the source repo (that would pollute the package install / npx cache). +function resolveProjectRoot() { + const cwd = process.cwd(); + if (existsSync(join(cwd, 'agent-context.config.json')) || existsSync(join(cwd, 'agent-context'))) + return join(cwd, 'agent-context'); + process.stderr.write("agent-context가 초기화되지 않았습니다. 먼저 'agent-context-init.mjs --yes' 를 실행하세요.\n"); + process.stderr.write("(agent-context not initialized in cwd; run 'agent-context-init.mjs --yes' first.)\n"); + process.stderr.write("cwd: " + cwd + "\n"); + process.exit(1); +} +const ROOT = resolveProjectRoot(); const CONFIG = (() => { - for (const p of [join(process.cwd(),'agent-context.config.json'), new URL('../agent-context.config.json', import.meta.url).pathname]) - if (existsSync(p)) return JSON.parse(readFileSync(p,'utf8')); + const p = join(process.cwd(), 'agent-context.config.json'); + if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')); return {}; })(); -const ROOT = (existsSync(join(process.cwd(),'agent-context')) ? join(process.cwd(),'agent-context') - : new URL('../agent-context', import.meta.url).pathname); const CAND = join(ROOT, '.candidates'); function git(args) { diff --git a/tools/agent-search-lite.mjs b/tools/agent-search-lite.mjs index a6d5527..faacc17 100644 --- a/tools/agent-search-lite.mjs +++ b/tools/agent-search-lite.mjs @@ -10,20 +10,29 @@ import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; +// Issue #14 fix: require an initialized project in cwd; never silently fall back +// to the source repo. +function resolveProjectRoot() { + const cwd = process.cwd(); + const ctxRoot = 'agent-context'; + if (existsSync(join(cwd, 'agent-context.config.json')) || existsSync(join(cwd, ctxRoot))) + return join(cwd, ctxRoot); + process.stderr.write("agent-context가 초기화되지 않았습니다. 먼저 'agent-context-init.mjs --yes' 를 실행하세요.\n"); + process.stderr.write("(agent-context not initialized in cwd; run 'agent-context-init.mjs --yes' first.)\n"); + process.stderr.write("cwd: " + cwd + "\n"); + process.exit(1); +} +const ROOT = resolveProjectRoot(); function resolveConfig() { - const cands = [ - join(process.cwd(), 'agent-context.config.json'), - new URL('../agent-context.config.json', import.meta.url).pathname, - ]; - for (const p of cands) if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')); + const p = join(process.cwd(), 'agent-context.config.json'); + if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')); return { contextRoot: 'agent-context' }; } const CONFIG = resolveConfig(); -const ROOT = (existsSync(join(process.cwd(), 'agent-context.config.json')) || existsSync(join(process.cwd(), CONFIG.contextRoot || 'agent-context'))) - ? join(process.cwd(), CONFIG.contextRoot || 'agent-context') - : new URL(`../${CONFIG.contextRoot || 'agent-context'}`, import.meta.url).pathname; const INDEX_PATH = join(ROOT, 'index.json'); +const MIN_HIT_WEIGHT = CONFIG.search?.minHitWeight ?? 2; + const LEVELS = CONFIG.hierarchy?.levels || { 'post-it': { tokens: 15 }, memo: { tokens: 50 }, diary: { tokens: 200 }, bookshelf: { tokens: 1000 }, library: { tokens: 5000 }, @@ -103,8 +112,8 @@ function collect(entries, qTokens, opts, startRank, query) { try { const d=(Date.now()-new Date(e.updated).getTime())/86400000; recency = d<7?1:(d<30?0.8:0.5); } catch {} const score = hitScore*0.5 - levelDistance*0.1 + priorityScore*0.2 + recency*0.1; const estTokens = LEVELS[lev]?.tokens || 200; - return { entry:e, lev, levelDistance, hitScore, score, estTokens }; - }).filter(s => s.hitScore > 0 || s.entry.feature === query?.toLowerCase() || opts.level); + return { entry:e, lev, levelDistance, hitScore, score, estTokens, w }; + }).filter(s => s.w >= (opts.minWeight ?? MIN_HIT_WEIGHT) || s.entry.feature === query?.toLowerCase() || opts.level); scored.sort((a,b)=>b.score-a.score); const top = scored.slice(0, limit); const topTokens = top.reduce((s,x)=>s+x.estTokens,0); @@ -115,6 +124,7 @@ function collect(entries, qTokens, opts, startRank, query) { async function search(query, opts={}) { let index; try { index = JSON.parse(readFileSync(INDEX_PATH,'utf8')); } catch { index = { entries: [] }; } const entries = index.entries || []; + opts.minWeight = opts.minWeight ?? CONFIG.search?.minHitWeight ?? 2; const assignedLevel = opts.level || heuristicLevel(query); const rawTokens = query.toLowerCase().split(/\s+/).filter(Boolean); const qTokens = expandTokens(rawTokens); @@ -155,6 +165,7 @@ async function search(query, opts={}) { totalEntries: entries.length, evaluated: res.evaluated, hit: res.hit, + guidance: res.hit ? null : '관련 결과 없음 — 상위 레벨로 확장 검색 필요 (no related results; expand to a higher level)', top: res.top.map(t=>({ id:t.entry.id, title:t.entry.title, level:t.lev, feature:t.entry.feature, priority:t.entry.priority, estTokens:t.estTokens, path:t.entry.path, summary:t.entry.summary })), tokens: { top: res.topTokens, full: res.fullTokens, saving, avgPerQuery: res.top.length ? Math.round(res.topTokens/res.top.length) : 0 }, note: `Hierarchical ${ORDER.join('→')} — miss expands to larger levels`, @@ -217,6 +228,7 @@ for (let i=0;ir.hit).length/results.length*100).toFixed(0)+'%', results }, null, 2)); process.exit(0); } -if (__isMain) { const res = await search(out.query, { level: out.level, limit: out.limit }); +if (__isMain) { const res = await search(out.query, { level: out.level, limit: out.limit, minWeight: out.minWeight }); if (out.json) console.log(JSON.stringify(res, null, 2)); else { console.log(`\n🔍 "${res.query}" → 휴리스틱 라우터: ${res.assignedLevel}${res.expandedTo?` (miss→확장: ${res.expandedTo})`:''} | 동의어 +${res.router.synonymExpanded} | semantic:${res.router.semantic}`); console.log(` order: ${res.order.join(' → ')} | total:${res.totalEntries} evaluated:${res.evaluated} | hit:${res.hit?'✅':'❌'} | tokens top:${res.tokens.top} vs full:${res.tokens.full} saving:${res.tokens.saving}`); for (const t of res.top) console.log(` - [${t.level} ${t.feature}] ${t.title} (p${t.priority}) → ${t.path}`); + if (!res.hit && res.guidance) console.log(' ⚠️ ' + res.guidance); console.log(''); } }