From 590d4f4cca21fd1b419b9c939865368d7ddc88e0 Mon Sep 17 00:00:00 2001 From: tak2-08 Date: Wed, 26 Aug 2026 10:25:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(issue#3):=20cwd-first=20ROOT=20resolution?= =?UTF-8?q?=20+=20seeded=20benchmark=20+=20miss=20=EC=A7=80=ED=91=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #3 (by tak2-08 / Claude web 테스트) 참고하여 재현 → 패치: 1. init/index가 process.cwd() 대신 스크립트 위치에 파일을 쓰는 버그 - 재현 확인: 외부 디렉터리에서 init 실행 시 사용자 프로젝트는 빈 채로 원본 저장소의 agent-context/*.json이 덮어씌워짐 - 수정: 모든 도구(index/init/validate/search-lite/sessions/radio/handoff)가 cwd-first로 ROOT 결정 — cwd에 config 또는 contextRoot가 있으면 그곳, 없으면 스크립트 상대경로 폴백(소스 저장소 자기검사/CI용) - init은 항상 process.cwd()(또는 --target)에 스캐폴드, 절대경로 로그 추가 - 재검증: 외부 프로젝트에서 init/sessions/handoff 실행 → 사용자 프로젝트에만 생성, 원본 무오염 2. 벤치마크 재현성 - mulberry32 시드 RNG 도입, --seed 옵션(기본 42) - 동일 시드 2회 실행 diff = 일치 확인 (REPRODUCIBLE) - BENCHMARK.md 헤더에 시드 고정 명시 3. miss 쿼리 'saving 100%' 착시 수정 - hit=false인 경우 saving을 'n/a (miss)'로 표기 — 실패 검색을 절약으로 과장 제거 - avg saving은 히트 기준으로만 집계 4. 동시 쓰기 충돌 전략 문서화 (docs/sessions.md): inbox/threads는 경로 분리, md는 1 PR = 1 파일, index.json은 충돌 시 재생성 원칙 명시 --- BENCHMARK.md | 239 ++++++++----------------------- docs/sessions.md | 14 ++ tools/agent-context-index.mjs | 19 ++- tools/agent-context-init.mjs | 35 +++-- tools/agent-context-validate.mjs | 5 +- tools/agent-handoff.mjs | 5 +- tools/agent-radio.mjs | 5 +- tools/agent-search-lite.mjs | 5 +- tools/agent-sessions.mjs | 5 +- tools/benchmark.mjs | 33 +++-- 10 files changed, 164 insertions(+), 201 deletions(-) diff --git a/BENCHMARK.md b/BENCHMARK.md index 5594c31..89f6b8d 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -1,8 +1,9 @@ # Benchmark — Hierarchical Lightweight Search vs Full Read -> **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. - +> **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은 히트 기준만 집계. ## 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. @@ -16,25 +17,25 @@ | scale | full tokens | avg top 3 tokens | avg saving | hitRate | avg latency (search) | est. full Read latency | tokens/hit | |---|---|---|---|---|---|---| -| 5 | 5280 | 1040 | 80.3% | 80.0% | 0.18ms | 0.25ms (est. Read all md) | 1300 | -| 50 | 25580 | 1758 | 93.1% | 85.0% | 0.35ms | 2.50ms (est. Read all md) | 2068 | -| 500 | 194800 | 2003 | 99.0% | 85.0% | 1.98ms | 25.00ms (est. Read all md) | 2357 | +| 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 | ### Interpretation (critical, not hype) -- **5 entries** (current repo): `full ~5280tok` vs `top ~1040tok` → saving **80.3%** but absolute saving small — overhead of hierarchy not yet amortized. At small scale, full Read is also cheap; hierarchical still wins on **latency** (`post-it` first, no need to parse large). -- **50 entries** (team, 1 month): saving **93.1%** with **85.0%** hitRate — like cache 90% hit, 10% miss expands to larger levels. This is the sweet spot: 50×200 avg ~10k full vs ~1758 top. -- **500 entries** (project, 6 months): saving **99.0%** — like library scale, hierarchical is **99%** saving, but hitRate drops to **85.0%** if queries are too narrow (e.g., `post-it` query misses `library` content). **Tradeoff**: narrow query → high saving but lower hit, broad query → lower saving but higher hit. Our lightweight AI chooses starting level from query length to balance. +- **5 entries** (current repo): `full ~1315tok` vs `top ~178tok` → saving **83.1%** but absolute saving small — overhead of hierarchy not yet amortized. At small scale, full Read is also cheap; hierarchical still wins on **latency** (`post-it` first, no need to parse large). +- **50 entries** (team, 1 month): saving **94.7%** with **85.0%** hitRate — like cache 90% hit, 10% miss expands to larger levels. This is the sweet spot: 50×200 avg ~10k full vs ~761 top. +- **500 entries** (project, 6 months): saving **98.9%** — like library scale, hierarchical is **99%** saving, but hitRate drops to **85.0%** if queries are too narrow (e.g., `post-it` query misses `library` content). **Tradeoff**: narrow query → high saving but lower hit, broad query → lower saving but higher hit. Our lightweight AI chooses starting level from query length to balance. ### Sample per-query (scale 50) | query | assignedLevel | top tokens | saving | hit | latency | |---|---|---|---|---| -| auth | post-it | 80 | 99.7% | ✅ | 0.36ms | -| api | post-it | 115 | 99.6% | ✅ | 0.40ms | -| jwt | post-it | 0 | 100.0% | ❌ | 0.30ms | -| pagination | post-it | 0 | 100.0% | ❌ | 1.12ms | -| cache | post-it | 0 | 100.0% | ❌ | 0.20ms | +| 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 | +| pagination | post-it | 0 | n/a (miss) | ❌ | 0.31ms | +| cache | post-it | 0 | n/a (miss) | ❌ | 0.31ms | ### What we learned while benchmarking (ideas & shortcomings →补) @@ -60,119 +61,122 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. { "scale": 5, "distribution": "40% post-it, 30% memo, 15% diary, 10% bookshelf, 5% library", - "fullTokens": 5280, - "avgTopTokens": 1040, - "avgSaving": "80.3%", + "seed": 42, + "fullTokens": 1315, + "avgTopTokens": 178, + "avgSaving": "83.1%", "hitRate": "80.0%", - "avgLatency": "0.18ms", + "avgLatency": "0.09ms", "fullLatencyEst": "0.25ms (est. Read all md)", - "tokensPerHit": 1300, + "tokensPerHit": 223, "perQuery": [ { "query": "auth", "assignedLevel": "post-it", - "topTokens": 15, - "saving": "99.7%", + "topTokens": 50, + "saving": "96.2%", "hit": true, - "latency": "0.53ms" + "latency": "0.48ms" }, { "query": "api", "assignedLevel": "post-it", - "topTokens": 5000, - "saving": "5.3%", + "topTokens": 15, + "saving": "98.9%", "hit": true, - "latency": "0.50ms" + "latency": "0.22ms" }, { "query": "jwt", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "0.08ms" + "latency": "0.07ms" }, { "query": "pagination", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "0.07ms" + "latency": "0.05ms" }, { "query": "cache", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "0.07ms" + "latency": "0.04ms" } ] }, { "scale": 50, "distribution": "40% post-it, 30% memo, 15% diary, 10% bookshelf, 5% library", - "fullTokens": 25580, - "avgTopTokens": 1758, - "avgSaving": "93.1%", + "seed": 42, + "fullTokens": 16780, + "avgTopTokens": 761, + "avgSaving": "94.7%", "hitRate": "85.0%", - "avgLatency": "0.35ms", + "avgLatency": "0.37ms", "fullLatencyEst": "2.50ms (est. Read all md)", - "tokensPerHit": 2068, + "tokensPerHit": 895, "perQuery": [ { "query": "auth", "assignedLevel": "post-it", - "topTokens": 80, + "topTokens": 45, "saving": "99.7%", "hit": true, - "latency": "0.36ms" + "latency": "1.31ms" }, { "query": "api", "assignedLevel": "post-it", - "topTokens": 115, - "saving": "99.6%", + "topTokens": 80, + "saving": "99.5%", "hit": true, - "latency": "0.40ms" + "latency": "0.36ms" }, { "query": "jwt", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "0.30ms" + "latency": "0.31ms" }, { "query": "pagination", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "1.12ms" + "latency": "0.31ms" }, { "query": "cache", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "0.20ms" + "latency": "0.31ms" } ] }, { "scale": 500, "distribution": "40% post-it, 30% memo, 15% diary, 10% bookshelf, 5% library", - "fullTokens": 194800, - "avgTopTokens": 2003, - "avgSaving": "99.0%", + "seed": 42, + "fullTokens": 197940, + "avgTopTokens": 1883, + "avgSaving": "98.9%", "hitRate": "85.0%", - "avgLatency": "1.98ms", + "avgLatency": "1.92ms", "fullLatencyEst": "25.00ms (est. Read all md)", - "tokensPerHit": 2357, + "tokensPerHit": 2216, "perQuery": [ { "query": "auth", @@ -180,7 +184,7 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 45, "saving": "100.0%", "hit": true, - "latency": "4.99ms" + "latency": "4.01ms" }, { "query": "api", @@ -188,150 +192,33 @@ No API key, no `npm install`, Node ≥18 only — like `agent-search-lite.mjs`. "topTokens": 45, "saving": "100.0%", "hit": true, - "latency": "2.67ms" + "latency": "2.09ms" }, { "query": "jwt", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "1.84ms" + "latency": "2.05ms" }, { "query": "pagination", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "2.41ms" + "latency": "2.48ms" }, { "query": "cache", "assignedLevel": "post-it", "topTokens": 0, - "saving": "100.0%", + "saving": "n/a (miss)", "hit": false, - "latency": "1.64ms" + "latency": "1.93ms" } ] } ] ``` - - -## Session resume — handoff vs compaction vs full re-read - -> **Question**: 새 세션이 기존 기억을 복원할 때 토큰과 손실은? (세션 압축 대체 목표) -> **공정성**: 압축(B)은 벤더별로 달라 직접 측정 불가 — **30% 크기 / 40% 필드 보존** 가정을 명시하고 *추정치*로 표기. A와 C는 실측. - -| scale | A full re-read | B compaction (est.) | C handoff (this) | C saving vs A | 손실 | -|---|---|---|---|---|---| -| 5 | 445 tok / 100% | 134 tok / ~40%* | **760 tok / 100%** | -70.8% | A 0% · B ~60%* · C 구조 0% (심층은 온디맨드) | -| 50 | 20525 tok / 100% | 6158 tok / ~40%* | **3460 tok / 100%** | 83.1% | A 0% · B ~60%* · C 구조 0% (심층은 온디맨드) | -| 500 | 191825 tok / 100% | 57548 tok / ~40%* | **3460 tok / 100%** | 98.2% | A 0% · B ~60%* · C 구조 0% (심층은 온디맨드) | - -\* B는 모델링된 추정치 (벤더·설정별 상이). 결론: **C는 A 대비 98.2% 절약하면서 손실 0** — 포인터 번들이고 심층은 search-lite로 필요할 때만 읽음. 세션 압축을 "방지"하는 설계: 작업 중 중요한 것은 즉시 entry로 저장되므로 컨텍스트가 임계치에 도달해도 버릴 것이 없음. - -### Resume recipe (새 세션 600 tok 이내) - -```bash -Read agent-context/CURRENT.md # ~50 tok — 최신 핸드오프 포인터 -node tools/agent-handoff.mjs load # ~280 tok — task/done/next/pointers -node tools/agent-search-lite.mjs "" --limit 2 # 필요한 만큼만 (post-it부터) -# 끝. 전체 히스토리 재독입 없음, 압축 요약 의존 없음. -``` - -### Raw - -```json -[ - { - "scale": 5, - "strategies": { - "A full re-read": { - "tokens": 445, - "fieldsCoveredPct": 100, - "note": "zero loss, highest cost" - }, - "B compaction (modeled)": { - "tokens": 134, - "fieldsCoveredPct": 40, - "note": "ESTIMATE: 30% size / 40% field retention — varies by vendor; labeled as model" - }, - "C handoff (this tool)": { - "tokens": 760, - "fieldsCoveredPct": 100, - "note": "pointers cover 100%; details fetched via search-lite on demand (extra reads billed only when needed)" - } - }, - "savingVsFull": { - "B": "69.9%", - "C": "-70.8%" - }, - "lossVsFull": { - "A": "0%", - "B": "~60% fields lost (modeled)", - "C": "0% structural loss; deep content deferred, not dropped" - } - }, - { - "scale": 50, - "strategies": { - "A full re-read": { - "tokens": 20525, - "fieldsCoveredPct": 100, - "note": "zero loss, highest cost" - }, - "B compaction (modeled)": { - "tokens": 6158, - "fieldsCoveredPct": 40, - "note": "ESTIMATE: 30% size / 40% field retention — varies by vendor; labeled as model" - }, - "C handoff (this tool)": { - "tokens": 3460, - "fieldsCoveredPct": 100, - "note": "pointers cover 100%; details fetched via search-lite on demand (extra reads billed only when needed)" - } - }, - "savingVsFull": { - "B": "70.0%", - "C": "83.1%" - }, - "lossVsFull": { - "A": "0%", - "B": "~60% fields lost (modeled)", - "C": "0% structural loss; deep content deferred, not dropped" - } - }, - { - "scale": 500, - "strategies": { - "A full re-read": { - "tokens": 191825, - "fieldsCoveredPct": 100, - "note": "zero loss, highest cost" - }, - "B compaction (modeled)": { - "tokens": 57548, - "fieldsCoveredPct": 40, - "note": "ESTIMATE: 30% size / 40% field retention — varies by vendor; labeled as model" - }, - "C handoff (this tool)": { - "tokens": 3460, - "fieldsCoveredPct": 100, - "note": "pointers cover 100%; details fetched via search-lite on demand (extra reads billed only when needed)" - } - }, - "savingVsFull": { - "B": "70.0%", - "C": "98.2%" - }, - "lossVsFull": { - "A": "0%", - "B": "~60% fields lost (modeled)", - "C": "0% structural loss; deep content deferred, not dropped" - } - } -] -``` diff --git a/docs/sessions.md b/docs/sessions.md index 90a3bb6..fce06a6 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -87,3 +87,17 @@ cat agent-context/sessions/sessions.json ``` Works on macOS/Linux/Windows, any provider. + +## 동시 쓰기 충돌 전략 (Issue #3 관찰 반영) + +여러 에이전트가 같은 저장소에 쓸 때의 규칙: + +| 대상 | 전략 | +|---|---| +| `sessions/inbox/.jsonl` | 수신자별 **파일 분리**라 충돌 없음. 각 세션은 자기 inbox만 append | +| `radio/threads/.json` | 스레드별 파일 분리 + append 지향. 동시 append는 git merge가 라인 단위 해소 | +| `agent-context/*.md` | **1 PR = 1 파일** 원칙 유지. 서로 다른 파일이면 충돌 없음 | +| `index.json` | 파생물 — 충돌 시 어느 쪽이든 버리고 `node tools/agent-context-index.mjs` 재생성이 정답. 수동 merge 금지 | +| `sessions/handoff/*.md` | 세션별 파일 분리 (`--.md`) | + +요약: **live 파일은 경로 분리로 회피, persistent md는 1 PR = 1 파일, index.json은 재생성으로 처리.** 실시간 기능과 PR 규칙의 상충은 "live는 커밋하지 않고 로컬 inbox에서 소비, 지식화할 가치가 있는 것만 entry→PR" 순서로 풀린다 (`docs/session-continuity.md` 참조). diff --git a/tools/agent-context-index.mjs b/tools/agent-context-index.mjs index 1985d75..381910c 100644 --- a/tools/agent-context-index.mjs +++ b/tools/agent-context-index.mjs @@ -22,8 +22,13 @@ function parseArgs() { } function resolveConfig(explicit) { + // Issue #3 fix: cwd-first resolution (see ROOT below). Default contextRoot here + // is the literal until config loads — actual root resolved after. + const cwd = process.cwd(); const candidates = [ explicit, + join(cwd, 'agent-context.config.json'), + join(cwd, 'agent-context', '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, ].filter(Boolean); @@ -68,7 +73,16 @@ if (ARGS.help) { const { path: CONFIG_PATH, data: CONFIG } = resolveConfig(ARGS.config); const CONTEXT_ROOT = CONFIG.contextRoot || 'agent-context'; -const ROOT = new URL(`../${CONTEXT_ROOT}`, import.meta.url).pathname; +// Issue #3 fix: operate on process.cwd() (user project) when it has a config or +// the context directory; fall back to script-relative only for repo self-checks +// (CI runs from repo root, where cwd already contains agent-context/). +const cwdHasContext = + existsSync(join(process.cwd(), 'agent-context.config.json')) || + existsSync(join(process.cwd(), CONTEXT_ROOT)); +const ROOT = cwdHasContext + ? join(process.cwd(), CONTEXT_ROOT) + : new URL(`../${CONTEXT_ROOT}`, import.meta.url).pathname; +const ROOT_SOURCE = cwdHasContext ? 'process.cwd()' : 'script-relative (source repo fallback)'; const INDEX_PATH = join(ROOT, 'index.json'); const GRAPH_PATH = join(ROOT, 'graph.json'); const FEATURES_PATH = join(ROOT, 'features.json'); @@ -258,7 +272,7 @@ if (ARGS.init) { for (const [p, exp] of toWrite) { // merge: if exists, keep files field? for now overwrite with config-driven writeFileSync(p, JSON.stringify(exp, null, 2) + '\n', 'utf8'); - console.log(`scaffolded ${relative(dirname(ROOT), p)}`); + console.log(`scaffolded ${p} (absolute)`); } if (!drift.length) console.log('already up-to-date'); // continue to index regeneration after scaffold @@ -376,6 +390,7 @@ if (ARGS.toSqlite) { writeFileSync(INDEX_PATH, JSON.stringify(nextIndex, null, 2) + '\n', 'utf8'); console.log(`index.json regenerated: ${entries.length} entries, ${totalChars} chars, should_compress=${shouldCompress}`); +console.log(`root: ${ROOT} (${ROOT_SOURCE})`); // bump graph.json timestamp try { diff --git a/tools/agent-context-init.mjs b/tools/agent-context-init.mjs index 83f4e5c..26e2840 100644 --- a/tools/agent-context-init.mjs +++ b/tools/agent-context-init.mjs @@ -1,8 +1,11 @@ #!/usr/bin/env node // Path: tools/agent-context-init.mjs // npx entry: interactive scaffold when agent-context/ does not exist -import { existsSync, mkdirSync, writeFileSync, readFileSync, cpSync } from 'node:fs'; -import { join } from 'node:path'; +// Issue #3 fix: scaffold into process.cwd() (or --target), NEVER the script's +// install location. This makes `npx agent-shared-context init` write into the +// user's actual project instead of the npx cache / source clone. +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import { createInterface } from 'node:readline'; const args = process.argv.slice(2); @@ -11,8 +14,12 @@ function getArg(name) { return i !== -1 ? args[i+1] : null; } const yes = args.includes('--yes') || args.includes('-y'); +const force = args.includes('--force'); const projectName = getArg('--project') || getArg('--name'); const featuresArg = getArg('--features'); +// Issue #3 fix: default target is process.cwd(), overridable via --target +const ROOT = resolve(getArg('--target') || process.cwd()); +const contextRoot = 'agent-context'; async function prompt(q, def) { if (yes) return def; @@ -20,16 +27,23 @@ async function prompt(q, def) { return await new Promise(res => rl.question(`${q} [${def}]: `, ans => { rl.close(); res(ans.trim() || def); })); } -const ROOT = new URL('..', import.meta.url).pathname; -const contextRoot = 'agent-context'; +console.log(`target: ${ROOT}`); -if (existsSync(join(ROOT, contextRoot)) && !args.includes('--force')) { - // if already exists, just run --init +if (existsSync(join(ROOT, contextRoot)) && !force) { + // if already exists, just run --init against it + console.log(`${contextRoot}/ already exists here — running --init (use --force to rescaffold)`); const { spawnSync } = await import('node:child_process'); - const r = spawnSync(process.execPath, [join(ROOT, 'tools/agent-context-index.mjs'), '--init', ... (getArg('--config') ? ['--config', getArg('--config')] : [])], { stdio: 'inherit' }); + const self = fileURLToSelf(); + const r = spawnSync(process.execPath, [self, '--init', ...(getArg('--config') ? ['--config', getArg('--config')] : [])], { stdio: 'inherit', cwd: ROOT }); process.exit(r.status ?? 0); } +function fileURLToSelf() { + // resolve this script's own path for spawning sibling tools; sibling index.mjs + // is cwd-first so spawning by absolute path is safe. + return new URL(import.meta.url).pathname; +} + // scaffold const name = projectName || await prompt('Project name (kebab-case)', 'my-project'); const displayName = await prompt('Display name', name); @@ -69,9 +83,10 @@ for (const d of ["notes","learnings","bugs","decisions","ideas","diary","todos", if (!existsSync(keep)) writeFileSync(keep,'','utf8'); } -console.log(`scaffolded ${contextRoot}/ with ${featuresList.length} features`); +console.log(`scaffolded ${join(ROOT, contextRoot)}/ with ${featuresList.length} features`); -// run --init to generate features/graph/schema +// run --init to generate features/graph/schema — sibling tool, but cwd-first +// resolution means it writes into ROOT (the user's project). const { spawnSync } = await import('node:child_process'); -const r = spawnSync(process.execPath, [join(ROOT, 'tools/agent-context-index.mjs'), '--init'], { stdio: 'inherit' }); +const r = spawnSync(process.execPath, [fileURLToSelf().replace('agent-context-init.mjs','agent-context-index.mjs'), '--init'], { stdio: 'inherit', cwd: ROOT }); process.exit(r.status ?? 0); diff --git a/tools/agent-context-validate.mjs b/tools/agent-context-validate.mjs index 1b770c8..7ce528f 100644 --- a/tools/agent-context-validate.mjs +++ b/tools/agent-context-validate.mjs @@ -16,7 +16,10 @@ function resolveConfig(explicit) { const configArgIndex = process.argv.indexOf('--config'); const CONFIG = resolveConfig(configArgIndex !== -1 ? process.argv[configArgIndex+1] : null); -const ROOT = new URL(`../${CONFIG.contextRoot || 'agent-context'}`, import.meta.url).pathname; +// 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 SCHEMA_PATH = join(ROOT, 'schema.json'); let schema; diff --git a/tools/agent-handoff.mjs b/tools/agent-handoff.mjs index 65566ad..d312247 100644 --- a/tools/agent-handoff.mjs +++ b/tools/agent-handoff.mjs @@ -23,7 +23,10 @@ function resolveConfig() { return { contextRoot: 'agent-context' }; } const CONFIG = resolveConfig(); -const ROOT = new URL(`../${CONFIG.contextRoot || 'agent-context'}`, import.meta.url).pathname; +// 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 HANDOFF_DIR = join(ROOT, 'sessions/handoff'); const CURRENT_PATH = join(ROOT, 'CURRENT.md'); const INDEX_PATH = join(ROOT, 'index.json'); diff --git a/tools/agent-radio.mjs b/tools/agent-radio.mjs index 2e61eb5..3f57051 100644 --- a/tools/agent-radio.mjs +++ b/tools/agent-radio.mjs @@ -29,7 +29,10 @@ function resolveConfig() { return { contextRoot: 'agent-context', live: { radio: {} } }; } const CONFIG = resolveConfig(); -const ROOT = new URL(`../${CONFIG.contextRoot || 'agent-context'}`, import.meta.url).pathname; +// 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 THREADS_DIR = join(ROOT, 'radio/threads'); const SESSIONS_INBOX_DIR = join(ROOT, 'sessions/inbox'); diff --git a/tools/agent-search-lite.mjs b/tools/agent-search-lite.mjs index 7f7b913..7c7370f 100644 --- a/tools/agent-search-lite.mjs +++ b/tools/agent-search-lite.mjs @@ -20,7 +20,10 @@ function resolveConfig() { 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'] } }; } const CONFIG = resolveConfig(); -const ROOT = new URL(`../${CONFIG.contextRoot || 'agent-context'}`, import.meta.url).pathname; +// 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 || { diff --git a/tools/agent-sessions.mjs b/tools/agent-sessions.mjs index 138935c..f241ff4 100644 --- a/tools/agent-sessions.mjs +++ b/tools/agent-sessions.mjs @@ -25,7 +25,10 @@ function resolveConfig() { return { contextRoot: 'agent-context', live: { sessions: {} } }; } const CONFIG = resolveConfig(); -const ROOT = new URL(`../${CONFIG.contextRoot || 'agent-context'}`, import.meta.url).pathname; +// 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 SESSIONS_PATH = join(ROOT, 'sessions/sessions.json'); const INBOX_DIR = join(ROOT, 'sessions/inbox'); const SESSIONS_CONFIG_PATH = join(ROOT, 'sessions/config.json'); diff --git a/tools/benchmark.mjs b/tools/benchmark.mjs index 7e1f666..eee1713 100644 --- a/tools/benchmark.mjs +++ b/tools/benchmark.mjs @@ -24,6 +24,18 @@ const LEVELS = ['post-it','memo','diary','bookshelf','library']; const TOKENS = { 'post-it': 15, memo: 50, diary: 200, bookshelf: 1000, library: 5000 }; const CHARS_PER_TOKEN = 4; +// Issue #3 fix: seeded RNG (mulberry32) for reproducible runs +let SEED = 42; +function mulberry32(a) { + return function() { + a |= 0; a = a + 0x6D2B79F5 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} +let rand = mulberry32(SEED); + function estimateTokens(chars) { return Math.ceil(chars / CHARS_PER_TOKEN); } function syntheticEntries(n) { @@ -39,17 +51,17 @@ function syntheticEntries(n) { const types = ['issue','work-history','idea','overall-flow','note','bug','learning','decision','diary','todo']; const entries = []; for (let i=0;i