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 b650c56..7b5856a 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -16,9 +16,15 @@ | scale | full tokens | avg top 3 tokens | avg saving | hitRate | avg latency (search) | est. full Read latency | tokens/hit | |---|---|---|---|---|---|---| +<<<<<<< HEAD +| 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 | +======= | 5 | 1315 | 178 | 83.1% | 80.0% | 0.16ms | 0.25ms (est. Read all md) | 223 | | 50 | 16780 | 761 | 94.7% | 85.0% | 0.41ms | 2.50ms (est. Read all md) | 895 | | 500 | 197940 | 1883 | 98.9% | 85.0% | 2.34ms | 25.00ms (est. Read all md) | 2216 | +>>>>>>> fix/integration-defects ### Interpretation (critical, not hype) @@ -30,11 +36,19 @@ | query | assignedLevel | top tokens | saving | hit | latency | |---|---|---|---|---| +<<<<<<< HEAD +| 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.36ms | +======= | auth | post-it | 45 | 99.7% | ✅ | 0.39ms | | api | post-it | 80 | 99.5% | ✅ | 0.33ms | | jwt | post-it | 0 | n/a (miss) | ❌ | 0.89ms | | pagination | post-it | 0 | n/a (miss) | ❌ | 1.56ms | | cache | post-it | 0 | n/a (miss) | ❌ | 0.64ms | +>>>>>>> fix/integration-defects ### What we learned while benchmarking (ideas & shortcomings →补) @@ -65,7 +79,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "avgTopTokens": 178, "avgSaving": "83.1%", "hitRate": "80.0%", +<<<<<<< HEAD + "avgLatency": "0.11ms", +======= "avgLatency": "0.16ms", +>>>>>>> fix/integration-defects "fullLatencyEst": "0.25ms (est. Read all md)", "tokensPerHit": 223, "perQuery": [ @@ -75,7 +93,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 50, "saving": "96.2%", "hit": true, +<<<<<<< HEAD + "latency": "0.57ms" +======= "latency": "0.65ms" +>>>>>>> fix/integration-defects }, { "query": "api", @@ -107,7 +129,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, +<<<<<<< HEAD + "latency": "0.05ms" +======= "latency": "0.35ms" +>>>>>>> fix/integration-defects } ] }, @@ -119,7 +145,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "avgTopTokens": 761, "avgSaving": "94.7%", "hitRate": "85.0%", +<<<<<<< HEAD + "avgLatency": "0.40ms", +======= "avgLatency": "0.41ms", +>>>>>>> fix/integration-defects "fullLatencyEst": "2.50ms (est. Read all md)", "tokensPerHit": 895, "perQuery": [ @@ -129,7 +159,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 45, "saving": "99.7%", "hit": true, +<<<<<<< HEAD + "latency": "1.38ms" +======= "latency": "0.39ms" +>>>>>>> fix/integration-defects }, { "query": "api", @@ -145,7 +179,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, +<<<<<<< HEAD + "latency": "0.32ms" +======= "latency": "0.89ms" +>>>>>>> fix/integration-defects }, { "query": "pagination", @@ -161,7 +199,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, +<<<<<<< HEAD + "latency": "0.36ms" +======= "latency": "0.64ms" +>>>>>>> fix/integration-defects } ] }, @@ -173,7 +215,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "avgTopTokens": 1883, "avgSaving": "98.9%", "hitRate": "85.0%", +<<<<<<< HEAD + "avgLatency": "2.60ms", +======= "avgLatency": "2.34ms", +>>>>>>> fix/integration-defects "fullLatencyEst": "25.00ms (est. Read all md)", "tokensPerHit": 2216, "perQuery": [ @@ -183,7 +229,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 45, "saving": "99.9%+", "hit": true, +<<<<<<< HEAD + "latency": "5.40ms" +======= "latency": "3.82ms" +>>>>>>> fix/integration-defects }, { "query": "api", @@ -191,7 +241,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 45, "saving": "99.9%+", "hit": true, +<<<<<<< HEAD + "latency": "2.38ms" +======= "latency": "1.83ms" +>>>>>>> fix/integration-defects }, { "query": "jwt", @@ -199,7 +253,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, +<<<<<<< HEAD + "latency": "2.32ms" +======= "latency": "6.05ms" +>>>>>>> fix/integration-defects }, { "query": "pagination", @@ -207,7 +265,11 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, +<<<<<<< HEAD + "latency": "2.66ms" +======= "latency": "4.64ms" +>>>>>>> fix/integration-defects }, { "query": "cache", @@ -215,9 +277,29 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 0, "saving": "n/a (miss)", "hit": false, +<<<<<<< HEAD + "latency": "2.46ms" +======= "latency": "4.53ms" +>>>>>>> fix/integration-defects } ] } ] ``` + +## 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 7de6bd1..d0aa223 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 @@ -241,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.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/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 5681ef2..4ffd4ab 100644 --- a/agent-context/graph.json +++ b/agent-context/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generated_at": "2026-08-26T11:25:17.405Z", + "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 6fc4024..9a55d5a 100644 --- a/agent-context/index.json +++ b/agent-context/index.json @@ -1,6 +1,6 @@ { "version": 1, - "generated_at": "2026-08-26T11:25:17.401Z", + "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,15 +8,62 @@ "soft_limit_chars": 200000, "max_entries": 1000, "should_compress": false, - "total_chars": 1509, - "total_entries": 2 + "total_chars": 1953, + "total_entries": 3 }, "counts": { + "note": 1, + "learning": 1, "handoff": 1, - "issue": 1, - "total": 2 + "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", + "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": "handoff-20260826-khd31lw6", "type": "handoff", @@ -39,28 +86,15 @@ "related": [], "affects": [], "chars": 1117 - }, - { - "id": "issue-20260826-b48a3b55", - "type": "issue", - "level": "diary", - "title": "login 500 on refresh", - "tags": [ - "issue" - ], - "feature": "auth", - "scope": "global", - "agent": "system", - "created": "2026-08-26T11:23:08.632Z", - "updated": "2026-08-26T11:23:08.632Z", - "status": "done", - "priority": 4, - "summary": "login 500 on refresh", - "preview": "login 500 on refresh", - "path": "bugs/2026-08-26-login-500-on-refresh--system.md", - "related": [], - "affects": [], - "chars": 392 } - ] + ], + "knowledge": { + "note": "related[]에서 유추한 인과 엣지 — bug↔learning, decision supersedes 등. graph.json(feature)과 별개의 지식 그래프.", + "edges": [] + }, + "stale": { + "threshold_days": 90, + "count": 0, + "items": [] + } } 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..2465cca --- /dev/null +++ b/agent-context/notes/2026-08-26-api-moved--claude.md @@ -0,0 +1,23 @@ + +--- +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" +--- + +## 결과 + +API moved to /v2/items + + + 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/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/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/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 04ca99a..ca7436a 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() { @@ -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) { @@ -390,7 +421,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 31ca7f2..a6d5527 100644 --- a/tools/agent-search-lite.mjs +++ b/tools/agent-search-lite.mjs @@ -1,187 +1,264 @@ #!/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가 질의 분석해 가장 작은 레벨부터 탐색, 히트 시 중단 -// 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 --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, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; 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])); +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 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 }; - for (let i=0;iset.add(x)); } + for (const [k, list] of Object.entries(SYNONYMS)) if (tokens.some(t => list.includes(t))) set.add(k); + return [...set]; } -function assignLevel(content, priority=3, affects=[]) { - // Lightweight AI: rule-based, 0 LLM calls, 0 tokens - // Inspired by cache hierarchy: small→large, priority and affects push to larger level - const len = content.length; - const aff = Array.isArray(affects) ? affects.length : 0; - if (len <= 30 && priority >=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 -} - -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'; - if (len <= 3000) return 'bookshelf'; - return 'library'; +// ── 선택적 의미 어댑터 — 기본 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 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) }; } } -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 heuristicLevel(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'; + 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 search(query, opts={}) { - const index = JSON.parse(readFileSync(INDEX_PATH,'utf8')); - const entries = index.entries || []; - const requestedLevel = opts.level || lightweightAIAssignLevelForQuery(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 = query.toLowerCase().split(/\s+/).filter(Boolean); - const scored = entries.map(e=>{ +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 - 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 - const priorityScore = (e.priority||3)/5; // 0.2-1 - // Recency: updated within 30 days → boost + 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 (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; + 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 {} - // Final: weighted + 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 + 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, 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 hitNum = parseFloat(saving); - const savingStr = !hit ? 'n/a (miss)' : (hitNum >= 99.95 ? '99.9%+' : `${saving}%`); + 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 && res.top.length) { + const simById = new Map(sem.map(x=>[x.id,x.sim])); + // 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; + }); + } + + 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', noLLM: true, zeroTokens: true, reason: `query ${qTokens.length} words → ${requestedLevel}` }, - lightweightAI: { reason: `query ${qTokens.length} words → ${requestedLevel} (hierarchical cache)`, noLLM: true, zeroTokens: true }, + 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, - expandedTo: hit ? null : null, - 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: savingStr, avgPerQuery: top.length? Math.round(totalTokens/top.length):0 }, - note: `Hierarchical: ${ORDER.slice(0, startRank+1).join('→')} first, miss expands to larger levels — cache metaphor` + 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`, }; } -const ARGS = parseArgs(); -if (ARGS.help) { +// ── --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) { + 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 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: ${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}`, + `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'); + 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 }; +} + +// ── 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 = 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();