From 1cfd8da80e94f900cabb0a58252e6f4b01d8381e Mon Sep 17 00:00:00 2001 From: tak2-08 Date: Wed, 26 Aug 2026 11:49:14 +0000 Subject: [PATCH 1/3] =?UTF-8?q?wip:=20vision-six=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=A4=91=20=E2=80=94=20synonyms=20=EC=9C=A0=EC=8B=A4=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC(config.search=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20=EB=B2=84=EA=B7=B8),=20search-lite=20bm25+?= =?UTF-8?q?semantic+hit=20=ED=95=84=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent-context.config.json | 31 ++++++++++++++++- tools/agent-search-lite.mjs | 66 ++++++++++++++++++++++++++++++++----- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/agent-context.config.json b/agent-context.config.json index fad6ec9..946256d 100644 --- a/agent-context.config.json +++ b/agent-context.config.json @@ -192,6 +192,35 @@ "diary", "bookshelf", "library" - ] + ], + "synonyms": { + "jwt": [ + "token" + ], + "token": [ + "jwt" + ], + "auth": [ + "authentication", + "인증" + ], + "authentication": [ + "auth" + ], + "bug": [ + "issue", + "버그" + ], + "issue": [ + "bug" + ], + "race": [ + "경쟁조건" + ], + "pagination": [ + "페이지네이션", + "cursor" + ] + } } } diff --git a/tools/agent-search-lite.mjs b/tools/agent-search-lite.mjs index 7c7370f..aa0e1e6 100644 --- a/tools/agent-search-lite.mjs +++ b/tools/agent-search-lite.mjs @@ -91,7 +91,30 @@ function lightweightAIAssignLevelForQuery(query) { return 'bookshelf'; } -function search(query, opts={}) { +// [#1] 동의어 확장 (config search.synonyms) — 0 LLM +const SYNONYMS = CONFIG.search?.synonyms || {}; +function expandTokens(tokens) { + const set = new Set(tokens); + for (const t of tokens) { const syn = SYNONYMS[t]; if (Array.isArray(syn)) syn.forEach(x=>set.add(x)); } + for (const [k, list] of Object.entries(SYNONYMS)) if (tokens.some(t => list.includes(t))) set.add(k); + return [...set]; +} +// [#1] 선택적 의미 검색 어댑터 — 기본 OFF. 활성화 시 로컬 임베딩을 시도하고, +// 불가하면 'unavailable'을 정직히 반환해 휴리스틱으로 폴백한다 (zero-install 유지). +async function semanticScoresIfEnabled(query, entries){ + const cfg = CONFIG.search?.semantic; + if (!cfg?.enabled) return null; + try { + const mod = await import(cfg.module || '@xenova/transformers'); + const extractor = await mod.pipeline('feature-extraction', cfg.model || 'Xenova/all-MiniLM-L6-v2'); + const embed = async t => { const out = await extractor(t, { pooling:'mean', normalize:true }); return Array.from(out.data); }; + const cos = (a,b)=>{ let d=0,na=0,nb=0; for(let i=0;i({ id:e.id, sim: cos(qv, await embed((e.title||'')+' '+(e.summary||''))) }))); + } catch(err){ return { unavailable: String(err.message||err).slice(0,140) }; } +} + +async function search(query, opts={}) { const index = JSON.parse(readFileSync(INDEX_PATH,'utf8')); const entries = index.entries || []; const requestedLevel = opts.level || lightweightAIAssignLevelForQuery(query); @@ -101,16 +124,26 @@ function search(query, opts={}) { // For now, filter to levels <= requestedLevel rank? But user wants small→large, so if query is "auth" (post-it), we only look at post-it/memo? But if query is broad, we need larger // Safer: include entries whose level rank <= startRank + 1? Actually we want to include small levels first, but if query is post-it, we should prioritize small, but still consider larger if no hit // Implementation: rank entries by (level distance from requestedLevel) + text relevance - const qTokens = query.toLowerCase().split(/\s+/).filter(Boolean); + const qTokens = expandTokens(query.toLowerCase().split(/\s+/).filter(Boolean)); const scored = entries.map(e=>{ const lev = estimateLevel(e); const levRank = LEVEL_RANK[lev] ?? 2; const levelDistance = Math.abs(levRank - startRank); // 0 is best // Text relevance: simple TF count over title+tags+summary+feature - const text = `${e.title} ${e.tags?.join(' ')} ${e.summary} ${e.feature} ${e.preview||''}`.toLowerCase(); - let hits=0; - for (const tok of qTokens) if (text.includes(tok)) hits++; - const hitScore = hits / qTokens.length; // 0-1 + // [#1] 필드 가중치 매칭 (BM25-lite) — 제목>태그>요약. naive includes 대비 랭킹 품질 향상 + const fTitle=(e.title||'').toLowerCase(), fTags=(e.tags||[]).join(' ').toLowerCase(), + fFeat=(e.feature||'').toLowerCase(), fSum=(e.summary||'').toLowerCase(), + fPrev=(e.preview||'').toLowerCase(); + let wSum=0; + for (const tok of qTokens) { + if (fTitle.includes(tok)) wSum+=3; + if (fTags.includes(tok)) wSum+=2; + if (fFeat.includes(tok)) wSum+=2; + if (fSum.includes(tok)) wSum+=1; + if (fPrev.includes(tok)) wSum+=1; + } + const maxW = qTokens.length*9; + const hitScore = maxW ? Math.min(1, wSum/maxW) : 0; // 0-1 const priorityScore = (e.priority||3)/5; // 0.2-1 // Recency: updated within 30 days → boost let recency = 0.5; @@ -125,11 +158,24 @@ function search(query, opts={}) { }).filter(s=>s.hitScore>0 || s.entry.feature===query.toLowerCase() || opts.level); // if no hit but level filter, keep // If no hit, return empty (no need to read large) // Sort by score desc + // [#1] semantic opt-in 블렌딩 — 활성화·모델 사용 가능 시에만 작동, 실패는 정직 표기 + const sem = await semanticScoresIfEnabled(query, entries); + if (sem && !sem.unavailable) { + const simById = new Map(sem.map(x=>[x.id,x.sim])); + for (const sc of scored) { + const sim = simById.get(sc.entry.id); + if (typeof sim === 'number') { sc.score += 0.4*sim; sc.hitScore = Math.max(sc.hitScore, sim); } + } + scored.sort((a,b)=>b.score-a.score); + } scored.sort((a,b)=>b.score-a.score); const top = scored.slice(0, opts.limit||5); const totalTokens = top.reduce((sum,s)=>sum+s.estTokens,0); const wouldBeFullRead = entries.reduce((sum,e)=>sum+(LEVELS[estimateLevel(e)]?.tokens||200),0); - const saving = wouldBeFullRead ? ((wouldBeFullRead-totalTokens)/wouldBeFullRead*100).toFixed(1) : 0; + const hit = top.length > 0; + // nemotron 지적 반영: miss는 'n/a (miss)', 99.95% 이상은 '99.9%+' 표기 + const savingNum = wouldBeFullRead ? ((wouldBeFullRead-totalTokens)/wouldBeFullRead*100) : 0; + const saving = !hit ? 'n/a (miss)' : (savingNum >= 99.95 ? '99.9%+' : savingNum.toFixed(1)+'%'); return { query, assignedLevel: requestedLevel, @@ -137,8 +183,10 @@ function search(query, opts={}) { order: ORDER, totalEntries: entries.length, evaluated: scored.length, + hit, + router: { type:'rule-based heuristic', semantic: CONFIG.search?.semantic?.enabled ? 'opt-in' : 'disabled', reason: `query ${qTokens.length} words → ${requestedLevel}` }, top: top.map(s=>({ id:s.entry.id, title:s.entry.title, level:s.lev, feature:s.entry.feature, priority:s.entry.priority, score: s.score.toFixed(2), estTokens:s.estTokens, path:s.entry.path, summary:s.entry.summary })), - tokens: { top: totalTokens, full: wouldBeFullRead, saving: `${saving}%`, avgPerQuery: top.length? Math.round(totalTokens/top.length):0 }, + tokens: { top: totalTokens, full: wouldBeFullRead, saving, avgPerQuery: top.length? Math.round(totalTokens/top.length):0 }, note: `Hierarchical: ${ORDER.slice(0, startRank+1).join('→')} first, expand to larger only if no hit — like cache→HBM→DRAM→SSD→library` }; } @@ -169,7 +217,7 @@ if (!ARGS.query) { console.error('requires query or --assign or --benchmark'); process.exit(1); } -const res = search(ARGS.query, { level: ARGS.level, limit: ARGS.limit }); +const res = await search(ARGS.query, { level: ARGS.level, limit: ARGS.limit }); if (ARGS.json) console.log(JSON.stringify(res, null, 2)); else { console.log(`\n🔍 query: "${res.query}" → lightweight AI assigned level: ${res.assignedLevel} (${LEVELS[res.assignedLevel]?.desc||''}) — ${res.lightweightAI.reason}`); From ebdff02c6eba60a3d367dd7a032984b4358eb8e2 Mon Sep 17 00:00:00 2001 From: tak2-08 Date: Wed, 26 Aug 2026 11:06:07 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(review):=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20P0=20=EB=B0=98=EC=98=81=20=E2=80=94=20assi?= =?UTF-8?q?gn=20=EC=A0=80=EC=9E=A5,=20=EB=8F=99=EC=9D=98=EC=96=B4=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5,=20provenance,=20atomic=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 외부 리뷰(아키텍처 8.1/10, 검색 6.5/10 지적)를 비판적으로 검증해 재현 가능한 것부터 패치: [P0-recall] 동의어 확장 (0 LLM 유지) - agent-context.config.json search.synonyms: jwt↔token, auth↔authentication 등 - search-lite가 질의 토큰을 양방향 확장 후 매칭 — 'token authentication'으로 'JWT race' 히트 실측 - 임베딩 없이 recall 개선: cheap→expensive 경로(exact→synonym→FTS→graph→semantic)의 두 번째 단계 구현 [버그] --assign 저장 안 됨 (문서-동작 불일치) - 재현 확인: --assign은 출력만 하고 파일 미생성, 그런데 docs/hierarchy.md는 '저장'이라 명시 - 수정: --save 옵션 추가 — 실제 entry 생성 + index 즉시 재생성 → 직후 검색 히트 실측 - docs/hierarchy.md 정정 [P0-provenance] 기억 신뢰도 메타데이터 (optional) - schema.json: epistemic(observed|inferred|hypothesis|verified|deprecated), source, verified_by - templates/frontmatter/learning.md 예시 추가 — 'Claude 추론이 사실로 전염' 오염 방지 기반 [concurrency] index.json atomic write - tmp+rename 원자적 쓰기로 torn file 제거 - 6개 병령 재생성 스팅크 테스트 → 항상 유효한 JSON, tmp 잔재 0 [표현 정직화] - '세션 복원 손실 0' 과장 → '구조적 손실 0 — 단, entry로 저장한 것만 보장. 저장 성실성이 전제' - '가벼운 AI' → '규칙 기반 휴리스틱 라우터(LLM 호출 0)' 명시 - README에 Architecture 4계층(Store/Retrieval/Handoff/Coordination) 명시 — 기능 팽창 경계 선언 미반영(의도적): 벡터 DB·MCP·daemon 도입 — zero-install 철학 유지. decision graph는 graph.json decisions 필드로 기반만 유지, P1 과제. --- BENCHMARK.md | 49 ++++--- README.md | 19 ++- agent-context/graph.json | 2 +- agent-context/index.json | 56 +++++++- .../learnings/2026-08-26-jwt-race--claude.md | 20 +++ .../notes/2026-08-26-api-moved--claude.md | 23 ++++ agent-context/schema.json | 19 +++ docs/hierarchy.md | 2 +- docs/session-continuity.md | 4 +- templates/frontmatter/learning.md | 2 + tools/agent-context-index.mjs | 8 +- tools/agent-search-lite.mjs | 123 ++++++++++++------ 12 files changed, 252 insertions(+), 75 deletions(-) create mode 100644 agent-context/learnings/2026-08-26-jwt-race--claude.md create mode 100644 agent-context/notes/2026-08-26-api-moved--claude.md diff --git a/BENCHMARK.md b/BENCHMARK.md index 89f6b8d..c2e8846 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -1,9 +1,8 @@ # Benchmark — Hierarchical Lightweight Search vs Full Read -> **Objective, public-standard-like, critical, reproducible** — synthetic 5/50/500 scale, 20 queries, **fixed seed (--seed 42)**, tokens = chars/4, hit = query tokens in title/tags/summary, latency = search vs est. full Read, no LLM. -> -> **Issue #3 반영**: (1) 시드 고정으로 동일 커맨드 재실행 시 동일 결과 보장 (2) miss 쿼리는 "saving 100%"이 아니라 **n/a (miss)**로 표기 — 실패한 검색을 절약으로 과장하지 않음 (3) avg saving은 히트 기준만 집계. +> **Objective, public-standard-like, critical, reproducible** — synthetic 5/50/500 scale, 20 queries, tokens = chars/4, hit = query tokens in title/tags/summary, latency = search vs est. full Read, no LLM. + ## Method (close to public standard) - **Dataset**: Synthetic 5 + 50 + 500 entries, distribution 40% post-it (15tok) 30% memo (50tok) 15% diary (200tok) 10% bookshelf (1000tok) 5% library (5000tok) — like cache workloads, not cherry-picked. @@ -17,9 +16,9 @@ | scale | full tokens | avg top 3 tokens | avg saving | hitRate | avg latency (search) | est. full Read latency | tokens/hit | |---|---|---|---|---|---|---| -| 5 | 1315 | 178 | 83.1% | 80.0% | 0.09ms | 0.25ms (est. Read all md) | 223 | -| 50 | 16780 | 761 | 94.7% | 85.0% | 0.37ms | 2.50ms (est. Read all md) | 895 | -| 500 | 197940 | 1883 | 98.9% | 85.0% | 1.92ms | 25.00ms (est. Read all md) | 2216 | +| 5 | 1315 | 178 | 83.1% | 80.0% | 0.11ms | 0.25ms (est. Read all md) | 223 | +| 50 | 16780 | 761 | 94.7% | 85.0% | 0.40ms | 2.50ms (est. Read all md) | 895 | +| 500 | 197940 | 1883 | 98.9% | 85.0% | 2.60ms | 25.00ms (est. Read all md) | 2216 | ### Interpretation (critical, not hype) @@ -31,11 +30,11 @@ | query | assignedLevel | top tokens | saving | hit | latency | |---|---|---|---|---| -| auth | post-it | 45 | 99.7% | ✅ | 1.31ms | -| api | post-it | 80 | 99.5% | ✅ | 0.36ms | -| jwt | post-it | 0 | n/a (miss) | ❌ | 0.31ms | +| auth | post-it | 45 | 99.7% | ✅ | 1.38ms | +| api | post-it | 80 | 99.5% | ✅ | 0.33ms | +| jwt | post-it | 0 | n/a (miss) | ❌ | 0.32ms | | pagination | post-it | 0 | n/a (miss) | ❌ | 0.31ms | -| cache | post-it | 0 | n/a (miss) | ❌ | 0.31ms | +| cache | post-it | 0 | n/a (miss) | ❌ | 0.36ms | ### What we learned while benchmarking (ideas & shortcomings →补) @@ -66,7 +65,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "avgTopTokens": 178, "avgSaving": "83.1%", "hitRate": "80.0%", - "avgLatency": "0.09ms", + "avgLatency": "0.11ms", "fullLatencyEst": "0.25ms (est. Read all md)", "tokensPerHit": 223, "perQuery": [ @@ -76,7 +75,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 50, "saving": "96.2%", "hit": true, - "latency": "0.48ms" + "latency": "0.57ms" }, { "query": "api", @@ -84,7 +83,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 15, "saving": "98.9%", "hit": true, - "latency": "0.22ms" + "latency": "0.25ms" }, { "query": "jwt", @@ -108,7 +107,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, - "latency": "0.04ms" + "latency": "0.05ms" } ] }, @@ -120,7 +119,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "avgTopTokens": 761, "avgSaving": "94.7%", "hitRate": "85.0%", - "avgLatency": "0.37ms", + "avgLatency": "0.40ms", "fullLatencyEst": "2.50ms (est. Read all md)", "tokensPerHit": 895, "perQuery": [ @@ -130,7 +129,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 45, "saving": "99.7%", "hit": true, - "latency": "1.31ms" + "latency": "1.38ms" }, { "query": "api", @@ -138,7 +137,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 80, "saving": "99.5%", "hit": true, - "latency": "0.36ms" + "latency": "0.33ms" }, { "query": "jwt", @@ -146,7 +145,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, - "latency": "0.31ms" + "latency": "0.32ms" }, { "query": "pagination", @@ -162,7 +161,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, - "latency": "0.31ms" + "latency": "0.36ms" } ] }, @@ -174,7 +173,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "avgTopTokens": 1883, "avgSaving": "98.9%", "hitRate": "85.0%", - "avgLatency": "1.92ms", + "avgLatency": "2.60ms", "fullLatencyEst": "25.00ms (est. Read all md)", "tokensPerHit": 2216, "perQuery": [ @@ -184,7 +183,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 45, "saving": "100.0%", "hit": true, - "latency": "4.01ms" + "latency": "5.40ms" }, { "query": "api", @@ -192,7 +191,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 45, "saving": "100.0%", "hit": true, - "latency": "2.09ms" + "latency": "2.38ms" }, { "query": "jwt", @@ -200,7 +199,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, - "latency": "2.05ms" + "latency": "2.32ms" }, { "query": "pagination", @@ -208,7 +207,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, - "latency": "2.48ms" + "latency": "2.66ms" }, { "query": "cache", @@ -216,7 +215,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, - "latency": "1.93ms" + "latency": "2.46ms" } ] } diff --git a/README.md b/README.md index 7de6bd1..f11883a 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ | 시나리오 | 전체 읽기 | 이 프로젝트 사용 | 절약 | 히트율 | |---|---|---|---|---| | 검색 5개 | 5,280 tok | 1,040 tok | **80%** | 80% | -| 검색 50개 | 25,580 tok | 1,758 tok | **93%** | 85% | +| 검색 50개 | 25,580 tok | ~1,760 tok | **~94%** | 85% | | 검색 500개 | 194,800 tok | 2,003 tok | **99%** | 85% | -| **세션 복원** 500개 | 191,825 tok (재독입) | **3,460 tok** (핸드오프) | **98%**, 손실 0 | — | +| **세션 복원** 500개 | 191,825 tok (재독입) | **3,460 tok** (핸드오프) | **98%**, 구조적 손실 0* | — | -*세션 복원: 압축(compaction) 없이 `CURRENT.md` + 핸드오프 포인터 번들로 새 세션이 ~600 tok 만에 기존 작업을 이어받음. 상세는 `docs/session-continuity.md`.* +*세션 복원: 압축 없이 포인터 번들로 복원. \*구조적 손실 0 — 단, entry로 저장한 것만 보장됨. 저장하지 않은 논의는 사라짐(저장 성실성이 전제).* - **에이전트 간 공유**: 모든 AI 에이전트가 `git pull` 하나로 동일한 `agent-context/`를 읽고 쓴다 — `agent-to-agent` 컨텍스트 브리지 -- **3단계 점진 공개 + 계층**: L1 `index.json` → L2 `graph.json`/`features.json` → L3 `*.md` 1~2개, 가벼운 AI가 `post-it`(15tok)→`library`(5000tok) 중 시작점 자동 결정 +- **3단계 점진 공개 + 계층**: L1 `index.json` → L2 `graph.json`/`features.json` → L3 `*.md` 1~2개, 규칙 기반 휴리스틱 라우터(LLM 호출 0)가 `post-it`(15tok)→`library`(5000tok) 중 시작점 자동 결정 — "가벼운 AI"는 휴리스틱을 의미 - **서브에이전트 불필요**: 모든 도구가 단일 Bash 호출 — 메인 에이전트가 직접 검색, Node 없으면 순수 Grep/Read 폴백까지 동작 - **Git이 곧 DB**: PR 리뷰·`git blame` 가능, 모든 agent가 `git pull`로 동기화 - **학습 루프**: `learnings`의 `cause/fix/lesson`으로 실패 반복 방지 @@ -26,6 +26,17 @@ **Muse Spark 1.2 Agent** (`opencode/muse-spark-1.2-contributor-free`, Meta Muse Spark via OpenCode) — 설계·구현·벤치마크·후기(`REVIEW.md`) 전부 이 에이전트가 직접 수행. 환경 상세는 `AGENT.md` `docs/agent-environment.md`. +## Architecture (4 layers — 경계 명시) + +| Layer | 책임 | 위치 | +|---|---|---| +| **Context Store** | 기억의 정본 저장 | `agent-context/*.md` + `index/graph/features.json` | +| **Retrieval** | 계층적 검색·레벨 배정 | `tools/agent-search-lite.mjs` + `agent-context-index.mjs` | +| **Handoff** | 세션 연속성 | `tools/agent-handoff.mjs` + `CURRENT.md` + `sessions/handoff/` | +| **Coordination** | 실시간 협업 상태 (지식 아님) | `tools/agent-sessions/radio.mjs` + `sessions/inbox/` + `radio/threads/` | + +Coordination은 지식을 만들지 않는다 — live 메시지는 로컬에서 소비되고, 지식화 가치가 있을 때만 entry→PR로 승격된다. 이 경계를 지키는 것이 기능 팽창 방지의 핵심이다. + ## 빠른 시작 ```bash diff --git a/agent-context/graph.json b/agent-context/graph.json index 3b691fb..563844b 100644 --- a/agent-context/graph.json +++ b/agent-context/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generated_at": "2026-08-26T09:51:47.670Z", + "generated_at": "2026-08-26T11:02:08.986Z", "_path": "agent-context/graph.json", "description": "기능 연관 그래프 — depends_on/affects로 영향 범위 추적. agent-context.config.json graph.edges로부터 생성됨.", "graph": { diff --git a/agent-context/index.json b/agent-context/index.json index 6af22a4..80bcad6 100644 --- a/agent-context/index.json +++ b/agent-context/index.json @@ -1,6 +1,6 @@ { "version": 1, - "generated_at": "2026-08-26T09:51:47.664Z", + "generated_at": "2026-08-26T11:02:08.983Z", "generated_by": "agent-context-index.mjs", "_path": "agent-context/index.json", "description": "L1 압축 카탈로그 — 저용량 에이전트가 가장 먼저 읽는 파일. preview 60자 + summary 120자로 본문 Read 없이 관련성 판단.", @@ -8,14 +8,62 @@ "soft_limit_chars": 200000, "max_entries": 1000, "should_compress": false, - "total_chars": 1117, - "total_entries": 1 + "total_chars": 1980, + "total_entries": 3 }, "counts": { + "learning": 1, + "note": 1, "handoff": 1, - "total": 1 + "total": 3 }, "entries": [ + { + "id": "learning-20260826-aaa11111", + "type": "learning", + "level": "memo", + "title": "JWT race condition", + "tags": [ + "auth", + "jwt" + ], + "feature": "auth", + "scope": "global", + "agent": "claude", + "created": "2026-08-26T11:00:26.279Z", + "updated": "2026-08-26T11:00:26.280Z", + "status": "done", + "priority": 5, + "summary": "JWT refresh race → mutex 해결. 검증: src/auth/refresh.ts:42", + "preview": "JWT refresh race → mutex 해결. 검증: src/auth/refresh.ts:42", + "path": "learnings/2026-08-26-jwt-race--claude.md", + "related": [], + "affects": [], + "chars": 429 + }, + { + "id": "note-20260826-a72de388", + "type": "note", + "level": "memo", + "title": "API moved", + "tags": [ + "note", + "api" + ], + "feature": "api", + "scope": "global", + "agent": "claude", + "created": "2026-08-26T11:00:26.070Z", + "updated": "2026-08-26T11:00:26.070Z", + "status": "done", + "priority": 5, + "summary": "API moved to /v2/items — post-it test", + "preview": "API moved to /v2/items — post-it test", + "path": "notes/2026-08-26-api-moved--claude.md", + "related": [], + "affects": [], + "chars": 434 + }, { "id": "handoff-20260826-khd31lw6", "type": "handoff", diff --git a/agent-context/learnings/2026-08-26-jwt-race--claude.md b/agent-context/learnings/2026-08-26-jwt-race--claude.md new file mode 100644 index 0000000..0b90fdf --- /dev/null +++ b/agent-context/learnings/2026-08-26-jwt-race--claude.md @@ -0,0 +1,20 @@ + +--- +id: learning-20260826-aaa11111 +type: learning +level: memo +title: "JWT race condition" +tags: [auth, jwt] +feature: auth +scope: global +agent: claude +created: 2026-08-26T11:00:26.279Z +updated: 2026-08-26T11:00:26.280Z +status: done +priority: 5 +summary: "JWT refresh race → mutex 해결. 검증: src/auth/refresh.ts:42" +refs: + - "src/auth/refresh.ts:42" +--- + +JWT race diff --git a/agent-context/notes/2026-08-26-api-moved--claude.md b/agent-context/notes/2026-08-26-api-moved--claude.md new file mode 100644 index 0000000..3b6d025 --- /dev/null +++ b/agent-context/notes/2026-08-26-api-moved--claude.md @@ -0,0 +1,23 @@ + +--- +id: note-20260826-a72de388 +type: note +level: memo +title: "API moved" +tags: [note, api] +feature: api +scope: global +agent: claude +created: 2026-08-26T11:00:26.070Z +updated: 2026-08-26T11:00:26.070Z +status: done +priority: 5 +summary: "API moved to /v2/items — post-it test" +--- + +## 결과 + +API moved to /v2/items — post-it test + + + diff --git a/agent-context/schema.json b/agent-context/schema.json index d042d7e..d705b1c 100644 --- a/agent-context/schema.json +++ b/agent-context/schema.json @@ -174,6 +174,25 @@ "type": "string" }, "description": "결과 중심 기록 — 검증 링크(문서·코드 경로). 도구 실행 로그 대신 결론+링크만 저장" + }, + "epistemic": { + "type": "string", + "enum": [ + "observed", + "inferred", + "hypothesis", + "verified", + "deprecated" + ], + "description": "인식 상태 — 관찰/추론/가설/검증됨/폐기. 기억 신뢰도 구분" + }, + "source": { + "type": "string", + "description": "근거 출처 (파일:라인, PR, 대화 등)" + }, + "verified_by": { + "type": "string", + "description": "검증한 에이전트·사람" } } } diff --git a/docs/hierarchy.md b/docs/hierarchy.md index 00ac508..fbd301b 100644 --- a/docs/hierarchy.md +++ b/docs/hierarchy.md @@ -50,7 +50,7 @@ ## 구현 -- **저장**: `tools/agent-search-lite.mjs --assign` 또는 `tools/agent-context-index.mjs`가 `index.json` 재생성 시 `level` 자동 계산 (길이 기반, 0 LLM 호출) +- **저장**: `--assign`만 쓰면 레벨 계산·출력일 뿐 파일 생성 없음. 실제 저장은 `--assign --save --title "T" --content "결론..." --type issue --feature auth` — entry 생성 + index 즉시 재생성까지 한 번에 (external review에서 발견된 문서-동작 불일치 수정) - **검색**: `node tools/agent-search-lite.mjs "query" --level post-it --limit 3` — 가벼운 AI가 `--level`을 자동 결정하면 생략 가능 - **호환**: 기존 `note/memo/...` 9타입은 `level` 없이도 동작 — `level`이 없으면 `chars`로 추정해 하위호환 diff --git a/docs/session-continuity.md b/docs/session-continuity.md index 3a952bf..8879792 100644 --- a/docs/session-continuity.md +++ b/docs/session-continuity.md @@ -33,7 +33,7 @@ node tools/agent-handoff.mjs save \ - `sessions/handoff/--.md` 생성 (task/done/key pointers/next) - `agent-context/CURRENT.md` 포인터 갱신 (~50 tok) — **새 세션의 첫 Read** -### 3) 새 세션 복원 — ~600 tok, 손실 0 +### 3) 새 세션 복원 — ~600 tok, 구조적 손실 0 ```bash Read agent-context/CURRENT.md # ~50 tok @@ -44,6 +44,8 @@ Read <검색된 1~2 md> # 온디맨드 전체 히스토리 재독입도, 압축 요약 의존도 없음. 벤치마크: 500개 기준 full re-read 대비 **98.2% 절약**, 구조적 손실 0 (`BENCHMARK.md` Session resume 섹션). +> **정직한 한계**: "손실 0"은 *entry로 저장한 것*에 한함. 저장하지 않은 논의는 사라진다 — 손실 방지는 에이전트가 작업 중 entry를 성실히 남기는 데 의존한다. 이것이 이 설계의 전제다. + ## 서브에이전트·AI 배정 없이 동작 (메인 에이전트 직접 검색) 모든 도구는 **단일 Bash 호출**이다 — 서브에이전트 스폰 없음, 라우팅용 AI 호출 없음: diff --git a/templates/frontmatter/learning.md b/templates/frontmatter/learning.md index a5f5a72..5a68a98 100644 --- a/templates/frontmatter/learning.md +++ b/templates/frontmatter/learning.md @@ -10,6 +10,8 @@ agent: claude created: 2026-08-27T10:00:00+09:00 updated: 2026-08-27T10:00:00+09:00 status: done +epistemic: verified # observed|inferred|hypothesis|verified|deprecated — 기억 신뢰도 +source: "src/auth/refresh.ts:42" priority: 5 summary: "refresh를 mutex 없이 병렬 호출하면 두 번째 토큰이 첫 번째를 덮어 로그아웃됨" related: [decisions/0001-use-file-db.md, bugs/2026-08-27-refresh-race--codex.md] diff --git a/tools/agent-context-index.mjs b/tools/agent-context-index.mjs index 381910c..51dbcb1 100644 --- a/tools/agent-context-index.mjs +++ b/tools/agent-context-index.mjs @@ -3,7 +3,7 @@ // agent-context/*.md frontmatter → index.json + graph.json 갱신 (universal, config-aware) // 사용: node tools/agent-context-index.mjs [--check] [--init] [--config ] [--dry-run] [--to-sqlite] -import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { readdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs'; import { join, relative, dirname } from 'node:path'; function parseArgs() { @@ -388,7 +388,11 @@ if (ARGS.toSqlite) { // TODO: implement actual FTS5 creation when dependency available } -writeFileSync(INDEX_PATH, JSON.stringify(nextIndex, null, 2) + '\n', 'utf8'); +// Issue(external review #8) fix: atomic write — concurrent agents never see a torn index.json. +// tmp+rename is atomic on POSIX; readers either get the old or the new file, never partial. +const tmpPath = INDEX_PATH + '.tmp-' + process.pid; +writeFileSync(tmpPath, JSON.stringify(nextIndex, null, 2) + '\n', 'utf8'); +renameSync(tmpPath, INDEX_PATH); console.log(`index.json regenerated: ${entries.length} entries, ${totalChars} chars, should_compress=${shouldCompress}`); console.log(`root: ${ROOT} (${ROOT_SOURCE})`); diff --git a/tools/agent-search-lite.mjs b/tools/agent-search-lite.mjs index aa0e1e6..403802b 100644 --- a/tools/agent-search-lite.mjs +++ b/tools/agent-search-lite.mjs @@ -1,76 +1,87 @@ #!/usr/bin/env node // Path: tools/agent-search-lite.mjs -// Lightweight AI search — hierarchical, fluid, 0 LLM calls, 0 install cost -// Inspired by AI accelerator cache hierarchy: post-it (L1) → memo (HBM) → diary (DRAM) → bookshelf (SSD) → library (cold) -// Search engine (&AI)[post-it|memo|diary|bookshelf|library] — 가벼운 AI가 질의 분석해 가장 작은 레벨부터 탐색, 히트 시 중단 +// Hierarchical search + level assignment — rule-based heuristic, 0 LLM calls, 0 install. +// (표현 정리: "가벼운 AI" = 규칙 기반 휴리스틱 라우터. 외부 리뷰 지적을 반영해 +// 문서·출력에서 AI 과장 표현을 휴리스틱으로 명확히 한다.) +// Cache-hierarchy metaphor: post-it (L1) → memo (HBM) → diary (DRAM) → bookshelf (SSD) → library (cold) +// +// Issue(external review) fixes: +// - --assign was print-only; docs claimed it saves. Now --save actually creates an entry. +// - Synonym expansion: query tokens expand via config `search.synonyms` (still 0 LLM) +// // Usage: -// node tools/agent-search-lite.mjs "auth jwt race" [--level post-it] [--limit 3] [--json] -// node tools/agent-search-lite.mjs --assign --content "some text" --priority 5 +// node tools/agent-search-lite.mjs "query" [--level L] [--limit N] [--json] +// node tools/agent-search-lite.mjs --assign --content "text" --priority 5 +// node tools/agent-search-lite.mjs --assign --save --title "T" --content "body..." \ +// --type issue --feature auth --agent claude [--priority 5] [--refs "a,b"] // node tools/agent-search-lite.mjs --benchmark -import { readFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { spawnSync } from 'node:child_process'; function resolveConfig() { const cands = [ + join(process.cwd(), 'agent-context.config.json'), new URL('../agent-context.config.json', import.meta.url).pathname, - new URL('../agent-context/agent-context.config.json', import.meta.url).pathname, ]; for (const p of cands) if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')); - return { contextRoot: 'agent-context', hierarchy: { levels: { 'post-it': { tokens: 15 }, memo: { tokens: 50 }, diary: { tokens: 200 }, bookshelf: { tokens: 1000 }, library: { tokens: 5000 } }, searchOrder: ['post-it','memo','diary','bookshelf','library'] } }; + return { contextRoot: 'agent-context' }; } const CONFIG = resolveConfig(); -// Issue #3 fix: cwd-first — operate on the user's project, fall back to script-relative only inside the source repo 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 LEVELS = CONFIG.hierarchy?.levels || { - 'post-it': { tokens: 15 }, - memo: { tokens: 50 }, - diary: { tokens: 200 }, - bookshelf: { tokens: 1000 }, - library: { tokens: 5000 }, + 'post-it': { tokens: 15 }, memo: { tokens: 50 }, diary: { tokens: 200 }, + bookshelf: { tokens: 1000 }, library: { tokens: 5000 }, }; const ORDER = CONFIG.hierarchy?.searchOrder || ['post-it','memo','diary','bookshelf','library']; const LEVEL_RANK = Object.fromEntries(ORDER.map((k,i)=>[k,i])); +// Issue(external review P0-recall) fix: config-driven synonym expansion, still 0 LLM +const SYNONYMS = CONFIG.search?.synonyms || {}; function parseArgs() { const a = process.argv.slice(2); - const out = { query: null, level: null, limit: 5, json: false, assign: false, content: null, priority: 3, benchmark: false, help: false }; + const out = { query: null, level: null, limit: 5, json: false, assign: false, save: false, + content: null, priority: 3, title: null, type: 'note', feature: 'global', + agent: 'system', refs: null, benchmark: false, help: false }; for (let i=0;i=4 && aff===0) return 'post-it'; // L1 cache — one-liner, high priority, no affect - if (len <= 80 && priority >=3) return 'memo'; // HBM - if (len <= 400) return 'diary'; // DRAM - if (len <= 2000 || aff >=2) return 'bookshelf'; // SSD - return 'library'; // cold + if (len <= 30 && priority >=4 && aff===0) return 'post-it'; + if (len <= 80 && priority >=3) return 'memo'; + if (len <= 400) return 'diary'; + if (len <= 2000 || aff >=2) return 'bookshelf'; + return 'library'; } function estimateLevel(entry) { - // If entry has level, use it; else estimate from chars/summary length (backward compat) if (entry.level && LEVEL_RANK[entry.level]!==undefined) return entry.level; const len = entry.chars || (entry.summary?.length || 0) + (entry.title?.length||0); - // Rough: chars 50 → post-it, 150 → memo, 600 → diary, 2500 → bookshelf, else library if (len <= 80) return 'post-it'; if (len <= 250) return 'memo'; if (len <= 800) return 'diary'; @@ -78,9 +89,7 @@ function estimateLevel(entry) { return 'library'; } -function lightweightAIAssignLevelForQuery(query) { - // Like hierarchy doc: query token count + keyword count decides starting level - // 1 word → post-it, short phrase → memo, sentence → diary, "overall flow" → bookshelf/library +function lightweightAssignLevelForQuery(query) { const q = query.toLowerCase(); const words = q.trim().split(/\s+/).filter(Boolean).length; if (q.includes('overall') || q.includes('전체') || q.includes('architecture') || q.includes('아키텍처')) return 'bookshelf'; @@ -115,9 +124,9 @@ async function semanticScoresIfEnabled(query, entries){ } async function search(query, opts={}) { - const index = JSON.parse(readFileSync(INDEX_PATH,'utf8')); + let index; try { index = JSON.parse(readFileSync(INDEX_PATH,'utf8')); } catch { index = { entries: [] }; } const entries = index.entries || []; - const requestedLevel = opts.level || lightweightAIAssignLevelForQuery(query); + const requestedLevel = opts.level || lightweightAssignLevelForQuery(query); const startRank = LEVEL_RANK[requestedLevel] ?? 0; // Hierarchical: only levels from requestedLevel up to library? Actually search from smallest up to requestedLevel? // Our hierarchy searchOrder is small→large, we start at requestedLevel and expand upward if needed @@ -151,7 +160,6 @@ async function search(query, opts={}) { const days = (Date.now() - new Date(e.updated).getTime())/86400000; if (days < 7) recency=1; else if (days < 30) recency=0.8; } catch {} - // Final: weighted const score = hitScore*0.5 - levelDistance*0.1 + priorityScore*0.2 + recency*0.1; const estTokens = LEVELS[lev]?.tokens || 200; return { entry:e, lev, levRank, levelDistance, hitScore, priorityScore, recency, score, estTokens }; @@ -179,7 +187,7 @@ async function search(query, opts={}) { return { query, assignedLevel: requestedLevel, - lightweightAI: { reason: `query ${qTokens.length} words → ${requestedLevel} (hierarchical cache)`, noLLM: true, zeroTokens: true }, + router: { type: 'rule-based heuristic (no LLM)', reason: `${rawTokens.length} words → ${requestedLevel}`, expandedTokens: qTokens.length - rawTokens.length }, order: ORDER, totalEntries: entries.length, evaluated: scored.length, @@ -191,11 +199,52 @@ async function search(query, opts={}) { }; } -const ARGS = parseArgs(); -if (ARGS.help) { - console.log(`Usage: - node tools/agent-search-lite.mjs "query" [--level post-it|memo|diary|bookshelf|library] [--limit 5] [--json] +function saveEntry(o) { + // Issue(external review) fix: --assign previously printed only; --save now writes a real entry + const dirMap = { issue:'bugs', bug:'bugs', learning:'learnings', idea:'ideas', note:'notes', + decision:'decisions', diary:'diary', todo:'todos', memo:'notes', 'work-history':'code-history', 'overall-flow':'notes' }; + const dir = join(ROOT, dirMap[o.type] || 'notes'); + mkdirSync(dir, { recursive: true }); + const date = new Date().toISOString().slice(0,10); + const fname = `${date}-${String(o.title||o.content).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,40)}--${o.agent}.md`; + const path = join(dir, fname); + const level = o.computedLevel; + const refs = o.refs ? o.refs.split(',').map(s=>s.trim()).filter(Boolean) : []; + const md = [ + ``, + '---', + `id: ${o.type}-${date.replace(/-/g,'')}-${Math.random().toString(16).slice(2,10)}`, + `type: ${o.type}`, + `level: ${level}`, + `title: "${String(o.title||o.content).slice(0,80)}"`, + `tags: [${o.type}, ${o.feature}]`, + `feature: ${o.feature}`, + `scope: global`, + `agent: ${o.agent}`, + `created: ${new Date().toISOString()}`, + `updated: ${new Date().toISOString()}`, + `status: done`, + `priority: ${o.priority}`, + `summary: "${String(o.content).slice(0,180)}"`, + ...(refs.length ? ['refs:', ...refs.map(r=>` - "${r}"`)] : []), + '---','', + `## 결과\n\n${o.content}\n`, + `\n`, + ].join('\n')+'\n'; + writeFileSync(path, md, 'utf8'); + // regenerate index so the new entry is searchable immediately + const idxSrc = new URL('./agent-context-index.mjs', import.meta.url).pathname; + spawnSync(process.execPath, [idxSrc], { stdio: 'inherit' }); + return { saved: true, path, level, tokens: LEVELS[level]?.tokens }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const ARGS = parseArgs(); + if (ARGS.help) { + console.log(`Usage: + node tools/agent-search-lite.mjs "query" [--level L] [--limit N] [--json] node tools/agent-search-lite.mjs --assign --content "text" --priority 5 + node tools/agent-search-lite.mjs --assign --save --title "T" --content "결론..." --type issue --feature auth [--agent claude] [--priority 5] [--refs "a,b"] node tools/agent-search-lite.mjs --benchmark Lightweight AI: rule-based, 0 LLM calls, 0 tokens, hierarchical post-it→library like cache→HBM→DRAM→SSD`); process.exit(0); From 59e00f45ea7dfd94499593c20f9e02f188f5c736 Mon Sep 17 00:00:00 2001 From: tak2-08 Date: Wed, 26 Aug 2026 12:17:43 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(vision):=206=EC=A0=90=20=EB=B9=84?= =?UTF-8?q?=EC=A0=84=20=EA=B5=AC=ED=98=84=20=E2=80=94=20=EC=9D=98=EB=AF=B8?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=8B=A8=EA=B3=84=ED=99=94,=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B4=80=EC=B0=B0,=20=EC=A7=80=EC=8B=9D=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84,=20=EA=B3=BC=EC=97=85=20=EB=B2=A4?= =?UTF-8?q?=EC=B9=98=EB=A7=88=ED=81=AC,=20FTS=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C,=20=EB=A1=9C=EB=93=9C=EB=A7=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [#1 의미 이해 — 단계적] - BM25-lite 필드 가중치 스코어링 (제목3/태그2/피처2/요약1) - 동의어 확장 실측: 'token authentication' → JWT entry 히트 (synonym +2) - 로컬 임베딩 opt-in 어댑터 (search.semantic.enabled) — 미설치 시 실행조차 안 하고 router.semantic에 unavailable 사유 정직 표기 후 휴리스틱 폴백. 기본 OFF로 zero-install 유지 [#2 자동 관찰] tools/ac-watch.mjs - git 이력 R1 fix-signal / R2 test+src / R3 대형변경 / R4 decision-word 감지 - .candidates/에 proposed+observed 후보 생성(실측 12건), promote 전까지 비확정 — 오염 방지 - 결과 중심 기록 원칙과 직렬: 자동화=신호 수집, 원칙=승격 형식 [#3 지식 그래프 기반] index.json.knowledge.edges[] - related[]에서 인과 엣지 유추: bug↔learning(caused_by/mitigates), decision supersedes, idea adopted_from, code-history implements. 1-hop, P1에서 supersedes 체인 전개 [#4 과업 벤치마크] tools/benchmark-task.mjs - 질의→정답 파일 도달율 + 과업당 토큰 측정 프록시 (현재 100% accuracy, 88 tok/task, 표본3) - 정직 한계 명시: 실작업 성공률은 라이브 에이전트 하네스 필요 — ROADMAP P1 [#5 장기 운영] index.json.stale{} — priority≥4·90일 경과 리포트 + ROADMAP.md 신설 [#6 SQLite] tools/ac-fts.mjs — Node ≥22.5 내장 node:sqlite FTS5 (0 npm install), 미지원 환경은 JSON+Grep 폴백 안내. build/query/status 기타: search-lite 전면 재작성(통합), fix/integration-defects 병합(e2e-workflow·CI·typesFluid 회수), '가벼운 AI'→'규칙 기반 휴리스틱' 문구 정리, Architecture 4계층 명시 검증: node --check 전체, e2e-workflow 6/6 pass, 시드 재현성, validate ok --- .claude/skills/agent-shared-context/SKILL.md | 11 + BENCHMARK.md | 16 + README.md | 2 + ROADMAP.md | 48 +++ .../.candidates/024d4e63ea-work-history.md | 30 ++ .../.candidates/2bb248d9ba-learning.md | 30 ++ .../.candidates/590d4f4cca-learning.md | 30 ++ .../.candidates/631556cf20-work-history.md | 30 ++ .../.candidates/7189ca202d-work-history.md | 30 ++ .../.candidates/7a7d9a464f-work-history.md | 30 ++ .../.candidates/b79365fec5-work-history.md | 30 ++ .../.candidates/c1916b3ddc-work-history.md | 30 ++ .../.candidates/d4370a407c-work-history.md | 30 ++ .../.candidates/e2636a67a9-work-history.md | 30 ++ .../.candidates/ea300c8755-work-history.md | 30 ++ .../.candidates/ebdff02c6e-learning.md | 30 ++ agent-context/graph.json | 2 +- agent-context/index.json | 63 +-- .../notes/2026-08-26-api-moved--claude.md | 12 +- skills/agent-shared-context/SKILL.md | 11 + tools/ac-fts.mjs | 84 ++++ tools/ac-watch.mjs | 129 ++++++ tools/agent-context-index.mjs | 31 ++ tools/agent-search-lite.mjs | 373 +++++++++--------- tools/benchmark-task.mjs | 79 ++++ 25 files changed, 994 insertions(+), 227 deletions(-) create mode 100644 ROADMAP.md create mode 100644 agent-context/.candidates/024d4e63ea-work-history.md create mode 100644 agent-context/.candidates/2bb248d9ba-learning.md create mode 100644 agent-context/.candidates/590d4f4cca-learning.md create mode 100644 agent-context/.candidates/631556cf20-work-history.md create mode 100644 agent-context/.candidates/7189ca202d-work-history.md create mode 100644 agent-context/.candidates/7a7d9a464f-work-history.md create mode 100644 agent-context/.candidates/b79365fec5-work-history.md create mode 100644 agent-context/.candidates/c1916b3ddc-work-history.md create mode 100644 agent-context/.candidates/d4370a407c-work-history.md create mode 100644 agent-context/.candidates/e2636a67a9-work-history.md create mode 100644 agent-context/.candidates/ea300c8755-work-history.md create mode 100644 agent-context/.candidates/ebdff02c6e-learning.md create mode 100644 tools/ac-fts.mjs create mode 100644 tools/ac-watch.mjs create mode 100644 tools/benchmark-task.mjs diff --git a/.claude/skills/agent-shared-context/SKILL.md b/.claude/skills/agent-shared-context/SKILL.md index 552ac45..0246847 100644 --- a/.claude/skills/agent-shared-context/SKILL.md +++ b/.claude/skills/agent-shared-context/SKILL.md @@ -144,6 +144,17 @@ node tools/benchmark.mjs # synthetic 5/50/500, writes BENCHMARK.m - 결론 + `refs`(검증 링크)만 저장. 다음 에이전트는 결론을 쓰거나 refs로 직접 확인 - 버그는 `repro`에 재현 레시피만 (이것도 과정 로그가 아니라 레시피) +## Auto-observability & FTS (v0.5.0) + +| 별칭 | 명령 | 동작 | +|---|---|---| +| /ac-watch | `node tools/ac-watch.mjs --since "1 day ago"` | git 이력에서 학습 후보 자동 감지 → .candidates/ (승격 전까지 비확정) | +| /ac-promote | `node tools/ac-watch.mjs promote ` | 후보를 정식 디렉터리로 승격 | +| /ac-fts | `node tools/ac-fts.mjs build/query/status` | SQLite FTS 백엔드 (Node ≥22.5 내장, 0 npm install) | +| /ac-tasks | `node tools/benchmark-task.mjs` | 검색→정답 도달 과업 성공률 측정 | + +자동 관찰은 **후보만** 만든다 — 결론은 에이전트가 채워 넣어야 오염이 없다. 상세: `ROADMAP.md` `docs/session-continuity.md`. + ## References - Concepts from `Coral-Protocol/AgentRadio` (Apache 2.0) and contemporary session collaboration patterns — file-based adaptation. See `docs/radio.md` `docs/sessions.md` `docs/hierarchy.md` `REFERENCES.md`. diff --git a/BENCHMARK.md b/BENCHMARK.md index 45b1122..7b5856a 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -287,3 +287,19 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. } ] ``` + +## Task-success benchmark (#4 프록시) + +```json +{ + "metric": "retrieval-task success (proxy for agent task success)", + "tasks": 3, + "accuracy": "100.0%", + "avgTokensPerTask": 88, + "flatReadTokensPerTask": 265, + "savingVsFlat": "66.7%", + "honesty_note": "이 수치는 \"검색→정답 파일 도달\" 프록시다. 실제 작업 성공률(버그 수정 등)은 라이브 에이전트 하네스 필요 — ROADMAP P1." +} +``` + +> 실행: `node tools/benchmark-task.mjs`. 위 지표는 "질의→정답 파일 도달" 프록시이며, 실제 작업 성공률은 라이브 에이전트 하네스 과제 (ROADMAP P1). diff --git a/README.md b/README.md index f11883a..d0aa223 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,8 @@ cp -r skills/agent-shared-context ~/.codex/skills/ **결과 중심 기록 원칙**: 도구 호출 로그 저장 금지 — 결론 + refs(검증 링크)만. 토큰 낭비 제거. +**자동 관찰(v0.5)**: `node tools/ac-watch.mjs` — git 이력에서 학습 후보를 자동 생성(.candidates/, proposed). 승격 전까지 비확정으로 오염 방지. `ROADMAP.md` 참조. + ## 검증 ```bash diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..50a9916 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,48 @@ + +# Roadmap — 6점 비전 반영 현황 (외부 리뷰·사용자 제안 통합) + +> 각 항목은 **채택/부분/보류**와 근거를 함께 기록한다. 전부 지금 하면 zero-install 철학이 무너지므로, 순서가 설계의 일부다. + +## #1 규칙 기반 → 의미 이해 — **부분 완료, 단계적 확장** + +| 단계 | 상태 | 내용 | +|---|---|---| +| 1a 동의어 확장 | ✅ v0.4.x | config `search.synonyms` — jwt↔token 등, 0 LLM. `token authentication` → JWT entry 히트 실측 | +| 1b 필드 가중치(BM25-lite) | ✅ v0.5.0 | 제목×3 태그×2 피처×2 요약×1 — naive includes 대비 랭킹 품질 향상 | +| 1c 로컬 임베딩 | 🔶 opt-in 어댑터 | `search.semantic.enabled=true` 시 `@xenova/transformers` 동적 로드. **미설치면 실행조차 안 하고** `router.semantic`에 unavailable 사유 정직 표기 후 휴리스틱 폴백. 기본 OFF — "0-install 셀링포인트 유지"가 트레이드오프 결론 | + +"가벼운 AI" 표현은 외부 리뷰 지적대로 **규칙 기반 휴리스틱 라우터**로 문서 전반에 명확히 표기했다. + +## #2 수동 기록 → 자동 관찰 — **부분 완료 (후보 생성 방식)** + +- 결과 중심 기록 원칙과 자동화는 **모순이 아니라 직렬**이다: 자동화는 *원시 신호*를 모으고, 결과 중심 원칙은 *승격된 기억*의 형식을 강제한다. +- 구현: `tools/ac-watch.mjs` — git 이력에서 R1 fix-signal / R2 test+src co-change / R3 대형 변경 / R4 decision-word를 감지해 `.candidates/`에 **후보** 생성 (0 LLM). 실측: 최근 7일 커밋에서 12건 후보. +- 오염 방지: 후보는 `status: proposed`, `epistemic: observed`로 자동 확정되지 않음. 에이전트가 결론을 덧붙인 뒤 `promote`해야 정식 기억. + +## #3 파일 → 지식 그래프 — **기반 완료, 심화는 P1** + +- `index.json.knowledge.edges[]`: related[]에서 유추한 인과 엣지 (`bug→learning=mitigates/caused_by`, `decision supersedes`, `idea adopted_from`, `code-history implements`). +- 현재는 1-hop 유추. P1: supersedes 체인 전개로 "이 결정이 왜 뒤집혔나" 질의 지원, graph.json과 병합. + +## #4 토큰 벤치마크 → 작업 성공률 — **프록시 하네스 완료, 실전 과제는 P1** + +- `tools/benchmark-task.mjs`: 질의→정답 파일 도달율(retrieval-task accuracy) + 과업당 토큰 측정. 현재 소규모 실측: accuracy 100%, 88 tok/task (표본 3 — 한계 명시). +- 정직한 선언: 이것은 프록시다. AgentRadio식 실작업 성공률(버그 수정 성공) 비교는 라이브 에이전트 하네스가 필요하며 P1 과제로 남긴다. + +## #5 실사용 규모 검증 — **도구 준비 완료, 운영 축적 필요** + +- 장기 운영 문제(오래된 기억 vs 최신 결정 모순)의 첫 도구: `index.json.stale{}` — priority≥4 · 90일 경과 항목 리포트. +- 진짜 답은 시간이 필요하다: 수백~수천 entry, 다중 에이전트 운용 데이터가 쌓여야 검증된다. ROADMAP상 P1 유지. + +## #6 JSON → SQLite — **선택 백엔드 완료 (Node 내장, 0 npm install)** + +- `tools/ac-fts.mjs`: Node ≥22.5 내장 `node:sqlite`로 FTS5 build/query/status. better-sqlite3 불필요. +- 미지원 환경(Node 20 등)은 JSON+Grep 폴백이 **기능 손실 없이** 계속 동작 — 전환 임계는 entries 300+ 권장. + +## 우선순위 (외부 리뷰 P0/P1/P2 재편성) + +- ~~P0 retrieval recall~~ → 1a·1b 완료, 1c opt-in +- ~~P0 provenance~~ → schema `epistemic/source/verified_by` (v0.4.1), watch 후보는 `observed`로 자동 부여 +- ~~P0 concurrency~~ → index atomic write(tmp+rename), 6-프로세스 병령 스모크 테스트 통과 +- P1 실전 벤치마크 / decision graph 심화 / E2E CI 유지보수 +- P2 vector DB·MCP server — 당분간 보류 (zero-install 철학) diff --git a/agent-context/.candidates/024d4e63ea-work-history.md b/agent-context/.candidates/024d4e63ea-work-history.md new file mode 100644 index 0000000..6ade417 --- /dev/null +++ b/agent-context/.candidates/024d4e63ea-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas1 +type: work-history +level: "" +title: "feat(continuity): 세션 압축 대체 핸드오프 — 손실 0·저토큰 복원, 서브에이전트 불필요" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.697Z +updated: 2026-08-26T11:56:39.697Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 684 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:024d4e63ea" +--- + +## 감지 근거 +R3 large change: 684 insertions (684 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/2bb248d9ba-learning.md b/agent-context/.candidates/2bb248d9ba-learning.md new file mode 100644 index 0000000..e75eab1 --- /dev/null +++ b/agent-context/.candidates/2bb248d9ba-learning.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas0 +type: learning +level: "" +title: "Merge pull request #5 from tak2-08/fix/issue-3-cwd-root-seed" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.696Z +updated: 2026-08-26T11:56:39.696Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R1 fix-signal: 'Merge pull request #5 from tak2-08/fix/issue-3-cwd-root-seed'. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:2bb248d9ba" +--- + +## 감지 근거 +R1 fix-signal: "Merge pull request #5 from tak2-08/fix/issue-3-cwd-root-seed" (164 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `learnings/` diff --git a/agent-context/.candidates/590d4f4cca-learning.md b/agent-context/.candidates/590d4f4cca-learning.md new file mode 100644 index 0000000..196c22e --- /dev/null +++ b/agent-context/.candidates/590d4f4cca-learning.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas0 +type: learning +level: "" +title: "fix(issue#3): cwd-first ROOT resolution + seeded benchmark + miss 지표 보정" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.696Z +updated: 2026-08-26T11:56:39.696Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R1 fix-signal: 'fix(issue#3): cwd-first ROOT resolution + seeded benchmark + miss 지표 보정'. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:590d4f4cca" +--- + +## 감지 근거 +R1 fix-signal: "fix(issue#3): cwd-first ROOT resolution + seeded benchmark + miss 지표 보정" (164 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `learnings/` diff --git a/agent-context/.candidates/631556cf20-work-history.md b/agent-context/.candidates/631556cf20-work-history.md new file mode 100644 index 0000000..63328f0 --- /dev/null +++ b/agent-context/.candidates/631556cf20-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas1 +type: work-history +level: "" +title: "Merge pull request #1 from tak2-08/agent/feat-radio-sessions" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.697Z +updated: 2026-08-26T11:56:39.697Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 1031 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:631556cf20" +--- + +## 감지 근거 +R3 large change: 1031 insertions (1031 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/7189ca202d-work-history.md b/agent-context/.candidates/7189ca202d-work-history.md new file mode 100644 index 0000000..75c65ea --- /dev/null +++ b/agent-context/.candidates/7189ca202d-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas0 +type: work-history +level: "" +title: "feat(commands): ac.mjs 통합 디스패처 + 결과 중심 기록 원칙 (사용자 아이디어 반영)" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.696Z +updated: 2026-08-26T11:56:39.696Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 254 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:7189ca202d" +--- + +## 감지 근거 +R3 large change: 254 insertions (254 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/7a7d9a464f-work-history.md b/agent-context/.candidates/7a7d9a464f-work-history.md new file mode 100644 index 0000000..7dd8860 --- /dev/null +++ b/agent-context/.candidates/7a7d9a464f-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas0 +type: work-history +level: "" +title: "Merge pull request #6 from tak2-08/feat/ac-commands-outcome-log" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.696Z +updated: 2026-08-26T11:56:39.696Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 254 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:7a7d9a464f" +--- + +## 감지 근거 +R3 large change: 254 insertions (254 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/b79365fec5-work-history.md b/agent-context/.candidates/b79365fec5-work-history.md new file mode 100644 index 0000000..2ce369e --- /dev/null +++ b/agent-context/.candidates/b79365fec5-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas2 +type: work-history +level: "" +title: "feat(live): AgentRadio passive awareness + Claude cross-session reverse-engineer" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.698Z +updated: 2026-08-26T11:56:39.698Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 1031 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:b79365fec5" +--- + +## 감지 근거 +R3 large change: 1031 insertions (1031 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/c1916b3ddc-work-history.md b/agent-context/.candidates/c1916b3ddc-work-history.md new file mode 100644 index 0000000..e66fd6e --- /dev/null +++ b/agent-context/.candidates/c1916b3ddc-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas1 +type: work-history +level: "" +title: "feat(hierarchy): 유동적 계층 저장 + 가벼운 AI 검색 + 객관적 벤치마크 + 후기" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.697Z +updated: 2026-08-26T11:56:39.697Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 1104 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:c1916b3ddc" +--- + +## 감지 근거 +R3 large change: 1104 insertions (1104 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/d4370a407c-work-history.md b/agent-context/.candidates/d4370a407c-work-history.md new file mode 100644 index 0000000..f2e2a1e --- /dev/null +++ b/agent-context/.candidates/d4370a407c-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas0 +type: work-history +level: "" +title: "Merge pull request #4 from tak2-08/agent/session-continuity" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.696Z +updated: 2026-08-26T11:56:39.696Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 684 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:d4370a407c" +--- + +## 감지 근거 +R3 large change: 684 insertions (684 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/e2636a67a9-work-history.md b/agent-context/.candidates/e2636a67a9-work-history.md new file mode 100644 index 0000000..1cc35b7 --- /dev/null +++ b/agent-context/.candidates/e2636a67a9-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas1 +type: work-history +level: "" +title: "Merge pull request #2 from tak2-08/agent/feat-fluid-hierarchy-benchmark" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.697Z +updated: 2026-08-26T11:56:39.697Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 1104 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:e2636a67a9" +--- + +## 감지 근거 +R3 large change: 1104 insertions (1104 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/ea300c8755-work-history.md b/agent-context/.candidates/ea300c8755-work-history.md new file mode 100644 index 0000000..86d2338 --- /dev/null +++ b/agent-context/.candidates/ea300c8755-work-history.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1gas2 +type: work-history +level: "" +title: "feat: initial universal agent-context DB (from T2Editor-v11 e42e8fd PR #97)" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.698Z +updated: 2026-08-26T11:56:39.698Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R3 large change: 3341 insertions. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:ea300c8755" +--- + +## 감지 근거 +R3 large change: 3341 insertions (3341 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `code-history/` diff --git a/agent-context/.candidates/ebdff02c6e-learning.md b/agent-context/.candidates/ebdff02c6e-learning.md new file mode 100644 index 0000000..4af7ff2 --- /dev/null +++ b/agent-context/.candidates/ebdff02c6e-learning.md @@ -0,0 +1,30 @@ + +--- +id: candidate-mta1garz +type: learning +level: "" +title: "fix(review): 외부 리뷰 P0 반영 — assign 저장, 동의어 확장, provenance, atomic write" +tags: [candidate, auto-watch] +feature: global +scope: global +agent: system +created: 2026-08-26T11:56:39.695Z +updated: 2026-08-26T11:56:39.695Z +status: proposed +priority: 2 +epistemic: observed +summary: "[auto-candidate] R1 fix-signal: 'fix(review): 외부 리뷰 P0 반영 — assign 저장, 동의어 확장, provenance, atomic write'. 사람/에이전트가 결론을 덧붙여 승격 필요." +refs: + - "commit:ebdff02c6e" +--- + +## 감지 근거 +R1 fix-signal: "fix(review): 외부 리뷰 P0 반영 — assign 저장, 동의어 확장, provenance, atomic write" (252 insertions) + +## 다음 단계 (에이전트가 수행) + +1. 아래 결론을 채워 넣고 status: proposed → done + +2. `node tools/ac.mjs index` 재생성 + +3. 파일을 정식 디렉터리로 이동: `learnings/` diff --git a/agent-context/graph.json b/agent-context/graph.json index 563844b..4ffd4ab 100644 --- a/agent-context/graph.json +++ b/agent-context/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generated_at": "2026-08-26T11:02:08.986Z", + "generated_at": "2026-08-26T11:58:03.544Z", "_path": "agent-context/graph.json", "description": "기능 연관 그래프 — depends_on/affects로 영향 범위 추적. agent-context.config.json graph.edges로부터 생성됨.", "graph": { diff --git a/agent-context/index.json b/agent-context/index.json index 80bcad6..9a55d5a 100644 --- a/agent-context/index.json +++ b/agent-context/index.json @@ -1,6 +1,6 @@ { "version": 1, - "generated_at": "2026-08-26T11:02:08.983Z", + "generated_at": "2026-08-26T11:58:03.541Z", "generated_by": "agent-context-index.mjs", "_path": "agent-context/index.json", "description": "L1 압축 카탈로그 — 저용량 에이전트가 가장 먼저 읽는 파일. preview 60자 + summary 120자로 본문 Read 없이 관련성 판단.", @@ -8,16 +8,39 @@ "soft_limit_chars": 200000, "max_entries": 1000, "should_compress": false, - "total_chars": 1980, + "total_chars": 1953, "total_entries": 3 }, "counts": { - "learning": 1, "note": 1, + "learning": 1, "handoff": 1, "total": 3 }, "entries": [ + { + "id": "note-20260826-39b7749c", + "type": "note", + "level": "post-it", + "title": "API moved", + "tags": [ + "note", + "api" + ], + "feature": "api", + "scope": "global", + "agent": "claude", + "created": "2026-08-26T11:52:10.157Z", + "updated": "2026-08-26T11:52:10.157Z", + "status": "done", + "priority": 5, + "summary": "API moved to /v2/items", + "preview": "API moved to /v2/items", + "path": "notes/2026-08-26-api-moved--claude.md", + "related": [], + "affects": [], + "chars": 407 + }, { "id": "learning-20260826-aaa11111", "type": "learning", @@ -41,29 +64,6 @@ "affects": [], "chars": 429 }, - { - "id": "note-20260826-a72de388", - "type": "note", - "level": "memo", - "title": "API moved", - "tags": [ - "note", - "api" - ], - "feature": "api", - "scope": "global", - "agent": "claude", - "created": "2026-08-26T11:00:26.070Z", - "updated": "2026-08-26T11:00:26.070Z", - "status": "done", - "priority": 5, - "summary": "API moved to /v2/items — post-it test", - "preview": "API moved to /v2/items — post-it test", - "path": "notes/2026-08-26-api-moved--claude.md", - "related": [], - "affects": [], - "chars": 434 - }, { "id": "handoff-20260826-khd31lw6", "type": "handoff", @@ -87,5 +87,14 @@ "affects": [], "chars": 1117 } - ] + ], + "knowledge": { + "note": "related[]에서 유추한 인과 엣지 — bug↔learning, decision supersedes 등. graph.json(feature)과 별개의 지식 그래프.", + "edges": [] + }, + "stale": { + "threshold_days": 90, + "count": 0, + "items": [] + } } diff --git a/agent-context/notes/2026-08-26-api-moved--claude.md b/agent-context/notes/2026-08-26-api-moved--claude.md index 3b6d025..2465cca 100644 --- a/agent-context/notes/2026-08-26-api-moved--claude.md +++ b/agent-context/notes/2026-08-26-api-moved--claude.md @@ -1,23 +1,23 @@ --- -id: note-20260826-a72de388 +id: note-20260826-39b7749c type: note -level: memo +level: post-it title: "API moved" tags: [note, api] feature: api scope: global agent: claude -created: 2026-08-26T11:00:26.070Z -updated: 2026-08-26T11:00:26.070Z +created: 2026-08-26T11:52:10.157Z +updated: 2026-08-26T11:52:10.157Z status: done priority: 5 -summary: "API moved to /v2/items — post-it test" +summary: "API moved to /v2/items" --- ## 결과 -API moved to /v2/items — post-it test +API moved to /v2/items diff --git a/skills/agent-shared-context/SKILL.md b/skills/agent-shared-context/SKILL.md index ccfd5c3..890bf4f 100644 --- a/skills/agent-shared-context/SKILL.md +++ b/skills/agent-shared-context/SKILL.md @@ -105,6 +105,17 @@ node tools/agent-search-lite.mjs "" ✅ "JWT race → 전역 mutex 해결. 검증: src/auth/refresh.ts:42" ``` +## Auto-observability & FTS (v0.5.0) + +| 별칭 | 명령 | 동작 | +|---|---|---| +| /ac-watch | `node tools/ac-watch.mjs --since "1 day ago"` | git 이력에서 학습 후보 자동 감지 → .candidates/ (승격 전까지 비확정) | +| /ac-promote | `node tools/ac-watch.mjs promote ` | 후보를 정식 디렉터리로 승격 | +| /ac-fts | `node tools/ac-fts.mjs build/query/status` | SQLite FTS 백엔드 (Node ≥22.5 내장, 0 npm install) | +| /ac-tasks | `node tools/benchmark-task.mjs` | 검색→정답 도달 과업 성공률 측정 | + +자동 관찰은 **후보만** 만든다 — 결론은 에이전트가 채워 넣어야 오염이 없다. 상세: `ROADMAP.md` `docs/session-continuity.md`. + ## References - Concepts from `Coral-Protocol/AgentRadio` (Apache 2.0) and contemporary session collaboration patterns — file-based adaptation. See `docs/radio.md` `docs/sessions.md` `docs/hierarchy.md` `REFERENCES.md`. diff --git a/tools/ac-fts.mjs b/tools/ac-fts.mjs new file mode 100644 index 0000000..74b6083 --- /dev/null +++ b/tools/ac-fts.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Path: tools/ac-fts.mjs +// Optional SQLite FTS5 backend — 500+ entries에서 Grep(≈500ms) → FTS(≈20ms). +// zero-install 기본 철학 유지: Node ≥22.5의 내장 node:sqlite 를 사용하므로 +// npm install 가 필요 없다. 미지원 환경에서는 정직하게 방법을 안내한다. +// +// Usage: +// node tools/ac-fts.mjs build # index.json → search.db (FTS5) +// node tools/ac-fts.mjs query "jwt race" # FTS 질의 +// node tools/ac-fts.mjs status + +import { existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +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')); + 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); +const DB_PATH = join(DB_DIR, 'search.db'); + +let sqlite; +try { + ({ DatabaseSync: sqlite } = await import('node:sqlite')); +} catch { + console.error(JSON.stringify({ + error: 'node:sqlite unavailable', + how_to: [ + 'Node ≥22.5 필요 (내장 node:sqlite). 현재: ' + process.version, + 'Node ≥22.5로 실행: npx -p node@22 node --experimental-sqlite tools/ac-fts.mjs build', + '또는 storage.backend=sqlite + better-sqlite3 설치 경로 사용 (선택)', + ], + fallback: 'backend=json 상태 유지 — Grep + index.json 으로 계속 동작 (기능 손실 없음)', + }, null, 2)); + process.exit(1); +} + +function loadEntries() { + try { return JSON.parse(readFileSync(INDEX_PATH,'utf8')).entries || []; } + catch { return []; } +} + +function build() { + mkdirSync(DB_DIR, { recursive:true }); + const db = new sqlite.DatabaseSync(DB_PATH); + db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ctx USING fts5(id UNINDEXED, path UNINDEXED, title, summary, tags, feature);`); + db.exec(`DELETE FROM ctx;`); + const ins = db.prepare(`INSERT INTO ctx (id, path, title, summary, tags, feature) VALUES (?, ?, ?, ?, ?, ?)`); + let n = 0; + for (const e of loadEntries()) { + ins.run(e.id, e.path, e.title, e.summary||'', (e.tags||[]).join(' '), e.feature||'global'); + n++; + } + db.close(); + console.log(JSON.stringify({ built: true, db: `${PRIVATE}/search.db`, entries: n }, null, 2)); +} + +function query(q, limit = 10) { + if (!existsSync(DB_PATH)) { console.error('search.db 없음 — 먼저 build'); process.exit(1); } + const db = new sqlite.DatabaseSync(DB_PATH); + const safe = q.replace(/["'*]/g, ' ').trim(); + const rows = db.prepare(`SELECT id, path, title, snippet(ctx,2,'[',']','…',12) AS snip FROM ctx WHERE ctx MATCH ? LIMIT ?`).all(safe, limit); + db.close(); + console.log(JSON.stringify({ query: q, hits: rows.length, rows }, null, 2)); +} + +function status() { + console.log(JSON.stringify({ + db_exists: existsSync(DB_PATH), db: DB_PATH, + entries_in_index: loadEntries().length, + recommendation: loadEntries().length >= 300 ? 'FTS 전환 권장 (Grep 체감 저하 구간)' : '현재 Grep으로 충분 — 전환 임계 300+', + }, null, 2)); +} + +const [cmd, ...rest] = process.argv.slice(2); +if (cmd === 'build') build(); +else if (cmd === 'query') query(rest.join(' ') || ''); +else if (cmd === 'status') status(); +else { console.log('Usage: node tools/ac-fts.mjs build|query "..." | status'); process.exit(cmd ? 1 : 0); } diff --git a/tools/ac-watch.mjs b/tools/ac-watch.mjs new file mode 100644 index 0000000..5411ea1 --- /dev/null +++ b/tools/ac-watch.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// Path: tools/ac-watch.mjs +// Auto-observability — "에이전트가 기록해야만 남는다"는 약점 보완. +// git 이력에서 학습 가치가 있는 신호를 감지해 **entry 후보**를 생성한다. +// 후보는 agent-context/.candidates/ 에 쌓이고, 에이전트가 검토해 결론을 덧붙인 뒤 +// 정식 디렉터리로 승격한다. (자동 생성 ≠ 자동 확정 — 오염 방지) +// +// 감지 규칙 (0 LLM): +// R1 커밋 메시지가 fix|bug|hotfix|regression 포함 → learning 후보 +// R2 커밋에서 tests/** 변경 + 소스 동시 변경 → learning 후보 +// R3 diff 라인 수 200+ → work-history 후보 +// R4 결정성 단어(decide|choose|migrate|switch) in message → decision 후보 +// +// Usage: +// node tools/ac-watch.mjs [--since "2 days ago"] [--out dir] +// node tools/ac-watch.mjs promote # .candidates → 정식 위치 + +import { execFileSync } from 'node:child_process'; +import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +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')); + 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) { + try { return execFileSync('git', args, { cwd: process.cwd(), encoding:'utf8' }); } + catch { return ''; } +} + +function detect({ since = '1 day ago' }) { + const log = git(['log', `--since=${since}`, '--pretty=%H%x09%s']).trim().split('\n').filter(Boolean); + const candidates = []; + for (const line of log) { + const [hash, subject] = line.split('\t'); + const stat = git(['show','--stat','--pretty=','--name-only',hash]).trim().split('\n'); + const files = stat.filter(f => f && !f.includes('|')).map(f=>f.trim()); + const insertions = Number((git(['show','--shortstat','--pretty=',hash]).match(/(\d+) insertion/)||[0,0])[1]) || 0; + const touchesTests = files.some(f=>/(tests?|spec)\//i.test(f)); + const touchesSrc = files.some(f=>/\.(ts|js|py|php|go|rs)$/.test(f)); + const s = subject.toLowerCase(); + + let type=null, why=''; + if (/fix|bug|hotfix|regression/.test(s)) { type='learning'; why=`R1 fix-signal: "${subject}"`; } + else if (touchesTests && touchesSrc) { type='learning'; why=`R2 test+src co-change (${files.length} files)`; } + else if (/decide|choose|migrate|switch to/.test(s)) { type='decision'; why=`R4 decision-signal: "${subject}"`; } + else if (insertions >= 200) { type='work-history'; why=`R3 large change: ${insertions} insertions`; } + + if (type) candidates.push({ + hash: hash.slice(0,10), subject, type, why, + insertions, files: files.slice(0,8), + suggestedPath: `${type === 'decision' ? 'decisions' : type==='learning' ? 'learnings' : 'code-history'}/`, + refs: [`commit:${hash.slice(0,10)}`], + title: subject.slice(0,80), + summary: `[auto-candidate] ${why}. 사람/에이전트가 결론을 덧붙여 승격 필요.`, + }); + } + return candidates; +} + +function writeCandidates(list) { + mkdirSync(CAND, { recursive:true }); + const written = []; + for (const c of list) { + const fname = `${c.hash}-${c.type}.md`; + const path = join(CAND, fname); + if (existsSync(path)) { written.push({ path:`.candidates/${fname}`, skipped:'exists' }); continue; } + const md = [ + ``, '---', + `id: candidate-${Date.now().toString(36)}`, `type: ${c.type}`, `level: ""`, + `title: "${c.title.replace(/"/g,"'")}"`, `tags: [candidate, auto-watch]`, + `feature: global`, `scope: global`, `agent: system`, + `created: ${new Date().toISOString()}`, `updated: ${new Date().toISOString()}`, + `status: proposed`, `priority: 2`, + `epistemic: observed`, + `summary: "${c.summary.replace(/"/g,"'").slice(0,180)}"`, + 'refs:', ...c.refs.map(r=>` - "${r}"`), + '---','', + `## 감지 근거\n${c.why} (${c.insertions} insertions)\n`, + `## 다음 단계 (에이전트가 수행)\n`, + `1. 아래 결론을 채워 넣고 status: proposed → done\n`, + `2. \`node tools/ac.mjs index\` 재생성\n`, + `3. 파일을 정식 디렉터리로 이동: \`${c.suggestedPath}\``, + ].join('\n')+'\n'; + writeFileSync(path, md,'utf8'); + written.push({ path:`.candidates/${fname}`, created:true }); + } + return written; +} + +const a = process.argv.slice(2); +if (a[0] === 'promote') { + const f = a[1]; + if (!f) { console.error('promote requires '); process.exit(1); } + const src = join(CAND, f); + if (!existsSync(src)) { console.error(`not found: ${src}`); process.exit(1); } + const content = readFileSync(src,'utf8'); + const m = content.match(/type: ([a-z-]+)/); + const dirMap = { learning:'learnings', decision:'decisions', 'work-history':'code-history', bug:'bugs' }; + const destDir = join(ROOT, dirMap[m?.[1]] || 'notes'); + mkdirSync(destDir,{recursive:true}); + const destName = f.replace(/^[\w]+-/, ''); // strip hash prefix + // move via git mv if tracked, else fs rename + try { execFileSync('git',['mv',src,join(destDir,destName)],{cwd:process.cwd()}); } + catch { + const { renameSync } = await import('node:fs'); + renameSync(src, join(destDir,destName)); + } + console.log(`promoted → ${join(destDir,destName)} (결론을 채운 뒤 node tools/ac.mjs index)`); + process.exit(0); +} + +const sinceIdx = a.indexOf('--since'); +const since = sinceIdx !== -1 ? a[sinceIdx+1] : '1 day ago'; +const list = detect({ since }); +const written = writeCandidates(list); +console.log(JSON.stringify({ + scanned_since: since, + commits_with_signal: list.length, + candidates_written: written.filter(w=>w.created).length, + skipped_existing: written.filter(w=>w.skipped).length, + out: '.candidates/', + note: '후보는 제안일 뿐 — 에이전트가 결론을 채우고 promote 해야 정식 기억이 됨 (오염 방지)', +}, null, 2)); diff --git a/tools/agent-context-index.mjs b/tools/agent-context-index.mjs index 4f65bfb..ca7436a 100644 --- a/tools/agent-context-index.mjs +++ b/tools/agent-context-index.mjs @@ -339,6 +339,35 @@ try { indexData = { version: 1 }; } +// [#3] 인과 관계 추출 — related[]가 가리키는 대상 entry의 type으로 관계 종류 유추 +// (decision→supersedes, bug→learning=mitigated_by, idea→decision=adopted_as 등) +const byPath = new Map(entries.map(e=>[e.path, e])); +const relations = []; +for (const e of entries) { + for (const r of (e.related||[])) { + const t = byPath.get(r.replace(/^agent-context\//,'')); + if (!t || t.id===e.id) continue; + let kind = 'references'; + if (e.type==='decision' && t.type==='decision') kind = 'supersedes'; + else if (e.type==='learning' && t.type==='bug') kind = 'mitigates'; + else if (e.type==='bug' && t.type==='learning') kind = 'caused_by'; + else if (e.type==='decision' && t.type==='idea') kind = 'adopted_from'; + else if (e.type==='code-history' && t.type==='decision') kind = 'implements'; + relations.push({ from: e.id, to: t.id, kind }); + } +} +const knowledge = { + note: 'related[]에서 유추한 인과 엣지 — bug↔learning, decision supersedes 등. graph.json(feature)과 별개의 지식 그래프.', + edges: relations, +}; + +// [#5] 장기 운영 — 오래된 고우선순위 기억의 신선도 리포트 (모순 정리 대상 후보) +const STALE_DAYS = CONFIG.storage?.staleDays ?? 90; +const now = Date.now(); +const stale = entries + .filter(e => e.priority >= 4 && (now - new Date(e.updated).getTime()) > STALE_DAYS*86400000) + .map(e => ({ id:e.id, title:e.title, ageDays: Math.floor((now-new Date(e.updated).getTime())/86400000), path:e.path })); + const nextIndex = { version: 1, generated_at: new Date().toISOString(), @@ -354,6 +383,8 @@ const nextIndex = { }, counts, entries, + knowledge, + stale: { threshold_days: CONFIG.storage?.staleDays ?? 90, count: stale.length, items: stale.slice(0,10) }, }; if (ARGS.check && !ARGS.init) { diff --git a/tools/agent-search-lite.mjs b/tools/agent-search-lite.mjs index 403802b..a6d5527 100644 --- a/tools/agent-search-lite.mjs +++ b/tools/agent-search-lite.mjs @@ -1,24 +1,14 @@ #!/usr/bin/env node // Path: tools/agent-search-lite.mjs -// Hierarchical search + level assignment — rule-based heuristic, 0 LLM calls, 0 install. -// (표현 정리: "가벼운 AI" = 규칙 기반 휴리스틱 라우터. 외부 리뷰 지적을 반영해 -// 문서·출력에서 AI 과장 표현을 휴리스틱으로 명확히 한다.) -// Cache-hierarchy metaphor: post-it (L1) → memo (HBM) → diary (DRAM) → bookshelf (SSD) → library (cold) -// -// Issue(external review) fixes: -// - --assign was print-only; docs claimed it saves. Now --save actually creates an entry. -// - Synonym expansion: query tokens expand via config `search.synonyms` (still 0 LLM) -// -// Usage: -// node tools/agent-search-lite.mjs "query" [--level L] [--limit N] [--json] -// node tools/agent-search-lite.mjs --assign --content "text" --priority 5 -// node tools/agent-search-lite.mjs --assign --save --title "T" --content "body..." \ -// --type issue --feature auth --agent claude [--priority 5] [--refs "a,b"] -// node tools/agent-search-lite.mjs --benchmark +// Hierarchical search + level assignment for agent-context. +// Router = rule-based heuristic (0 LLM calls, 0 install). Optional local-embedding +// adapter is opt-in via config `search.semantic.enabled` and degrades honestly. +// Cache-hierarchy metaphor: post-it(L1) → memo(HBM) → diary(DRAM) → bookshelf(SSD) → library(cold) import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; function resolveConfig() { const cands = [ @@ -39,239 +29,236 @@ const LEVELS = CONFIG.hierarchy?.levels || { bookshelf: { tokens: 1000 }, library: { tokens: 5000 }, }; const ORDER = CONFIG.hierarchy?.searchOrder || ['post-it','memo','diary','bookshelf','library']; -const LEVEL_RANK = Object.fromEntries(ORDER.map((k,i)=>[k,i])); -// Issue(external review P0-recall) fix: config-driven synonym expansion, still 0 LLM -const SYNONYMS = CONFIG.search?.synonyms || {}; - -function parseArgs() { - const a = process.argv.slice(2); - const out = { query: null, level: null, limit: 5, json: false, assign: false, save: false, - content: null, priority: 3, title: null, type: 'note', feature: 'global', - agent: 'system', refs: null, benchmark: false, help: false }; - for (let i=0;i=4 && aff===0) return 'post-it'; - if (len <= 80 && priority >=3) return 'memo'; - if (len <= 400) return 'diary'; - if (len <= 2000 || aff >=2) return 'bookshelf'; - return 'library'; -} +const RANK = Object.fromEntries(ORDER.map((k,i)=>[k,i])); +const rankOf = l => RANK[l] ?? 2; +export { search }; +const __isMain = import.meta.url === pathToFileURL(process.argv[1]).href; -function estimateLevel(entry) { - if (entry.level && LEVEL_RANK[entry.level]!==undefined) return entry.level; - const len = entry.chars || (entry.summary?.length || 0) + (entry.title?.length||0); - if (len <= 80) return 'post-it'; - if (len <= 250) return 'memo'; - if (len <= 800) return 'diary'; - if (len <= 3000) return 'bookshelf'; - return 'library'; -} - -function lightweightAssignLevelForQuery(query) { - const q = query.toLowerCase(); - const words = q.trim().split(/\s+/).filter(Boolean).length; - if (q.includes('overall') || q.includes('전체') || q.includes('architecture') || q.includes('아키텍처')) return 'bookshelf'; - if (q.includes('flow') || q.includes('흐름')) return 'bookshelf'; - if (words <= 1) return 'post-it'; - if (words <= 3) return 'memo'; - if (words <= 8) return 'diary'; - return 'bookshelf'; -} - -// [#1] 동의어 확장 (config search.synonyms) — 0 LLM +// ── 동의어 확장 (config search.synonyms) — 0 LLM ───────────────────────────── const SYNONYMS = CONFIG.search?.synonyms || {}; function expandTokens(tokens) { const set = new Set(tokens); - for (const t of tokens) { const syn = SYNONYMS[t]; if (Array.isArray(syn)) syn.forEach(x=>set.add(x)); } + for (const t of tokens) { const s = SYNONYMS[t]; if (Array.isArray(s)) s.forEach(x=>set.add(x)); } for (const [k, list] of Object.entries(SYNONYMS)) if (tokens.some(t => list.includes(t))) set.add(k); return [...set]; } -// [#1] 선택적 의미 검색 어댑터 — 기본 OFF. 활성화 시 로컬 임베딩을 시도하고, -// 불가하면 'unavailable'을 정직히 반환해 휴리스틱으로 폴백한다 (zero-install 유지). -async function semanticScoresIfEnabled(query, entries){ + +// ── 선택적 의미 어댑터 — 기본 OFF. 실패 시 정직 폴백 (zero-install 유지) ──── +async function semanticScoresIfEnabled(query, entries) { const cfg = CONFIG.search?.semantic; if (!cfg?.enabled) return null; try { const mod = await import(cfg.module || '@xenova/transformers'); const extractor = await mod.pipeline('feature-extraction', cfg.model || 'Xenova/all-MiniLM-L6-v2'); - const embed = async t => { const out = await extractor(t, { pooling:'mean', normalize:true }); return Array.from(out.data); }; + const embed = async t => { const o = await extractor(t, { pooling:'mean', normalize:true }); return Array.from(o.data); }; const cos = (a,b)=>{ let d=0,na=0,nb=0; for(let i=0;i({ id:e.id, sim: cos(qv, await embed((e.title||'')+' '+(e.summary||''))) }))); - } catch(err){ return { unavailable: String(err.message||err).slice(0,140) }; } + return await Promise.all(entries.map(async e => ({ id:e.id, sim: cos(qv, await embed((e.title||'')+' '+(e.summary||''))) }))); + } catch (err) { return { unavailable: String(err.message||err).slice(0,140) }; } } -async function search(query, opts={}) { - let index; try { index = JSON.parse(readFileSync(INDEX_PATH,'utf8')); } catch { index = { entries: [] }; } - const entries = index.entries || []; - const requestedLevel = opts.level || lightweightAssignLevelForQuery(query); - const startRank = LEVEL_RANK[requestedLevel] ?? 0; - // Hierarchical: only levels from requestedLevel up to library? Actually search from smallest up to requestedLevel? - // Our hierarchy searchOrder is small→large, we start at requestedLevel and expand upward if needed - // For now, filter to levels <= requestedLevel rank? But user wants small→large, so if query is "auth" (post-it), we only look at post-it/memo? But if query is broad, we need larger - // Safer: include entries whose level rank <= startRank + 1? Actually we want to include small levels first, but if query is post-it, we should prioritize small, but still consider larger if no hit - // Implementation: rank entries by (level distance from requestedLevel) + text relevance - const qTokens = expandTokens(query.toLowerCase().split(/\s+/).filter(Boolean)); - const scored = entries.map(e=>{ +function heuristicLevel(query) { + const q = query.toLowerCase(); + const words = q.split(/\s+/).filter(Boolean).length; + if (q.includes('overall') || q.includes('architecture') || q.includes('아키텍처') || q.includes('흐름')) return 'bookshelf'; + if (words <= 1) return 'post-it'; + if (words <= 3) return 'memo'; + if (words <= 8) return 'diary'; + return 'bookshelf'; +} + +function estimateLevel(e) { + if (e.level && RANK[e.level] !== undefined) return e.level; + const len = e.chars || ((e.summary||'').length + (e.title||'').length); + if (len <= 80) return 'post-it'; + if (len <= 250) return 'memo'; + if (len <= 800) return 'diary'; + if (len <= 3000) return 'bookshelf'; + return 'library'; +} + +// collect: 주어진 시작 랭크로 스코어링·정렬 (miss-expansion의 빌딩블록) +function collect(entries, qTokens, opts, startRank, query) { + const limit = opts.limit || 5; + const scored = entries.map(e => { const lev = estimateLevel(e); - const levRank = LEVEL_RANK[lev] ?? 2; - const levelDistance = Math.abs(levRank - startRank); // 0 is best - // Text relevance: simple TF count over title+tags+summary+feature - // [#1] 필드 가중치 매칭 (BM25-lite) — 제목>태그>요약. naive includes 대비 랭킹 품질 향상 - const fTitle=(e.title||'').toLowerCase(), fTags=(e.tags||[]).join(' ').toLowerCase(), - fFeat=(e.feature||'').toLowerCase(), fSum=(e.summary||'').toLowerCase(), - fPrev=(e.preview||'').toLowerCase(); - let wSum=0; + const levRank = rankOf(lev); + const levelDistance = Math.abs(levRank - startRank); + // [#1] 필드 가중치 (BM25-lite): 제목3 태그2 피처2 요약1 미리보기1 + const fT=(e.title||'').toLowerCase(), fG=(e.tags||[]).join(' ').toLowerCase(), + fF=(e.feature||'').toLowerCase(), fS=(e.summary||'').toLowerCase(), + fP=(e.preview||'').toLowerCase(); + let w=0; for (const tok of qTokens) { - if (fTitle.includes(tok)) wSum+=3; - if (fTags.includes(tok)) wSum+=2; - if (fFeat.includes(tok)) wSum+=2; - if (fSum.includes(tok)) wSum+=1; - if (fPrev.includes(tok)) wSum+=1; + if (fT.includes(tok)) w+=3; + if (fG.includes(tok)) w+=2; + if (fF.includes(tok)) w+=2; + if (fS.includes(tok)) w+=1; + if (fP.includes(tok)) w+=1; } const maxW = qTokens.length*9; - const hitScore = maxW ? Math.min(1, wSum/maxW) : 0; // 0-1 - const priorityScore = (e.priority||3)/5; // 0.2-1 - // Recency: updated within 30 days → boost + let hitScore = maxW ? Math.min(1, w/maxW) : 0; + const priorityScore = (e.priority||3)/5; let recency = 0.5; - try { - const days = (Date.now() - new Date(e.updated).getTime())/86400000; - if (days < 7) recency=1; else if (days < 30) recency=0.8; - } catch {} + 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, levRank, levelDistance, hitScore, priorityScore, recency, score, estTokens }; - }).filter(s=>s.hitScore>0 || s.entry.feature===query.toLowerCase() || opts.level); // if no hit but level filter, keep - // If no hit, return empty (no need to read large) - // Sort by score desc - // [#1] semantic opt-in 블렌딩 — 활성화·모델 사용 가능 시에만 작동, 실패는 정직 표기 + return { entry:e, lev, levelDistance, hitScore, score, estTokens }; + }).filter(s => s.hitScore > 0 || 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); + const fullTokens = entries.reduce((s,e)=>s+(LEVELS[estimateLevel(e)]?.tokens||200),0); + return { top, topTokens, fullTokens, hit: top.length>0, evaluated: scored.length }; +} + +async function search(query, opts={}) { + let index; try { index = JSON.parse(readFileSync(INDEX_PATH,'utf8')); } catch { index = { entries: [] }; } + const entries = index.entries || []; + const assignedLevel = opts.level || heuristicLevel(query); + const rawTokens = query.toLowerCase().split(/\s+/).filter(Boolean); + const qTokens = expandTokens(rawTokens); + + // cache-miss expansion: 시작 레벨에서 miss면 큰 레벨로 최대 2회 확장 + let res = collect(entries, qTokens, opts, rankOf(assignedLevel), query); + res.assignedLevel = assignedLevel; + if (!res.hit && !opts.level) { + let r = ORDER.indexOf(assignedLevel); + for (let step=0; step<2 && r+1 < ORDER.length; step++) { + r++; + const retry = collect(entries, qTokens, opts, rankOf(ORDER[r])); + if (retry.hit) { res = retry; res.expandedTo = ORDER[r]; res.assignedLevel = assignedLevel; break; } + } + } + + // [#1] semantic opt-in 블렌딩 — 활성화·모델 사용 가능 시에만 작동 const sem = await semanticScoresIfEnabled(query, entries); - if (sem && !sem.unavailable) { + if (sem && !sem.unavailable && res.top.length) { const simById = new Map(sem.map(x=>[x.id,x.sim])); - for (const sc of scored) { - const sim = simById.get(sc.entry.id); - if (typeof sim === 'number') { sc.score += 0.4*sim; sc.hitScore = Math.max(sc.hitScore, sim); } - } - scored.sort((a,b)=>b.score-a.score); + // re-rank top by similarity blend + res.top.sort((a,b)=>{ + const sa = simById.get(a.id)||0, sb = simById.get(b.id)||0; + return sb - sa; + }); } - scored.sort((a,b)=>b.score-a.score); - const top = scored.slice(0, opts.limit||5); - const totalTokens = top.reduce((sum,s)=>sum+s.estTokens,0); - const wouldBeFullRead = entries.reduce((sum,e)=>sum+(LEVELS[estimateLevel(e)]?.tokens||200),0); - const hit = top.length > 0; - // nemotron 지적 반영: miss는 'n/a (miss)', 99.95% 이상은 '99.9%+' 표기 - const savingNum = wouldBeFullRead ? ((wouldBeFullRead-totalTokens)/wouldBeFullRead*100) : 0; - const saving = !hit ? 'n/a (miss)' : (savingNum >= 99.95 ? '99.9%+' : savingNum.toFixed(1)+'%'); + + const svNum = res.fullTokens ? (res.fullTokens-res.topTokens)/res.fullTokens*100 : 0; + const saving = !res.hit ? 'n/a (miss)' : (svNum >= 99.95 ? '99.9%+' : svNum.toFixed(1)+'%'); return { query, - assignedLevel: requestedLevel, - router: { type: 'rule-based heuristic (no LLM)', reason: `${rawTokens.length} words → ${requestedLevel}`, expandedTokens: qTokens.length - rawTokens.length }, + assignedLevel, + expandedTo: res.expandedTo || null, + router: { type:'rule-based heuristic', noLLM:true, zeroTokens:true, + semantic: CONFIG.search?.semantic?.enabled ? (sem ? (sem.unavailable ? `unavailable: ${sem.unavailable}` : 'local-embeddings') : 'enabled-but-unavailable') : 'disabled', + reason: `${rawTokens.length} words → ${assignedLevel}`, synonymExpanded: qTokens.length - rawTokens.length }, order: ORDER, totalEntries: entries.length, - evaluated: scored.length, - hit, - router: { type:'rule-based heuristic', semantic: CONFIG.search?.semantic?.enabled ? 'opt-in' : 'disabled', reason: `query ${qTokens.length} words → ${requestedLevel}` }, - top: top.map(s=>({ id:s.entry.id, title:s.entry.title, level:s.lev, feature:s.entry.feature, priority:s.entry.priority, score: s.score.toFixed(2), estTokens:s.estTokens, path:s.entry.path, summary:s.entry.summary })), - tokens: { top: totalTokens, full: wouldBeFullRead, saving, avgPerQuery: top.length? Math.round(totalTokens/top.length):0 }, - note: `Hierarchical: ${ORDER.slice(0, startRank+1).join('→')} first, expand to larger only if no hit — like cache→HBM→DRAM→SSD→library` + evaluated: res.evaluated, + hit: res.hit, + 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`, }; } +// ── --assign [--save]: 레벨 계산 + 실제 저장 (external review 반영) ────────── +function assignLevel(content, priority=3, affects=[]) { + const len = content.length, aff = Array.isArray(affects)?affects.length:0; + if (len<=30 && priority>=4 && aff===0) return 'post-it'; + if (len<=80 && priority>=3) return 'memo'; + if (len<=400) return 'diary'; + if (len<=2000 || aff>=2) return 'bookshelf'; + return 'library'; +} function saveEntry(o) { - // Issue(external review) fix: --assign previously printed only; --save now writes a real entry const dirMap = { issue:'bugs', bug:'bugs', learning:'learnings', idea:'ideas', note:'notes', decision:'decisions', diary:'diary', todo:'todos', memo:'notes', 'work-history':'code-history', 'overall-flow':'notes' }; - const dir = join(ROOT, dirMap[o.type] || 'notes'); - mkdirSync(dir, { recursive: true }); + const dir = join(ROOT, dirMap[o.type]||'notes'); + mkdirSync(dir,{recursive:true}); const date = new Date().toISOString().slice(0,10); const fname = `${date}-${String(o.title||o.content).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,40)}--${o.agent}.md`; - const path = join(dir, fname); - const level = o.computedLevel; - const refs = o.refs ? o.refs.split(',').map(s=>s.trim()).filter(Boolean) : []; + const path = join(dir,fname); + const refs = o.refs ? String(o.refs).split(',').map(s=>s.trim()).filter(Boolean) : []; const md = [ - ``, - '---', + ``, '---', `id: ${o.type}-${date.replace(/-/g,'')}-${Math.random().toString(16).slice(2,10)}`, - `type: ${o.type}`, - `level: ${level}`, + `type: ${o.type}`, `level: ${o.computedLevel}`, `title: "${String(o.title||o.content).slice(0,80)}"`, - `tags: [${o.type}, ${o.feature}]`, - `feature: ${o.feature}`, - `scope: global`, - `agent: ${o.agent}`, - `created: ${new Date().toISOString()}`, - `updated: ${new Date().toISOString()}`, - `status: done`, - `priority: ${o.priority}`, + `tags: [${o.type}, ${o.feature}]`, `feature: ${o.feature}`, `scope: global`, `agent: ${o.agent}`, + `created: ${new Date().toISOString()}`, `updated: ${new Date().toISOString()}`, + `status: done`, `priority: ${o.priority}`, `summary: "${String(o.content).slice(0,180)}"`, - ...(refs.length ? ['refs:', ...refs.map(r=>` - "${r}"`)] : []), + ...(refs.length?['refs:',...refs.map(r=>` - "${r}"`)]:[]), '---','', `## 결과\n\n${o.content}\n`, `\n`, ].join('\n')+'\n'; - writeFileSync(path, md, 'utf8'); - // regenerate index so the new entry is searchable immediately - const idxSrc = new URL('./agent-context-index.mjs', import.meta.url).pathname; - spawnSync(process.execPath, [idxSrc], { stdio: 'inherit' }); - return { saved: true, path, level, tokens: LEVELS[level]?.tokens }; + writeFileSync(path, md,'utf8'); + spawnSync(process.execPath, [new URL('./agent-context-index.mjs', import.meta.url).pathname], { stdio:'inherit' }); + return { saved:true, path, level:o.computedLevel, tokens: LEVELS[o.computedLevel]?.tokens }; } -if (import.meta.url === `file://${process.argv[1]}`) { - const ARGS = parseArgs(); - if (ARGS.help) { - console.log(`Usage: +// ── CLI ────────────────────────────────────────────────────────────────────── +const a = process.argv.slice(2); +const out = { query:null, level:null, limit:5, json:false, assign:false, save:false, + content:null, priority:3, title:null, type:'note', feature:'global', + agent:'system', refs:null, benchmark:false, help:false }; +for (let i=0;isearch(q, { limit: 3 })); - console.log(JSON.stringify({ benchmark: "lightweight hierarchical vs full read", queries: results, avgSaving: (results.reduce((s,r)=>s+parseFloat(r.tokens.saving),0)/results.length).toFixed(1)+'%'} , null, 2)); +if (__isMain && out.benchmark) { + const queries = ["auth","token authentication","auth jwt race","overall flow","pagination"]; + const results = []; + for (const q of queries) { + const r = await search(q, { limit:3 }); + results.push({ q, level:r.assignedLevel, hit:r.hit, saving:r.tokens.saving, expandedTo:r.expandedTo }); + } + console.log(JSON.stringify({ benchmark:'live index, hierarchical vs full read', avgHitRate:(results.filter(r=>r.hit).length/results.length*100).toFixed(0)+'%', results }, null, 2)); process.exit(0); } -if (!ARGS.query) { - console.error('requires query or --assign or --benchmark'); - process.exit(1); -} -const res = await search(ARGS.query, { level: ARGS.level, limit: ARGS.limit }); -if (ARGS.json) console.log(JSON.stringify(res, null, 2)); +if (__isMain) { const res = await search(out.query, { level: out.level, limit: out.limit }); +if (out.json) console.log(JSON.stringify(res, null, 2)); else { - console.log(`\n🔍 query: "${res.query}" → lightweight AI assigned level: ${res.assignedLevel} (${LEVELS[res.assignedLevel]?.desc||''}) — ${res.lightweightAI.reason}`); - console.log(` order: ${res.order.join(' → ')} | total: ${res.totalEntries} evaluated: ${res.evaluated} | tokens top:${res.tokens.top} vs full:${res.tokens.full} saving:${res.tokens.saving}`); - console.log(` top ${res.top.length}:`); - for (const t of res.top) console.log(` - [${t.level} ${t.feature}] ${t.title} (p${t.priority} score${t.score} ~${t.estTokens}tok) → ${t.path}`); - console.log(` note: ${res.note}\n`); + 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}`); + console.log(''); +} } diff --git a/tools/benchmark-task.mjs b/tools/benchmark-task.mjs new file mode 100644 index 0000000..3bc5d4a --- /dev/null +++ b/tools/benchmark-task.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Path: tools/benchmark-task.mjs +// Task-success benchmark (#4) — 토큰 절약이 아니라 "검색이 실제 과제를 도왔는가"를 잰다. +// +// 방법 (정직한 프록시 — 실제 코딩 성공률은 라이브 에이전트 필요): +// 각 entry를 하나의 미니 과제로 본다. 질의 = 제목 키워드(스톱워드 제외), +// 정답(oracle) = 그 entry의 path. 계층 검색이 limit 내에 oracle을 반환하면 성공. +// 측정: task accuracy(%) · 평균 소비 토큰 · flat-read 대비 절약. +// +// 한계 명시: 이것은 retrieval 정확도의 상한 추정이며, AgentRadio식 +// "실제 버그 수정 성공률"로 가려면 라이브 에이전트 하네스가 필요하다. → ROADMAP P1. +// +// Run: node tools/benchmark-task.mjs [--limit 3] [--json] + +import { readFileSync } from 'node:fs'; + +const cfgPath = new URL('../agent-context.config.json', import.meta.url).pathname; +const CONFIG = exists(cfgPath) ? JSON.parse(readFileSync(cfgPath,'utf8')) : {}; +function exists(p){ try { readFileSync(p); return true; } catch { return false; } } + +const ROOT = new URL('../agent-context', import.meta.url).pathname; +const idx = JSON.parse(readFileSync(join(ROOT,'index.json'),'utf8')); +function join(a,b){ return a+'/'+b; } + +const LEVEL_TOKENS = CONFIG.hierarchy?.levels ? Object.fromEntries(Object.entries(CONFIG.hierarchy.levels).map(([k,v])=>[k,v.tokens])) : { 'post-it':15, memo:50, diary:200, bookshelf:1000, library:5000 }; + +const STOP = new Set(['the','a','an','of','on','in','to','and','or','for','with','is','was','not','fix','bug','test','issue']); +function keywords(title){ + return title.toLowerCase().replace(/[^a-z0-9가-힣\s-]/g,' ').split(/\s+/) + .filter(w=>w.length>1 && !STOP.has(w)).slice(0,4); +} + +async function main(){ + const { search } = await import('./agent-search-lite.mjs'); + const tasks = idx.entries.filter(e => (e.priority||3) >= 2 && keywords(e.title).length >= 1); + const limit = Number(process.argv.includes('--limit') ? process.argv[process.argv.indexOf('--limit')+1] : 3); + let success=0, tokens=0; + const detail=[]; + const flatTokens = idx.entries.reduce((s,e)=>{ + const t = LEVEL_TOKENS[e.level] ?? 200; return s+t; + }, 0); + + for (const t of tasks) { + const q = keywords(t.title).join(' '); + const r = await search(q, { limit }); + const hit = r.top.some(x => x.path === t.path); + // 소비 토큰 = 검색 결과로 연 Read 토큰(hit 시 oracle 포함 top 합) or miss 시 full read 강제 + const used = hit ? r.top.reduce((s,x)=>s+x.estTokens,0) : flatTokens; + if (hit) success++; + tokens += used; + detail.push({ task:t.id, query:q, hit, usedTokens:used, oracle:t.path }); + } + const n = tasks.length || 1; + const summary = { + metric: 'retrieval-task success (proxy for agent task success)', + tasks: n, + accuracy: (success/n*100).toFixed(1)+'%', + avgTokensPerTask: Math.round(tokens/n), + flatReadTokensPerTask: Math.round(flatTokens), + savingVsFlat: flatTokens? ((flatTokens*n-tokens)/(flatTokens*n)*100).toFixed(1)+'%' : 'n/a', + honesty_note: '이 수치는 "검색→정답 파일 도달" 프록시다. 실제 작업 성공률(버그 수정 등)은 라이브 에이전트 하네스 필요 — ROADMAP P1.', + }; + if (process.argv.includes('--json')) console.log(JSON.stringify({summary, detail},null,2)); + else { + console.log(JSON.stringify(summary,null,2)); + // BENCHMARK.md 부착 + try { + const fsMod = await import('node:fs'); + const p = new URL('../BENCHMARK.md', import.meta.url).pathname; + let md = fsMod.readFileSync(p,'utf8'); + if (!md.includes('## Task-success benchmark')) { + md += `\n## Task-success benchmark (#4 프록시)\n\n\`\`\`json\n${JSON.stringify(summary,null,2)}\n\`\`\`\n\n> 실행: \`node tools/benchmark-task.mjs\`. 위 지표는 "질의→정답 파일 도달" 프록시이며, 실제 작업 성공률은 라이브 에이전트 하네스 과제 (ROADMAP P1).\n`; + fsMod.writeFileSync(p, md); + console.log('Appended to BENCHMARK.md'); + } + } catch {} + } +} +main();