From 7f21ed65568868705083bfe902aa6f86272439d0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 10 Sep 2026 11:02:08 +0900 Subject: [PATCH 1/3] feat(recall): source-aware session briefing, targeted recall hints, freshness labels, hook output contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four native-integration items from the 2026-09-10 web survey (claude-mem hook lifecycle, Claude Code SessionStart source field, arXiv 2608.15008 on over-retrieval): - SessionStart shapes its notice by source (startup/clear, resume, compact) and ends every shape with one capped recovery line: memories.search when config.toml has [memories] dedicated_tools = true (read through codexHome(), fail-open false), otherwise the cxc chat/memory search commands. Budgets and the repo-key project block from #127 are unchanged. - UserPromptSubmit appends "Suggested recall terms: ..." when recall intent fires, extracted from the prompt (versions, filenames, error codes, CamelCase, quoted spans; max 4). No search runs in the hook; extraction on an 8,000-char prompt stays under 20 ms. Five trigger idioms added; bare "그때" stays out. - memory search text output labels each hit [age: Nd] and marks a hit whose distinctive tokens also appear in a newer hit in another file with [newer: ]. Ranking is untouched. - Hook output contract: assertLegalHookResult({stdout, stderr, code}) is pinned by a child-process test over dist/cli.js hook with an empty temp CODEX_HOME (exit 0, no ANSI on stderr, stdout empty or a JSON object). cxc doctor reports trusted_hash drift as WARN with file_sha256 evidence; a missing trust entry stays FAIL. --- README.ko.md | 2 +- README.md | 2 +- README.zh.md | 2 +- .../060_wp6_native-integration.md | 18 ++ .../components/cxc-ops/dist/doctor.js | 22 +- .../components/cxc-ops/dist/hook-trust.js | 11 +- .../components/cxc-ops/src/doctor.ts | 22 +- .../components/cxc-ops/src/hook-trust.ts | 11 +- .../cxc-ops/test/hook-trust.test.ts | 29 ++- .../components/recall/dist/format.js | 66 ++++- .../codexclaw/components/recall/dist/hook.js | 211 ++++++++++++++-- .../codexclaw/components/recall/src/format.ts | 68 ++++- .../codexclaw/components/recall/src/hook.ts | 211 ++++++++++++++-- .../recall/test/format-freshness.test.ts | 139 +++++++++++ .../components/recall/test/hook.test.ts | 235 +++++++++++++++++- 15 files changed, 971 insertions(+), 78 deletions(-) create mode 100644 plugins/codexclaw/components/recall/test/format-freshness.test.ts diff --git a/README.ko.md b/README.ko.md index 7a2ff99a..57b3157a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@

CI - 2,993 tests passing + 3,007 tests passing 29 skills 28 hooks Documentation diff --git a/README.md b/README.md index 4bc4a8c6..cdc8b6df 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

CI - 2,993 tests passing + 3,007 tests passing 29 skills 28 hooks Documentation diff --git a/README.zh.md b/README.zh.md index b4b6dd5c..2a524f8e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,7 +13,7 @@

CI - 2,993 tests passing + 3,007 tests passing 29 skills 28 hooks Documentation diff --git a/devlog/_plan/260910_memory-followup-roadmap/060_wp6_native-integration.md b/devlog/_plan/260910_memory-followup-roadmap/060_wp6_native-integration.md index 7eb279f2..2d424ea0 100644 --- a/devlog/_plan/260910_memory-followup-roadmap/060_wp6_native-integration.md +++ b/devlog/_plan/260910_memory-followup-roadmap/060_wp6_native-integration.md @@ -770,3 +770,21 @@ B4(000과의 정합): 000 §work-phase 맵의 wp6 설명을 이 문서 기준(sr | rollout_summaries 256 상한 영향 | 파일 수·날짜 분포와 `max_raw_memories_for_consolidation` 대조 | 동일 | 행 번호 정정: `hook.ts:110-145`, `hook.ts:499-531`, `format.ts:52-67`. + + +## P 재검증 (wp6 사이클, 2026-09-10) + +기준 트리 = origin/dev(#128 머지 직후). 계획 이후 `recall/src/hook.ts`는 wp4(#127)가 `listCwdSessions` 호출부와 "Recent work — project" 문구를 바꿨고(cwd-context.ts repo_key 연합), `format.ts`·`cxc-ops/src/doctor.ts`·`hook-trust.ts`는 계획 시점과 동일하다. §4.1의 SessionStart 분기는 #127의 cwd-context 호출을 그대로 감싸고, `dedicatedToolsEnabled`는 A 감사 반영대로 `paths.ts codexHome()`을 쓴다. §4.4의 훅 결과 헬퍼는 `assertLegalHookResult({stdout, stderr, code})`(자식 프로세스 실행). §10 관측 항목은 C에서 실측해 receipt에 붙인다. B는 행이 아니라 심볼로 patch한다. 브랜치 `codex/memory-l1-wp6-native`. + + + +## A 감사 반영 (wp6 round 1, 2026-09-10) + +리뷰어(grok-4.6) GO-WITH-FIXES(blocker 3, Medium 1, Low 1). 구현 제약으로 접는다. + +1. resume 안내문은 기존 테스트가 요구하는 "recall is available" 문구를 유지한다(resume 전용 문구는 그 뒤에 덧붙이는 형태). `hook.test.ts:98-102`의 startup/resume/clear 루프는 그대로 통과해야 한다. +2. `handleSessionStart`의 `dedicatedTools` 기본값은 이 머신 config(`[memories] dedicated_tools = true`)를 읽으므로, 기존 SessionStart 테스트(`hook.test.ts:74, :94` `/cxc chat search/`)는 `{ dedicatedTools: false }`를 명시하거나 테스트 홈의 config.toml 픽스처로 분기를 고정한다. §5의 "false/생략 → cxc chat search" 기대에서 "생략"을 뺀다. 분기 활성화 테스트는 두 값을 모두 명시. +3. §4.2 활성화 행의 프롬프트를 `그때 그 작업 hook.ts MEMORY-WRITE-GATE`(현재 `detectRecallIntent` 참)로 바꾼다. `그때` 단독은 트리거가 아니다. +4. Medium: §4.4 자식 프로세스 테스트는 `hook-e2e.test.mjs:10-16,124`의 `emptyCodexHome()` 패턴대로 `CODEX_HOME`·`CODEX_SQLITE_HOME`을 빈 임시 홈으로 고정한다(러너의 `CODEXCLAW_HOME`만으로는 라이브 `~/.codex`를 읽는다). +5. Low: doctor WARN 강등이 바꾸는 기존 단언은 `hook-trust.test.ts:256`, `:543`(drifted FAIL→WARN, :240 제목)이고 `:527`(untrusted FAIL)은 유지. + diff --git a/plugins/codexclaw/components/cxc-ops/dist/doctor.js b/plugins/codexclaw/components/cxc-ops/dist/doctor.js index 4912de33..10f523e8 100644 --- a/plugins/codexclaw/components/cxc-ops/dist/doctor.js +++ b/plugins/codexclaw/components/cxc-ops/dist/doctor.js @@ -461,27 +461,41 @@ export function runHookTrustCheck(pluginRoot , options = { // Distinguish "never trusted" from "drifted" so the repair line is the one // the operator actually needs (issue #33). const neverTrusted = failed.filter((result) => result.actual === null); + // Drift and absence are different facts. A recorded hash that no longer + // matches means the manifest moved on while the hooks kept running — the + // host decides trust from its own record, so codexclaw reporting this as a + // failure would paint every post-update machine red for a condition that + // blocks nothing (PLAN-BYPASS-NAMED-01). A MISSING entry is different: the + // hooks were never approved, and that stays a failure. + const driftedOnly = failed.length > 0 && neverTrusted.length === 0; const repair = failed.length === 0 ? undefined : neverTrusted.length === failed.length ? `${failed.length} hook(s) have no trust entry in ${join(codexHome, "config.toml")}; only Codex itself writes those on hook approval. Approve this plugin's hooks in Codex, or record them explicitly with: cxc hooks retrust --key ${pluginKey} --codex-home ${codexHome} --bootstrap-ok` : `cxc hooks retrust --key ${pluginKey} --codex-home ${codexHome}`; + const failureDetail = failed + .map( + (result) => + `${result.status} ${result.key} expected=${result.hash} actual=${result.actual ?? "(none)"} file_sha256=${result.fileSha256.slice(0, 16)}`, + ) + .join("; "); return { name: "hook-trust", // An EMPTY result set is not a pass. `diagnoseHookTrust` skips a handler it // cannot hash (invalid matcher, empty command, async), so "0 failed" can also // mean "0 examined" — a green check over hooks nobody verified. - severity: results.length === 0 ? "WARN" : failed.length === 0 ? "PASS" : "FAIL", + severity: + results.length === 0 ? "WARN" : failed.length === 0 ? "PASS" : driftedOnly ? "WARN" : "FAIL", repair, evidence: results.length === 0 ? `no hook handler could be hashed for ${pluginKey}; nothing was verified` : failed.length === 0 ? `${results.length} hook hash(es) trusted for ${pluginKey}` - : failed - .map((result) => `${result.status} ${result.key} expected=${result.hash} actual=${result.actual ?? "(none)"}`) - .join("; "), + : driftedOnly + ? `trusted_hash drift (reinstall updates it; hooks still run): ${failureDetail}` + : failureDetail, }; } catch (error) { return { name: "hook-trust", severity: "FAIL", evidence: error instanceof Error ? error.message : String(error) }; diff --git a/plugins/codexclaw/components/cxc-ops/dist/hook-trust.js b/plugins/codexclaw/components/cxc-ops/dist/hook-trust.js index d59a18b9..1ce1da73 100644 --- a/plugins/codexclaw/components/cxc-ops/dist/hook-trust.js +++ b/plugins/codexclaw/components/cxc-ops/dist/hook-trust.js @@ -80,6 +80,12 @@ const MATCHER_DROPPED_EVENTS = new Set (["UserPromptSubmit", "Stop + + + + + + @@ -166,7 +172,9 @@ export function listHookEntries(pluginRoot , pluginKey ) if (typeof hookRef !== "string") throw new Error("plugin manifest hook references must be strings"); const relativePath = normalizeHookPath(hookRef); assertSafeHeaderValue(relativePath, "hook path"); - const document = JSON.parse(readFileSync(containedPluginFile(pluginRoot, relativePath), "utf8")) + const raw = readFileSync(containedPluginFile(pluginRoot, relativePath)); + const fileSha256 = createHash("sha256").update(raw).digest("hex"); + const document = JSON.parse(raw.toString("utf8")) ; for (const [rawEventName, rawGroups] of Object.entries(document.hooks ?? {})) { @@ -197,6 +205,7 @@ export function listHookEntries(pluginRoot , pluginKey ) entries.push({ key: `${pluginKey}:${relativePath}:${EVENT_LABELS[eventName]}:${groupIdx}:${handlerIdx}`, hash: identityHash(eventName, group.matcher , handler), + fileSha256, }); } } diff --git a/plugins/codexclaw/components/cxc-ops/src/doctor.ts b/plugins/codexclaw/components/cxc-ops/src/doctor.ts index 098d0141..20fc9841 100644 --- a/plugins/codexclaw/components/cxc-ops/src/doctor.ts +++ b/plugins/codexclaw/components/cxc-ops/src/doctor.ts @@ -461,27 +461,41 @@ export function runHookTrustCheck(pluginRoot: string, options: DoctorOptions = { // Distinguish "never trusted" from "drifted" so the repair line is the one // the operator actually needs (issue #33). const neverTrusted = failed.filter((result) => result.actual === null); + // Drift and absence are different facts. A recorded hash that no longer + // matches means the manifest moved on while the hooks kept running — the + // host decides trust from its own record, so codexclaw reporting this as a + // failure would paint every post-update machine red for a condition that + // blocks nothing (PLAN-BYPASS-NAMED-01). A MISSING entry is different: the + // hooks were never approved, and that stays a failure. + const driftedOnly = failed.length > 0 && neverTrusted.length === 0; const repair = failed.length === 0 ? undefined : neverTrusted.length === failed.length ? `${failed.length} hook(s) have no trust entry in ${join(codexHome, "config.toml")}; only Codex itself writes those on hook approval. Approve this plugin's hooks in Codex, or record them explicitly with: cxc hooks retrust --key ${pluginKey} --codex-home ${codexHome} --bootstrap-ok` : `cxc hooks retrust --key ${pluginKey} --codex-home ${codexHome}`; + const failureDetail = failed + .map( + (result) => + `${result.status} ${result.key} expected=${result.hash} actual=${result.actual ?? "(none)"} file_sha256=${result.fileSha256.slice(0, 16)}`, + ) + .join("; "); return { name: "hook-trust", // An EMPTY result set is not a pass. `diagnoseHookTrust` skips a handler it // cannot hash (invalid matcher, empty command, async), so "0 failed" can also // mean "0 examined" — a green check over hooks nobody verified. - severity: results.length === 0 ? "WARN" : failed.length === 0 ? "PASS" : "FAIL", + severity: + results.length === 0 ? "WARN" : failed.length === 0 ? "PASS" : driftedOnly ? "WARN" : "FAIL", repair, evidence: results.length === 0 ? `no hook handler could be hashed for ${pluginKey}; nothing was verified` : failed.length === 0 ? `${results.length} hook hash(es) trusted for ${pluginKey}` - : failed - .map((result) => `${result.status} ${result.key} expected=${result.hash} actual=${result.actual ?? "(none)"}`) - .join("; "), + : driftedOnly + ? `trusted_hash drift (reinstall updates it; hooks still run): ${failureDetail}` + : failureDetail, }; } catch (error) { return { name: "hook-trust", severity: "FAIL", evidence: error instanceof Error ? error.message : String(error) }; diff --git a/plugins/codexclaw/components/cxc-ops/src/hook-trust.ts b/plugins/codexclaw/components/cxc-ops/src/hook-trust.ts index b45b9510..88e522c4 100644 --- a/plugins/codexclaw/components/cxc-ops/src/hook-trust.ts +++ b/plugins/codexclaw/components/cxc-ops/src/hook-trust.ts @@ -48,6 +48,12 @@ export interface HookHandler { export interface HookEntry { key: string; hash: string; + /** + * sha256 of the hook JSON file's bytes. EVIDENCE ONLY — trust is decided by + * `hash` (the canonical event+matcher+handler identity), and this digest is + * expected to differ from a recorded trusted_hash even on a healthy install. + */ + fileSha256: string; } export interface HookTrustResult extends HookEntry { @@ -166,7 +172,9 @@ export function listHookEntries(pluginRoot: string, pluginKey: string): HookEntr if (typeof hookRef !== "string") throw new Error("plugin manifest hook references must be strings"); const relativePath = normalizeHookPath(hookRef); assertSafeHeaderValue(relativePath, "hook path"); - const document = JSON.parse(readFileSync(containedPluginFile(pluginRoot, relativePath), "utf8")) as { + const raw = readFileSync(containedPluginFile(pluginRoot, relativePath)); + const fileSha256 = createHash("sha256").update(raw).digest("hex"); + const document = JSON.parse(raw.toString("utf8")) as { hooks?: Record; }; for (const [rawEventName, rawGroups] of Object.entries(document.hooks ?? {})) { @@ -197,6 +205,7 @@ export function listHookEntries(pluginRoot: string, pluginKey: string): HookEntr entries.push({ key: `${pluginKey}:${relativePath}:${EVENT_LABELS[eventName]}:${groupIdx}:${handlerIdx}`, hash: identityHash(eventName, group.matcher as string | undefined, handler), + fileSha256, }); } } diff --git a/plugins/codexclaw/components/cxc-ops/test/hook-trust.test.ts b/plugins/codexclaw/components/cxc-ops/test/hook-trust.test.ts index 6082f949..fe27ec0b 100644 --- a/plugins/codexclaw/components/cxc-ops/test/hook-trust.test.ts +++ b/plugins/codexclaw/components/cxc-ops/test/hook-trust.test.ts @@ -237,7 +237,7 @@ test("diagnoseHookTrust reports trusted, drifted, and untrusted independently", assert.equal(diagnosed[2].actual, null); }); -test("doctor hook-trust check warns on ambiguous keys and fails with per-hook drift evidence", () => { +test("doctor hook-trust check warns on ambiguous keys and warns with per-hook drift evidence", () => { const root = makePlugin({ hooks: { Stop: [{ hooks: [command("echo doctor")] }] } }); const entry = listHookEntries(root, "fixture@one")[0]; const ambiguousHome = makeCodexHome([ @@ -253,10 +253,16 @@ test("doctor hook-trust check warns on ambiguous keys and fails with per-hook dr writeFileSync(join(ambiguousHome, "config.toml"), `${trustSection(entry, "sha256:stale")}`); const drifted = runHookTrustCheck(root, { codexHome: ambiguousHome, pluginKey: "fixture@one" }); - assert.equal(drifted.severity, "FAIL"); + // Drift means the recorded hash is stale, not that the hooks stopped running: + // the host decides trust from its own record (PLAN-BYPASS-NAMED-01). + assert.equal(drifted.severity, "WARN"); + assert.match(drifted.evidence, /trusted_hash drift \(reinstall updates it; hooks still run\)/); assert.match(drifted.evidence, new RegExp(entry.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.match(drifted.evidence, new RegExp(`expected=${entry.hash}`)); assert.match(drifted.evidence, /actual=sha256:stale/); + // The file digest is evidence only — it never equals the identity hash. + assert.match(drifted.evidence, new RegExp(`file_sha256=${entry.fileSha256.slice(0, 16)}`)); + assert.notEqual(`sha256:${entry.fileSha256}`, entry.hash); }); test("retrustHooks replaces drift, appends missing sections, preserves unrelated bytes, and creates a backup", () => { @@ -540,12 +546,29 @@ test("doctor's drift repair omits --bootstrap-ok, which would be the wrong advic const entries = listHookEntries(root, PLUGIN_KEY); const home = makeCodexHome(trustSection(entries[0], "sha256:stale")); const check = runHookTrustCheck(root, { codexHome: home, pluginKey: PLUGIN_KEY }); - assert.equal(check.severity, "FAIL"); + assert.equal(check.severity, "WARN"); assert.match(check.evidence, /drifted/); assert.match(check.repair ?? "", /cxc hooks retrust/); assert.ok(!(check.repair ?? "").includes("--bootstrap-ok"), "drift is not a bootstrap case"); }); +/** + * The WARN downgrade covers drift ALONE. A hook with no trust entry was never + * approved, and mixing one into a drifted set must not launder it into a warning. + */ +test("drift mixed with a missing trust entry still fails", () => { + const root = makePlugin({ + hooks: { Stop: [{ hooks: [command("echo drifted")] }, { hooks: [command("echo missing")] }] }, + }); + const entries = listHookEntries(root, PLUGIN_KEY); + const home = makeCodexHome(trustSection(entries[0], "sha256:stale")); + const check = runHookTrustCheck(root, { codexHome: home, pluginKey: PLUGIN_KEY }); + assert.equal(check.severity, "FAIL"); + assert.match(check.evidence, /drifted/); + assert.match(check.evidence, /untrusted/); + assert.doesNotMatch(check.evidence, /hooks still run/); +}); + test("a fully trusted install carries no repair line", () => { const root = makePlugin({ hooks: { Stop: [{ hooks: [command("echo trusted")] }] } }); const entries = listHookEntries(root, PLUGIN_KEY); diff --git a/plugins/codexclaw/components/recall/dist/format.js b/plugins/codexclaw/components/recall/dist/format.js index 46b6bb79..6e3d6e64 100644 --- a/plugins/codexclaw/components/recall/dist/format.js +++ b/plugins/codexclaw/components/recall/dist/format.js @@ -49,7 +49,63 @@ export function formatChatResult(result ) { return lines.join("\n"); } -export function formatMemoryResult(result ) { +/** + * Tokens distinctive enough to say two hits are about the same thing: versions, + * source filenames, CamelCase symbols. Ordinary words are deliberately excluded — + * a shared "the" must never make one memory look like a correction of another. + */ +const TOPIC_TOKEN = + /\b\d+\.\d+(?:\.\d+)?\b|\b[\w.-]+\.(?:ts|tsx|js|mjs|json|md)\b|\b[A-Z][a-zA-Z]*[A-Z][A-Za-z0-9]*\b/g; + +/** Whole days between `updatedAt` and now; null when there is no usable stamp. */ +export function ageDays(updatedAt , nowMs ) { + if (!updatedAt) return null; + const stamp = Date.parse(updatedAt); + if (!Number.isFinite(stamp)) return null; + return Math.max(0, Math.floor((nowMs - stamp) / 86_400_000)); +} + +function topicTokens(hit ) { + const bag = `${hit.relpath} ${hit.excerpt}`; + const out = new Set (); + TOPIC_TOKEN.lastIndex = 0; + for (const match of bag.matchAll(TOPIC_TOKEN)) out.add(match[0].toLowerCase()); + return out; +} + +function sharesTopic(mine , theirs ) { + for (const token of mine) if (theirs.has(token)) return true; + return false; +} + +/** + * The newest same-topic hit that sits in a DIFFERENT file, or null. + * + * Memory accumulates corrections: a 2.48 note and a 2.49 note both match + * "provenance", and relevance alone can rank the superseded one first. Ranking + * stays exactly as `rankAndTrim` left it — this only labels, so the reader can + * see that a fresher record of the same subject is in the same result set. + */ +export function newerRelpath(hit , hits ) { + if (!hit.updatedAt) return null; + const mine = Date.parse(hit.updatedAt); + if (!Number.isFinite(mine)) return null; + const myTopic = topicTokens(hit); + if (myTopic.size === 0) return null; + let best = null; + let bestStamp = mine; + for (const other of hits) { + if (other === hit || other.relpath === hit.relpath || !other.updatedAt) continue; + const stamp = Date.parse(other.updatedAt); + if (!Number.isFinite(stamp) || stamp <= bestStamp) continue; + if (!sharesTopic(myTopic, topicTokens(other))) continue; + best = other; + bestStamp = stamp; + } + return best ? best.relpath : null; +} + +export function formatMemoryResult(result , nowMs = Date.now()) { const lines = []; lines.push(`# ${result.hits.length} memory hits (${result.scannedFiles} files scanned, ${result.elapsedMs}ms)`); if (result.hits.length === 0) lines.push("(no matches)"); @@ -58,7 +114,13 @@ export function formatMemoryResult(result ) { const loc = hit.startLine !== null ? `${hit.relpath}:${hit.startLine}` : hit.relpath; const when = hit.updatedAt ? ` [${hit.updatedAt}]` : ""; const cwd = hit.cwd ? ` {${hit.cwd}}` : ""; - lines.push(`(${hit.origin}/${hit.kind}) ${loc}${when}${cwd}`); + // The ISO stamp says when; the age says how long ago, which is what decides + // whether a stored claim can still be asserted without checking it live. + const age = ageDays(hit.updatedAt, nowMs); + const ageBit = age === null ? "" : ` [age: ${age}d]`; + const newer = newerRelpath(hit, result.hits); + const newerBit = newer ? ` [newer: ${newer}]` : ""; + lines.push(`(${hit.origin}/${hit.kind}) ${loc}${when}${cwd}${ageBit}${newerBit}`); lines.push(clip(hit.excerpt, EXCERPT)); lines.push("---"); } diff --git a/plugins/codexclaw/components/recall/dist/hook.js b/plugins/codexclaw/components/recall/dist/hook.js index 384d6bcb..7a3b260b 100644 --- a/plugins/codexclaw/components/recall/dist/hook.js +++ b/plugins/codexclaw/components/recall/dist/hook.js @@ -29,8 +29,9 @@ import { readHitCounts, } from "./index-db.js"; import { cwdMatches, FOLD_CWD_CASE } from "./rollout.js"; -import { existsSync } from "node:fs"; -import { basename } from "node:path"; +import { codexHome } from "./paths.js"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; // Cross-component dist import (established precedent: messenger-bridge api-compat). // Resolves from BOTH src (test-time ../../cxc-ops/dist) and shipped dist layouts. // Cross-component dist import, LAZY + FAIL-OPEN (260724 WP1): the entry must keep @@ -91,6 +92,13 @@ const RECALL_PATTERNS = [ /\b(as|we)\s+discussed\s+(earlier|before|previously|last\s+time)\b/i, /\bdiscussed\s+previously\b/i, /\bearlier\s+(session|conversation|work)\b/i, + // wp6: idioms the original set missed. Bare 그때 stays out — it reads as a + // plain time reference ("그때 봤어") far more often than as a recall request. + /이전에\s*(하|했|만든|작업|얘기|말)/, + /그\s*세션/, + /그때에(?:는|도)?/, + /\bprior\s+(work|session|conversation)\b/i, + /\ba\s+while\s+ago\b/i, ]; /** @@ -107,16 +115,63 @@ export function detectRecallIntent(prompt ) { return RECALL_PATTERNS.some((re) => re.test(prompt)); } +/** + * Distinctive tokens worth searching for: versions, filenames, error/rule codes, + * CamelCase symbols, and short quoted strings. Regex only — this runs on the + * UserPromptSubmit path, which must return in well under its 5s hook budget, so + * it must never open an index or touch the corpus. + */ +const VERSION_RE = /\b\d+\.\d+(?:\.\d+)?\b/g; +const FILE_RE = /\b[\w.-]+\.(?:ts|tsx|js|mjs|cjs|json|md|toml|py|rs)\b/g; +const ERROR_RE = /\b(?:[A-Z]{2,}(?:-[A-Z0-9]+)+|ERR_[A-Z0-9_]+)\b/g; +const CAMEL_RE = /\b[A-Z][a-zA-Z]*[A-Z][A-Za-z0-9]*\b/g; +const QUOTED_RE = /["'\`]([^"'\n]{3,60})["'\`]/g; + +/** Recall idioms themselves are not search terms — they are why we are here. */ +const TARGET_STOP = new Set([ + "그때", "지난번", "지난", "저번", "예전", "세션", "작업", "기억", "뭐였지", + "last", "time", "session", "previous", "previously", "remember", +]); + +/** Max suggested terms. A long list is noise; the agent still writes the query. */ +const TARGET_CAP = 4; + +export function extractRecallTargets(prompt , cap = TARGET_CAP) { + const out = []; + const seen = new Set (); + const push = (raw ) => { + const term = raw.trim(); + if (term.length < 2 || term.length > 60) return; + const key = term.toLowerCase(); + if (TARGET_STOP.has(key) || seen.has(key)) return; + seen.add(key); + out.push(term); + }; + for (const re of [VERSION_RE, FILE_RE, ERROR_RE, CAMEL_RE]) { + re.lastIndex = 0; + for (const match of prompt.matchAll(re)) push(match[0]); + } + QUOTED_RE.lastIndex = 0; + for (const match of prompt.matchAll(QUOTED_RE)) push(match[1] ?? ""); + return out.slice(0, cap); +} + // WHY a builder, not a const: the command prefix must be resolved per emit. -function buildDirective() { +function buildDirective(targets = []) { const cxc = CXC(); - return [ + const rows = [ "[cxc-recall] The prompt references past work. Before asking the user to re-explain,", "search prior sessions (read-only):", ` ${cxc} chat search "" --days 0 # full-history FTS over ~/.codex`, ` ${cxc} memory search "" # durable per-thread summaries`, "Add --context 2 to read around a hit, --cwd to scope. Details: $cxc-recall.", - ].join("\n"); + ]; + // The hook suggests, it does not search: running a query here would spend the + // prompt's latency budget on a guess the agent may not need. + if (targets.length > 0) { + rows.push(`Suggested recall terms: ${targets.join(" ")} (search not run by this hook).`); + } + return rows.join("\n"); } const MAX_CTX = 32_768; @@ -139,7 +194,7 @@ export function handleUserPromptSubmit(payload ) if (payload.hook_event_name !== "UserPromptSubmit") return ""; const prompt = typeof payload.prompt === "string" ? payload.prompt : ""; if (!detectRecallIntent(prompt)) return ""; - return buildContextOutput("UserPromptSubmit", buildDirective()); + return buildContextOutput("UserPromptSubmit", buildDirective(extractRecallTargets(prompt))); } catch { return ""; } @@ -495,6 +550,91 @@ export function buildCwdContext( } } +/** Injected by tests so a unit assertion never depends on the operator's config. */ + + + + +/** + * Is the native memories tool surface switched on for this Codex home? + * + * The recall hook has no other way to know: installation writes the managed key + * into config.toml, and nothing hands it to the hook on stdin. Resolution goes + * through paths.ts codexHome() (CODEX_HOME ?? ~/.codex) because CODEX_HOME is + * normally unset — reading the variable alone would leave this branch dead on + * every default install. Any read or parse problem is false: pointing at the + * `cxc` commands is correct whether or not the tools exist, while naming a tool + * the agent does not have is not. + */ +export function dedicatedToolsEnabled(home ) { + try { + const text = readFileSync(join(home ?? codexHome(), "config.toml"), "utf8"); + const body = memoriesTableBody(text); + if (body === null) return false; + return /^[ \t]*dedicated_tools[ \t]*=[ \t]*true[ \t]*(?:#.*)?$/m.test(body); + } catch { + return false; + } +} + +/** Body of the `[memories]` table, or null when the table is absent. */ +function memoriesTableBody(text ) { + const rows = text.split(/\r?\n/); + const start = rows.findIndex((line) => /^[ \t]*\[memories\][ \t]*(?:#.*)?$/.test(line)); + if (start === -1) return null; + const rest = rows.slice(start + 1); + const end = rest.findIndex((line) => /^[ \t]*\[/.test(line)); + return (end === -1 ? rest : rest.slice(0, end)).join("\n"); +} + +/** + * One line, hard-capped: a compaction just paid to free context, so the pointer + * that follows it must not start refilling the window. + */ +const RECOVERY_LINE_BUDGET = 160; + +function recoveryLine(cxc , dedicatedTools ) { + const line = dedicatedTools + ? `Recall: memories.search "" (native tool). Also: ${cxc} memory search ""` + : `Recall: ${cxc} chat search "" --days 0 | ${cxc} memory search ""`; + return line.length <= RECOVERY_LINE_BUDGET ? line : line.slice(0, RECOVERY_LINE_BUDGET); +} + +/** + * The notice, shaped by why the session started. + * + * `compact` is a recovery moment: the detail the agent is missing was just + * dropped from a context it already had. `resume` keeps the availability + * wording — the agent has not seen this thread's history in this process — and + * adds why the gap exists. `startup` and `clear` are the plain availability + * form. Every shape ends on the same recall pointer. + */ +function sessionNotice( + source , + cxc , + status , + dedicatedTools , +) { + const src = source ?? "startup"; + const rows = + src === "compact" + ? [ + "[cxc-recall] Context was just compacted. If any earlier detail is now missing,", + "recover it from past sessions before asking the user to repeat themselves.", + ] + : [ + "[cxc-recall] Past-session recall is available (read-only). Before asking the user", + "about prior work \u2014 unfamiliar terms, lost context, \"\uadf8\ub54c/\uc9c0\ub09c\ubc88/last time\" \u2014 recover it.", + ]; + if (src === "resume") { + rows.push("This session was resumed after a pause, so earlier turns may be missing here."); + } + rows.push(recoveryLine(cxc, dedicatedTools)); + if (status !== "") rows.push(`Index: ${status}. Details: $cxc-recall.`); + else rows.push("Details: $cxc-recall."); + return rows.join("\n"); +} + /** * SessionStart: inject CWD-scoped recent work context + recall availability notice. * The `cwd` comes from the hook JSON payload; `status` is the index status line. @@ -504,7 +644,12 @@ export function buildCwdContext( * after a compaction), which is where the post-compaction recovery directive is * delivered — PostCompact output itself cannot carry it (see handlePostCompact). */ -export function handleSessionStart(status , cwd , source ) { +export function handleSessionStart( + status , + cwd , + source , + opts = {}, +) { const parts = []; const compacted = source === "compact"; @@ -515,25 +660,10 @@ export function handleSessionStart(status , cwd , source if (cwdCtx) parts.push(cwdCtx); } - const cxc = CXC(); - // Recall availability notice (pointer). After a compaction the same pointer is - // framed as recovery: the detail the agent is missing was just dropped from the - // context window, not never seen. - const notice = compacted - ? [ - "[cxc-recall] Context was just compacted. If any earlier detail is now missing,", - "recover it from past sessions before asking the user to repeat themselves:", - ` ${cxc} chat search "" --days 0 --context 2`, - ` ${cxc} memory search ""`, - ] - : [ - "[cxc-recall] Past-session recall is available (read-only). Before asking the user", - "about prior work \u2014 unfamiliar terms, lost context, \"\uadf8\ub54c/\uc9c0\ub09c\ubc88/last time\" \u2014 run:", - ` ${cxc} chat search "" --days 0 | ${cxc} memory search ""`, - ]; - if (status !== "") notice.push(`Index: ${status}. Details: $cxc-recall.`); - else notice.push("Details: $cxc-recall."); - parts.push(notice.join("\n")); + // Absent injection means "ask the machine": the branch must stay reachable on a + // default install, where nothing sets CODEX_HOME. + const dedicatedTools = opts.dedicatedTools ?? dedicatedToolsEnabled(); + parts.push(sessionNotice(source, CXC(), status, dedicatedTools)); return buildContextOutput("SessionStart", parts.join("\n\n")); } @@ -558,3 +688,32 @@ export function handlePostCompact(cwd ) { void cwd; return ""; } + +/** One hook process run, as the runtime sees it. */ + + + + + + +/** + * The whole stdout/stderr/exit contract a Codex hook must satisfy, in one place + * so every handler can be held to it. + * + * claude-mem #621 is the failure this guards: a hook that printed a colored + * progress line and exited non-zero broke SessionStart for every session, because + * the runtime parses what the process wrote. So the legal shapes are exactly two — + * write nothing, or write one JSON object — and the process exits 0 either way. + * stderr is checked too: that is where the upstream break actually emitted its + * ANSI. + */ +export function assertLegalHookResult(result ) { + if (result.code !== 0) throw new Error(`hook exited ${result.code}`); + if (/\x1b\[/.test(result.stderr)) throw new Error("hook wrote ANSI to stderr"); + if (result.stdout === "") return; + if (/\x1b\[/.test(result.stdout)) throw new Error("hook wrote ANSI to stdout"); + const parsed = JSON.parse(result.stdout); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("hook stdout is not a JSON object"); + } +} diff --git a/plugins/codexclaw/components/recall/src/format.ts b/plugins/codexclaw/components/recall/src/format.ts index 68540df7..8ad9fe37 100644 --- a/plugins/codexclaw/components/recall/src/format.ts +++ b/plugins/codexclaw/components/recall/src/format.ts @@ -9,7 +9,7 @@ * --- */ import type { ChatSearchResult, ChatHit } from "./chat-search.ts"; -import type { MemorySearchResult } from "./memory-search.ts"; +import type { MemoryHit, MemorySearchResult } from "./memory-search.ts"; const EXCERPT = 300; @@ -49,7 +49,63 @@ export function formatChatResult(result: ChatSearchResult): string { return lines.join("\n"); } -export function formatMemoryResult(result: MemorySearchResult): string { +/** + * Tokens distinctive enough to say two hits are about the same thing: versions, + * source filenames, CamelCase symbols. Ordinary words are deliberately excluded — + * a shared "the" must never make one memory look like a correction of another. + */ +const TOPIC_TOKEN = + /\b\d+\.\d+(?:\.\d+)?\b|\b[\w.-]+\.(?:ts|tsx|js|mjs|json|md)\b|\b[A-Z][a-zA-Z]*[A-Z][A-Za-z0-9]*\b/g; + +/** Whole days between `updatedAt` and now; null when there is no usable stamp. */ +export function ageDays(updatedAt: string | null, nowMs: number): number | null { + if (!updatedAt) return null; + const stamp = Date.parse(updatedAt); + if (!Number.isFinite(stamp)) return null; + return Math.max(0, Math.floor((nowMs - stamp) / 86_400_000)); +} + +function topicTokens(hit: MemoryHit): Set { + const bag = `${hit.relpath} ${hit.excerpt}`; + const out = new Set(); + TOPIC_TOKEN.lastIndex = 0; + for (const match of bag.matchAll(TOPIC_TOKEN)) out.add(match[0].toLowerCase()); + return out; +} + +function sharesTopic(mine: Set, theirs: Set): boolean { + for (const token of mine) if (theirs.has(token)) return true; + return false; +} + +/** + * The newest same-topic hit that sits in a DIFFERENT file, or null. + * + * Memory accumulates corrections: a 2.48 note and a 2.49 note both match + * "provenance", and relevance alone can rank the superseded one first. Ranking + * stays exactly as `rankAndTrim` left it — this only labels, so the reader can + * see that a fresher record of the same subject is in the same result set. + */ +export function newerRelpath(hit: MemoryHit, hits: readonly MemoryHit[]): string | null { + if (!hit.updatedAt) return null; + const mine = Date.parse(hit.updatedAt); + if (!Number.isFinite(mine)) return null; + const myTopic = topicTokens(hit); + if (myTopic.size === 0) return null; + let best: MemoryHit | null = null; + let bestStamp = mine; + for (const other of hits) { + if (other === hit || other.relpath === hit.relpath || !other.updatedAt) continue; + const stamp = Date.parse(other.updatedAt); + if (!Number.isFinite(stamp) || stamp <= bestStamp) continue; + if (!sharesTopic(myTopic, topicTokens(other))) continue; + best = other; + bestStamp = stamp; + } + return best ? best.relpath : null; +} + +export function formatMemoryResult(result: MemorySearchResult, nowMs = Date.now()): string { const lines: string[] = []; lines.push(`# ${result.hits.length} memory hits (${result.scannedFiles} files scanned, ${result.elapsedMs}ms)`); if (result.hits.length === 0) lines.push("(no matches)"); @@ -58,7 +114,13 @@ export function formatMemoryResult(result: MemorySearchResult): string { const loc = hit.startLine !== null ? `${hit.relpath}:${hit.startLine}` : hit.relpath; const when = hit.updatedAt ? ` [${hit.updatedAt}]` : ""; const cwd = hit.cwd ? ` {${hit.cwd}}` : ""; - lines.push(`(${hit.origin}/${hit.kind}) ${loc}${when}${cwd}`); + // The ISO stamp says when; the age says how long ago, which is what decides + // whether a stored claim can still be asserted without checking it live. + const age = ageDays(hit.updatedAt, nowMs); + const ageBit = age === null ? "" : ` [age: ${age}d]`; + const newer = newerRelpath(hit, result.hits); + const newerBit = newer ? ` [newer: ${newer}]` : ""; + lines.push(`(${hit.origin}/${hit.kind}) ${loc}${when}${cwd}${ageBit}${newerBit}`); lines.push(clip(hit.excerpt, EXCERPT)); lines.push("---"); } diff --git a/plugins/codexclaw/components/recall/src/hook.ts b/plugins/codexclaw/components/recall/src/hook.ts index 452e02e0..cec932f3 100644 --- a/plugins/codexclaw/components/recall/src/hook.ts +++ b/plugins/codexclaw/components/recall/src/hook.ts @@ -29,8 +29,9 @@ import { readHitCounts, } from "./index-db.ts"; import { cwdMatches, FOLD_CWD_CASE } from "./rollout.ts"; -import { existsSync } from "node:fs"; -import { basename } from "node:path"; +import { codexHome } from "./paths.ts"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; // Cross-component dist import (established precedent: messenger-bridge api-compat). // Resolves from BOTH src (test-time ../../cxc-ops/dist) and shipped dist layouts. // Cross-component dist import, LAZY + FAIL-OPEN (260724 WP1): the entry must keep @@ -91,6 +92,13 @@ const RECALL_PATTERNS: readonly RegExp[] = [ /\b(as|we)\s+discussed\s+(earlier|before|previously|last\s+time)\b/i, /\bdiscussed\s+previously\b/i, /\bearlier\s+(session|conversation|work)\b/i, + // wp6: idioms the original set missed. Bare 그때 stays out — it reads as a + // plain time reference ("그때 봤어") far more often than as a recall request. + /이전에\s*(하|했|만든|작업|얘기|말)/, + /그\s*세션/, + /그때에(?:는|도)?/, + /\bprior\s+(work|session|conversation)\b/i, + /\ba\s+while\s+ago\b/i, ]; /** @@ -107,16 +115,63 @@ export function detectRecallIntent(prompt: string): boolean { return RECALL_PATTERNS.some((re) => re.test(prompt)); } +/** + * Distinctive tokens worth searching for: versions, filenames, error/rule codes, + * CamelCase symbols, and short quoted strings. Regex only — this runs on the + * UserPromptSubmit path, which must return in well under its 5s hook budget, so + * it must never open an index or touch the corpus. + */ +const VERSION_RE = /\b\d+\.\d+(?:\.\d+)?\b/g; +const FILE_RE = /\b[\w.-]+\.(?:ts|tsx|js|mjs|cjs|json|md|toml|py|rs)\b/g; +const ERROR_RE = /\b(?:[A-Z]{2,}(?:-[A-Z0-9]+)+|ERR_[A-Z0-9_]+)\b/g; +const CAMEL_RE = /\b[A-Z][a-zA-Z]*[A-Z][A-Za-z0-9]*\b/g; +const QUOTED_RE = /["'\`]([^"'\n]{3,60})["'\`]/g; + +/** Recall idioms themselves are not search terms — they are why we are here. */ +const TARGET_STOP = new Set([ + "그때", "지난번", "지난", "저번", "예전", "세션", "작업", "기억", "뭐였지", + "last", "time", "session", "previous", "previously", "remember", +]); + +/** Max suggested terms. A long list is noise; the agent still writes the query. */ +const TARGET_CAP = 4; + +export function extractRecallTargets(prompt: string, cap = TARGET_CAP): string[] { + const out: string[] = []; + const seen = new Set(); + const push = (raw: string): void => { + const term = raw.trim(); + if (term.length < 2 || term.length > 60) return; + const key = term.toLowerCase(); + if (TARGET_STOP.has(key) || seen.has(key)) return; + seen.add(key); + out.push(term); + }; + for (const re of [VERSION_RE, FILE_RE, ERROR_RE, CAMEL_RE]) { + re.lastIndex = 0; + for (const match of prompt.matchAll(re)) push(match[0]); + } + QUOTED_RE.lastIndex = 0; + for (const match of prompt.matchAll(QUOTED_RE)) push(match[1] ?? ""); + return out.slice(0, cap); +} + // WHY a builder, not a const: the command prefix must be resolved per emit. -function buildDirective(): string { +function buildDirective(targets: readonly string[] = []): string { const cxc = CXC(); - return [ + const rows = [ "[cxc-recall] The prompt references past work. Before asking the user to re-explain,", "search prior sessions (read-only):", ` ${cxc} chat search "" --days 0 # full-history FTS over ~/.codex`, ` ${cxc} memory search "" # durable per-thread summaries`, "Add --context 2 to read around a hit, --cwd to scope. Details: $cxc-recall.", - ].join("\n"); + ]; + // The hook suggests, it does not search: running a query here would spend the + // prompt's latency budget on a guess the agent may not need. + if (targets.length > 0) { + rows.push(`Suggested recall terms: ${targets.join(" ")} (search not run by this hook).`); + } + return rows.join("\n"); } const MAX_CTX = 32_768; @@ -139,7 +194,7 @@ export function handleUserPromptSubmit(payload: UserPromptSubmitPayload): string if (payload.hook_event_name !== "UserPromptSubmit") return ""; const prompt = typeof payload.prompt === "string" ? payload.prompt : ""; if (!detectRecallIntent(prompt)) return ""; - return buildContextOutput("UserPromptSubmit", buildDirective()); + return buildContextOutput("UserPromptSubmit", buildDirective(extractRecallTargets(prompt))); } catch { return ""; } @@ -495,6 +550,91 @@ export function buildCwdContext( } } +/** Injected by tests so a unit assertion never depends on the operator's config. */ +export interface SessionStartOptions { + dedicatedTools?: boolean; +} + +/** + * Is the native memories tool surface switched on for this Codex home? + * + * The recall hook has no other way to know: installation writes the managed key + * into config.toml, and nothing hands it to the hook on stdin. Resolution goes + * through paths.ts codexHome() (CODEX_HOME ?? ~/.codex) because CODEX_HOME is + * normally unset — reading the variable alone would leave this branch dead on + * every default install. Any read or parse problem is false: pointing at the + * `cxc` commands is correct whether or not the tools exist, while naming a tool + * the agent does not have is not. + */ +export function dedicatedToolsEnabled(home?: string): boolean { + try { + const text = readFileSync(join(home ?? codexHome(), "config.toml"), "utf8"); + const body = memoriesTableBody(text); + if (body === null) return false; + return /^[ \t]*dedicated_tools[ \t]*=[ \t]*true[ \t]*(?:#.*)?$/m.test(body); + } catch { + return false; + } +} + +/** Body of the `[memories]` table, or null when the table is absent. */ +function memoriesTableBody(text: string): string | null { + const rows = text.split(/\r?\n/); + const start = rows.findIndex((line) => /^[ \t]*\[memories\][ \t]*(?:#.*)?$/.test(line)); + if (start === -1) return null; + const rest = rows.slice(start + 1); + const end = rest.findIndex((line) => /^[ \t]*\[/.test(line)); + return (end === -1 ? rest : rest.slice(0, end)).join("\n"); +} + +/** + * One line, hard-capped: a compaction just paid to free context, so the pointer + * that follows it must not start refilling the window. + */ +const RECOVERY_LINE_BUDGET = 160; + +function recoveryLine(cxc: string, dedicatedTools: boolean): string { + const line = dedicatedTools + ? `Recall: memories.search "" (native tool). Also: ${cxc} memory search ""` + : `Recall: ${cxc} chat search "" --days 0 | ${cxc} memory search ""`; + return line.length <= RECOVERY_LINE_BUDGET ? line : line.slice(0, RECOVERY_LINE_BUDGET); +} + +/** + * The notice, shaped by why the session started. + * + * `compact` is a recovery moment: the detail the agent is missing was just + * dropped from a context it already had. `resume` keeps the availability + * wording — the agent has not seen this thread's history in this process — and + * adds why the gap exists. `startup` and `clear` are the plain availability + * form. Every shape ends on the same recall pointer. + */ +function sessionNotice( + source: string | undefined, + cxc: string, + status: string, + dedicatedTools: boolean, +): string { + const src = source ?? "startup"; + const rows = + src === "compact" + ? [ + "[cxc-recall] Context was just compacted. If any earlier detail is now missing,", + "recover it from past sessions before asking the user to repeat themselves.", + ] + : [ + "[cxc-recall] Past-session recall is available (read-only). Before asking the user", + "about prior work \u2014 unfamiliar terms, lost context, \"\uadf8\ub54c/\uc9c0\ub09c\ubc88/last time\" \u2014 recover it.", + ]; + if (src === "resume") { + rows.push("This session was resumed after a pause, so earlier turns may be missing here."); + } + rows.push(recoveryLine(cxc, dedicatedTools)); + if (status !== "") rows.push(`Index: ${status}. Details: $cxc-recall.`); + else rows.push("Details: $cxc-recall."); + return rows.join("\n"); +} + /** * SessionStart: inject CWD-scoped recent work context + recall availability notice. * The `cwd` comes from the hook JSON payload; `status` is the index status line. @@ -504,7 +644,12 @@ export function buildCwdContext( * after a compaction), which is where the post-compaction recovery directive is * delivered — PostCompact output itself cannot carry it (see handlePostCompact). */ -export function handleSessionStart(status: string, cwd?: string, source?: string): string { +export function handleSessionStart( + status: string, + cwd?: string, + source?: string, + opts: SessionStartOptions = {}, +): string { const parts: string[] = []; const compacted = source === "compact"; @@ -515,25 +660,10 @@ export function handleSessionStart(status: string, cwd?: string, source?: string if (cwdCtx) parts.push(cwdCtx); } - const cxc = CXC(); - // Recall availability notice (pointer). After a compaction the same pointer is - // framed as recovery: the detail the agent is missing was just dropped from the - // context window, not never seen. - const notice = compacted - ? [ - "[cxc-recall] Context was just compacted. If any earlier detail is now missing,", - "recover it from past sessions before asking the user to repeat themselves:", - ` ${cxc} chat search "" --days 0 --context 2`, - ` ${cxc} memory search ""`, - ] - : [ - "[cxc-recall] Past-session recall is available (read-only). Before asking the user", - "about prior work \u2014 unfamiliar terms, lost context, \"\uadf8\ub54c/\uc9c0\ub09c\ubc88/last time\" \u2014 run:", - ` ${cxc} chat search "" --days 0 | ${cxc} memory search ""`, - ]; - if (status !== "") notice.push(`Index: ${status}. Details: $cxc-recall.`); - else notice.push("Details: $cxc-recall."); - parts.push(notice.join("\n")); + // Absent injection means "ask the machine": the branch must stay reachable on a + // default install, where nothing sets CODEX_HOME. + const dedicatedTools = opts.dedicatedTools ?? dedicatedToolsEnabled(); + parts.push(sessionNotice(source, CXC(), status, dedicatedTools)); return buildContextOutput("SessionStart", parts.join("\n\n")); } @@ -558,3 +688,32 @@ export function handlePostCompact(cwd?: string): string { void cwd; return ""; } + +/** One hook process run, as the runtime sees it. */ +export interface HookResult { + stdout: string; + stderr: string; + code: number; +} + +/** + * The whole stdout/stderr/exit contract a Codex hook must satisfy, in one place + * so every handler can be held to it. + * + * claude-mem #621 is the failure this guards: a hook that printed a colored + * progress line and exited non-zero broke SessionStart for every session, because + * the runtime parses what the process wrote. So the legal shapes are exactly two — + * write nothing, or write one JSON object — and the process exits 0 either way. + * stderr is checked too: that is where the upstream break actually emitted its + * ANSI. + */ +export function assertLegalHookResult(result: HookResult): void { + if (result.code !== 0) throw new Error(`hook exited ${result.code}`); + if (/\x1b\[/.test(result.stderr)) throw new Error("hook wrote ANSI to stderr"); + if (result.stdout === "") return; + if (/\x1b\[/.test(result.stdout)) throw new Error("hook wrote ANSI to stdout"); + const parsed: unknown = JSON.parse(result.stdout); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("hook stdout is not a JSON object"); + } +} diff --git a/plugins/codexclaw/components/recall/test/format-freshness.test.ts b/plugins/codexclaw/components/recall/test/format-freshness.test.ts new file mode 100644 index 00000000..33e8d888 --- /dev/null +++ b/plugins/codexclaw/components/recall/test/format-freshness.test.ts @@ -0,0 +1,139 @@ +/** + * format-freshness.test.ts — wp6 item 3: the memory search text output carries + * an age per hit and points a superseded hit at the newer record of the same + * topic. Labels only: ranking belongs to memory-search's rankAndTrim, and this + * suite pins that the formatter never reorders what it was handed. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ageDays, formatMemoryResult, newerRelpath } from "../src/format.ts"; +import type { MemoryHit, MemorySearchResult } from "../src/memory-search.ts"; + +const NOW = Date.parse("2026-09-10T00:00:00Z"); +const DAY = 86_400_000; + +function hit( + partial: Partial & Pick, +): MemoryHit { + return { + origin: "file", + kind: "handbook", + threadId: null, + startLine: 1, + cwd: null, + score: 1, + ...partial, + }; +} + +function resultOf(hits: MemoryHit[]): MemorySearchResult { + return { hits, warnings: [], scannedFiles: hits.length, elapsedMs: 1 }; +} + +test("ageDays: ISO stamps become whole days, anything unusable stays unlabeled", () => { + assert.equal(ageDays(new Date(NOW - 3 * DAY).toISOString(), NOW), 3); + assert.equal(ageDays(new Date(NOW - 3 * DAY - 1000).toISOString(), NOW), 3, "floors, not rounds"); + assert.equal(ageDays(new Date(NOW).toISOString(), NOW), 0); + assert.equal(ageDays(new Date(NOW + 5 * DAY).toISOString(), NOW), 0, "a future stamp is not negative"); + assert.equal(ageDays(null, NOW), null); + assert.equal(ageDays("not a date", NOW), null); +}); + +test("a hit without a timestamp carries no age label", () => { + const text = formatMemoryResult( + resultOf([hit({ relpath: "MEMORY.md", excerpt: "no stamp here", updatedAt: null })]), + NOW, + ); + assert.doesNotMatch(text, /\[age:/); + assert.match(text, /MEMORY\.md/); +}); + +test("the older same-topic hit points at the newer relpath, and order is unchanged", () => { + const older = hit({ + relpath: "rollout_summaries/old.md", + excerpt: "2.49.0 provenance check", + updatedAt: new Date(NOW - 14 * DAY).toISOString(), + score: 9, + }); + const newer = hit({ + relpath: "MEMORY.md", + excerpt: "2.49.0 provenance verified", + updatedAt: new Date(NOW - 1 * DAY).toISOString(), + score: 3, + }); + // Ranking already put the older, higher-scoring hit first; that is the case + // the label exists for. + const hits = [older, newer]; + assert.equal(newerRelpath(older, hits), "MEMORY.md"); + assert.equal(newerRelpath(newer, hits), null); + + const before = hits.map((h) => h.relpath); + const text = formatMemoryResult(resultOf(hits), NOW); + assert.deepEqual(hits.map((h) => h.relpath), before, "format must not reorder or mutate hits"); + const first = text.indexOf("rollout_summaries/old.md"); + const second = text.indexOf("MEMORY.md"); + assert.ok(first >= 0 && second > first, "the rendered order follows the input order"); + assert.match(text, /\[age: 14d\]/); + assert.match(text, /\[age: 1d\]/); + assert.match(text, /\[newer: MEMORY\.md\]/); + assert.equal((text.match(/\[newer:/g) ?? []).length, 1, "only the superseded hit is labelled"); +}); + +test("hits that share no distinctive token are not called corrections of each other", () => { + const alpha = hit({ + relpath: "a.md", + excerpt: "alpha notes about the intake form", + updatedAt: new Date(NOW - 30 * DAY).toISOString(), + }); + const beta = hit({ + relpath: "b.md", + excerpt: "beta notes about the outbox", + updatedAt: new Date(NOW - 2 * DAY).toISOString(), + }); + assert.equal(newerRelpath(alpha, [alpha, beta]), null); + assert.doesNotMatch(formatMemoryResult(resultOf([alpha, beta]), NOW), /\[newer:/); +}); + +test("a newer hit in the SAME file is a longer record, not a correction", () => { + const first = hit({ + relpath: "MEMORY.md", + excerpt: "2.49.0 provenance check", + updatedAt: new Date(NOW - 9 * DAY).toISOString(), + }); + const second = hit({ + relpath: "MEMORY.md", + excerpt: "2.49.0 provenance rerun", + updatedAt: new Date(NOW - 1 * DAY).toISOString(), + startLine: 40, + }); + assert.equal(newerRelpath(first, [first, second]), null); +}); + +test("the existing envelope survives: header, location, excerpt, delimiter, warnings", () => { + const text = formatMemoryResult( + { + hits: [ + hit({ + relpath: "MEMORY.md", + excerpt: "wp6 recall labels", + updatedAt: new Date(NOW - 2 * DAY).toISOString(), + startLine: 12, + cwd: "/repo/current", + }), + ], + warnings: ["index is stale"], + scannedFiles: 4, + elapsedMs: 7, + }, + NOW, + ); + assert.match(text, /^# 1 memory hits \(4 files scanned, 7ms\)/); + assert.match(text, /\(file\/handbook\) MEMORY\.md:12 \[2026-09-08T00:00:00\.000Z\] \{\/repo\/current\} \[age: 2d\]/); + assert.match(text, /^wp6 recall labels$/m); + assert.match(text, /^---$/m); + assert.match(text, /index is stale/); + + const empty = formatMemoryResult(resultOf([]), NOW); + assert.match(empty, /^# 0 memory hits/); + assert.match(empty, /\(no matches\)/); +}); diff --git a/plugins/codexclaw/components/recall/test/hook.test.ts b/plugins/codexclaw/components/recall/test/hook.test.ts index b79fdf1b..bc71c10a 100644 --- a/plugins/codexclaw/components/recall/test/hook.test.ts +++ b/plugins/codexclaw/components/recall/test/hook.test.ts @@ -1,11 +1,19 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; // Pin the cxc-resolve seam (B1): assertions below expect literal `cxc ...` // command lines, which would otherwise depend on the runner's PATH. process.env.CODEXCLAW_CXC = "cxc"; import { detectRecallIntent, + dedicatedToolsEnabled, + extractRecallTargets, + assertLegalHookResult, handleUserPromptSubmit, handleSessionStart, handlePostCompact, @@ -69,11 +77,17 @@ test("handler emits the pabcd-parity envelope only for recall intents", () => { }); test("session-start advertises recall with and without index status", () => { - const withStatus = JSON.parse(handleSessionStart("1769 files / 354798 messages, last ingest X")); + // dedicatedTools is pinned: the default reads THIS machine's config.toml, and a + // unit assertion about wording must not depend on the operator's install. + const withStatus = JSON.parse( + handleSessionStart("1769 files / 354798 messages, last ingest X", undefined, undefined, { + dedicatedTools: false, + }), + ); assert.equal(withStatus.hookSpecificOutput.hookEventName, "SessionStart"); assert.match(withStatus.hookSpecificOutput.additionalContext, /cxc chat search/); assert.match(withStatus.hookSpecificOutput.additionalContext, /Index: 1769 files/); - const bare = JSON.parse(handleSessionStart("")); + const bare = JSON.parse(handleSessionStart("", undefined, undefined, { dedicatedTools: false })); assert.match(bare.hookSpecificOutput.additionalContext, /\$cxc-recall/); assert.ok(!bare.hookSpecificOutput.additionalContext.includes("Index:")); }); @@ -87,7 +101,9 @@ test("post-compact emits nothing: its output wire cannot carry context", () => { }); test("session-start carries the recovery directive when the source is a compaction", () => { - const compacted = JSON.parse(handleSessionStart("", undefined, "compact")); + const compacted = JSON.parse( + handleSessionStart("", undefined, "compact", { dedicatedTools: false }), + ); assert.equal(compacted.hookSpecificOutput.hookEventName, "SessionStart"); const text = compacted.hookSpecificOutput.additionalContext; assert.match(text, /compacted/); @@ -96,8 +112,9 @@ test("session-start carries the recovery directive when the source is a compacti // A normal start keeps the availability wording and must not claim a compaction. for (const source of [undefined, "startup", "resume", "clear"]) { - const plain = JSON.parse(handleSessionStart("", undefined, source)).hookSpecificOutput - .additionalContext; + const plain = JSON.parse( + handleSessionStart("", undefined, source, { dedicatedTools: false }), + ).hookSpecificOutput.additionalContext; assert.doesNotMatch(plain, /compacted/, `source=${source} must not mention compaction`); assert.match(plain, /recall is available/); } @@ -332,3 +349,211 @@ test("summary text is quoted and capped like every other untrusted field", () => assert.ok(line.length < 130, `summary line is capped: ${line.length}`); } }); + +// ─── wp6: source-shaped briefings ─────────────────────────────────────────── + +const notice = (source?: string, opts: { dedicatedTools?: boolean } = { dedicatedTools: false }) => + JSON.parse(handleSessionStart("", undefined, source, opts)).hookSpecificOutput + .additionalContext as string; + +test("session-start briefings are shaped by source and always end on a recall pointer", () => { + const startup = notice("startup"); + assert.match(startup, /recall is available/); + assert.doesNotMatch(startup, /resumed after a pause/); + + // Resume keeps the availability wording and adds why the context may be thin. + const resume = notice("resume"); + assert.match(resume, /recall is available/); + assert.match(resume, /resumed after a pause/); + assert.ok( + resume.indexOf("recall is available") < resume.indexOf("resumed after a pause"), + "the resume sentence follows the availability wording", + ); + + const compact = notice("compact"); + assert.match(compact, /Context was just compacted/); + assert.doesNotMatch(compact, /resumed after a pause/); + + for (const [label, text] of [ + ["startup", startup], + ["resume", resume], + ["compact", compact], + ] as const) { + const pointer = text.split("\n").find((line) => line.startsWith("Recall: ")) ?? ""; + assert.ok(pointer.length > 0, `${label} carries the recall pointer`); + assert.ok(pointer.length <= 160, `${label} pointer stays one capped line (${pointer.length})`); + assert.match(text.trimEnd().split("\n").at(-1) ?? "", /Details: \$cxc-recall\./); + } +}); + +test("a session with no cwd hits gets the notice and nothing else", () => { + const text = notice("startup"); + assert.doesNotMatch(text, //); + assert.doesNotMatch(text, /Recent work/); + assert.match(text, /^\[cxc-recall\]/); +}); + +test("the recall pointer names the native tool only when config.toml enables it", () => { + const home = mkdtempSync(join(tmpdir(), "recall-wp6-home-")); + const previous = process.env.CODEX_HOME; + try { + process.env.CODEX_HOME = home; + + // No [memories] table: fail-open to the commands, which are always correct. + writeFileSync(join(home, "config.toml"), "[other]\nx = 1\n"); + assert.equal(dedicatedToolsEnabled(), false); + const off = JSON.parse(handleSessionStart("", undefined, "startup")).hookSpecificOutput + .additionalContext as string; + assert.match(off, /cxc chat search/); + assert.doesNotMatch(off, /memories\.search/); + + writeFileSync(join(home, "config.toml"), "[memories]\ndedicated_tools = true\n\n[other]\nx = 1\n"); + assert.equal(dedicatedToolsEnabled(), true); + const on = JSON.parse(handleSessionStart("", undefined, "startup")).hookSpecificOutput + .additionalContext as string; + assert.match(on, /memories\.search/); + assert.doesNotMatch(on, /cxc chat search/); + + // The key counts only inside [memories], and a missing file is not a crash. + writeFileSync(join(home, "config.toml"), "[tools]\ndedicated_tools = true\n"); + assert.equal(dedicatedToolsEnabled(), false); + rmSync(join(home, "config.toml")); + assert.equal(dedicatedToolsEnabled(), false); + } finally { + if (previous === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } +}); + +// ─── wp6: targeted recall hints (no search) ───────────────────────────────── + +test("wp6 trigger idioms widen without catching ordinary instructions", () => { + for (const p of [ + "이전에 했던 배포 스크립트 다시 보자", + "그 세션에서 정한 예산이 뭐지", + "prior work on ingest?", + "we shipped that a while ago, right?", + ]) { + assert.ok(detectRecallIntent(p), `should trigger: ${p}`); + } + for (const p of ["add a --json flag to the status command", "hook.ts를 고쳐줘", "deploy 2.49.0 now"]) { + assert.equal(detectRecallIntent(p), false, `should NOT trigger: ${p}`); + } +}); + +test("recall intent appends suggested terms and still runs no search", () => { + const text = JSON.parse( + handleUserPromptSubmit({ + hook_event_name: "UserPromptSubmit", + prompt: "그때 그 작업 hook.ts MEMORY-WRITE-GATE", + }), + ).hookSpecificOutput.additionalContext as string; + assert.match(text, /Suggested recall terms:/); + assert.match(text, /hook\.ts/); + assert.match(text, /MEMORY-WRITE-GATE/); + // The directive itself is untouched. + assert.match(text, /cxc chat search/); + assert.match(text, /cxc memory search/); + // Nothing was searched, so no search-output shape can appear. + assert.doesNotMatch(text, /memory hits/); + assert.doesNotMatch(text, /^Index:/m); + assert.doesNotMatch(text, /^---$/m); + + const plain = JSON.parse( + handleUserPromptSubmit({ hook_event_name: "UserPromptSubmit", prompt: "지난번 세션 이어서" }), + ).hookSpecificOutput.additionalContext as string; + assert.doesNotMatch(plain, /Suggested recall terms:/); +}); + +test("extractRecallTargets keeps distinctive tokens, drops idioms, and stays fast", () => { + assert.deepEqual(extractRecallTargets("지난번 2.49.0 provenance와 hook.ts, 그리고 SessionStart"), [ + "2.49.0", + "hook.ts", + "SessionStart", + ]); + assert.deepEqual(extractRecallTargets('그때 "the ingest race" 얘기했잖아'), ["the ingest race"]); + assert.deepEqual(extractRecallTargets("지난번 그 작업 이어서"), []); + assert.equal(extractRecallTargets("2.1 2.2 2.3 2.4 2.5 2.6").length, 4, "the list is capped"); + + // UserPromptSubmit runs on every prompt, so extraction is regex-only: an 8KB + // prompt must not spend a measurable slice of the 5s hook budget. + const prompt = `그때 그 작업 hook.ts 2.49.0 ${"수정하고 다시 검증하자 ".repeat(400)}`.slice(0, 8000); + const started = performance.now(); + extractRecallTargets(prompt); + const elapsed = performance.now() - started; + assert.ok(elapsed < 20, `extraction stays inside the hook budget (${elapsed}ms)`); +}); + +// ─── wp6: hook stdout/stderr/exit contract, through the real entrypoint ───── + +const recallCli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +test("every recall hook event writes empty stdout or one JSON object, and exits 0", (t) => { + if (!existsSync(recallCli)) return t.skip("dist/cli.js absent; run npm run build first"); + const home = mkdtempSync(join(tmpdir(), "recall-wp6-hookhome-")); + try { + // The operator's real ~/.codex must stay out of this: CODEXCLAW_HOME alone + // still leaves the handler reading live sessions (hook-e2e emptyCodexHome). + const env = { + ...process.env, + CODEX_HOME: home, + CODEX_SQLITE_HOME: home, + CODEXCLAW_HOME: join(home, "cxc"), + CODEXCLAW_CXC: "cxc", + }; + const run = (event: string, payload: unknown) => { + const child = spawnSync(process.execPath, [recallCli, "hook", event], { + input: payload === null ? "" : JSON.stringify(payload), + encoding: "utf8", + env, + }); + return { stdout: child.stdout ?? "", stderr: child.stderr ?? "", code: child.status ?? 1 }; + }; + const cases: Array<[string, unknown]> = [ + ["session-start", { hook_event_name: "SessionStart", cwd: home, source: "startup" }], + ["session-start", { hook_event_name: "SessionStart", cwd: home, source: "compact" }], + ["session-start", null], + [ + "user-prompt-submit", + { hook_event_name: "UserPromptSubmit", cwd: home, prompt: "지난번 hook.ts 작업 이어서" }, + ], + [ + "user-prompt-submit", + { hook_event_name: "UserPromptSubmit", cwd: home, prompt: "\u001b[31m지난번\u001b[0m 그 작업" }, + ], + ["post-compact", { hook_event_name: "PostCompact", cwd: home }], + ["not-an-event", { hook_event_name: "Nonsense", cwd: home }], + ]; + for (const [event, payload] of cases) { + const result = run(event, payload); + assert.doesNotThrow( + () => assertLegalHookResult(result), + `${event}: ${result.code} ${JSON.stringify(result.stdout.slice(0, 80))}`, + ); + } + assert.equal( + run("post-compact", { hook_event_name: "PostCompact", cwd: home }).stdout, + "", + "PostCompact still carries no envelope", + ); + assert.equal(run("not-an-event", { hook_event_name: "Nonsense" }).stdout, ""); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("assertLegalHookResult rejects the shapes that broke SessionStart upstream", () => { + assert.throws(() => assertLegalHookResult({ stdout: "", stderr: "", code: 3 }), /exited 3/); + assert.throws( + () => assertLegalHookResult({ stdout: "", stderr: "\u001b[32mdone\u001b[0m", code: 0 }), + /ANSI to stderr/, + ); + assert.throws(() => assertLegalHookResult({ stdout: "plain text", stderr: "", code: 0 })); + assert.throws( + () => assertLegalHookResult({ stdout: "[1,2]", stderr: "", code: 0 }), + /not a JSON object/, + ); + assert.doesNotThrow(() => assertLegalHookResult({ stdout: "", stderr: "note\n", code: 0 })); + assert.doesNotThrow(() => assertLegalHookResult({ stdout: '{"a":1}\n', stderr: "", code: 0 })); +}); From 7b260880fea9b32909b1898bae271e6af9f06a25 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 10 Sep 2026 11:03:48 +0900 Subject: [PATCH 2/3] docs(plan): wp6 receipt with the native-integration observations --- .../076_wp6_receipt.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 devlog/_plan/260910_memory-followup-roadmap/076_wp6_receipt.md diff --git a/devlog/_plan/260910_memory-followup-roadmap/076_wp6_receipt.md b/devlog/_plan/260910_memory-followup-roadmap/076_wp6_receipt.md new file mode 100644 index 00000000..72985da3 --- /dev/null +++ b/devlog/_plan/260910_memory-followup-roadmap/076_wp6_receipt.md @@ -0,0 +1,39 @@ +# 076 — wp6 receipt: 네이티브 통합 보강 + +날짜: 2026-09-10 (KST). 세션 01a0880e-149e-72d0-86db-53bd99bcac0e. 브랜치 `codex/memory-l1-wp6-native`(origin/dev 9384a4f5 위), 구현 커밋 `7f21ed65`. 구현은 opus-5 실행 서브에이전트, 검증·통합·관측은 메인. + +## 결론 + +웹 조사(001 §3)가 권한 네 항목이 기존 훅과 인덱스 위에서 들어갔다. SessionStart는 source별로 브리핑 형태를 나누고 끝에 회수 안내 한 줄을 붙이며(`dedicated_tools = true`면 `memories.search`, 아니면 cxc 명령), UserPromptSubmit은 recall 의도가 잡힐 때 프롬프트에서 뽑은 표적 용어를 제안하고(검색 미실행), memory search 출력은 `[age: Nd]`·`[newer: ]` 라벨을 달며(랭킹 불변), 훅 출력 계약(exit 0·stderr ANSI 없음·stdout JSON 또는 빈 문자열)이 자식 프로세스 테스트로 고정됐다. doctor는 trusted_hash drift만 있으면 WARN, 항목 없음은 FAIL이다. + +## 무엇이 바뀌었나 + +- `recall/src/hook.ts`: `handleSessionStart(…, opts?)` source 분기(startup/clear는 기존 문구, resume은 재개 문장 추가, compact는 기존 COMPACTED_BUDGET 경로) + 160자 캡 회수 안내; `dedicatedToolsEnabled()`(`codexHome()/config.toml` `[memories]` 테이블, fail-open false); `extractRecallTargets`(버전·파일명·오류 코드·CamelCase·따옴표, 최대 4) + "Suggested recall terms:"; 트리거 어휘 5개 추가(`그때` 단독 제외); `assertLegalHookResult` export. +- `recall/src/format.ts`: `[age: Nd]`(nowMs 주입 가능), 다른 파일의 더 최신 히트가 같은 특징 토큰을 가지면 `[newer: …]`. +- `cxc-ops/src/doctor.ts`·`hook-trust.ts`: drift만 WARN("trusted_hash drift (reinstall updates it; hooks still run)"), untrusted 포함 시 FAIL 유지, `file_sha256` 근거. +- 테스트 +15(recall 191, cxc-ops 199), NEW `format-freshness.test.ts`, 자식 프로세스 계약 테스트(빈 임시 CODEX_HOME/CODEX_SQLITE_HOME/CODEXCLAW_HOME). dist 4파일. 배지 2993→3007. + +계획과의 차이: compact 브리핑은 두 줄 명령 대신 단일 회수 안내로 통일; resume 문구는 "recall is available" 뒤에 덧붙임(감사 제약 1); `fileSha256`은 `HookEntry`에 위치. + +## 증거 + +- receipt `.codexclaw/evidence/01a0880e-149e-72d0-86db-53bd99bcac0e/test-receipt.json`: `/tmp/mfu-260910/check-wp6.sh` exit 0 @ 7f21ed65 — dist CLI로 SessionStart startup/resume/compact 3형태(350/429/336바이트, exit 0, ANSI 없음, 회수 안내 포함), config 픽스처 `dedicated_tools = true`에서 `memories.search` 안내, UserPromptSubmit 표적 제안(`hook.ts`·`2.49.0`), 실인덱스 `2.49.0 SLSA` 출력에 age 4·newer 3, 스위트 390 + 38 pass. +- 이 머신의 doctor는 FAIL이다: #119 bg-wake·#116 fallback 훅이 `hooks.state`에 아직 없어(untrusted) 설계대로 FAIL, drift 1건은 같은 줄에 표시. 재설치 후 `cxc hooks retrust`가 처방이다. +- 감사: grok-4.6 리뷰어 GO-WITH-FIXES(3 High + Medium + Low) → 구현 제약으로 접어 C 테스트가 확인. + +## §10 관측 항목 실측 (2026-09-10, 메인) + +| 항목 | 실측 | +|---|---| +| compaction 후 SessionStart 재점화 | 09-09 15:00 이후 rollout 526개 중 `compacted` 11건, 그 직후 6항목 안에 `[cxc-recall]`/compact 문맥 주입 7건(64%). 나머지 4건은 compaction 뒤 사용자 턴이 없었거나 SessionStart가 재발화되지 않은 것으로 보이며 원인 분리는 미완(unknown) | +| `memories.search` 호출 빈도 | 같은 창에서 최상위 function_call로 `memor*` 이름 0건. Desktop이 `exec` 코드모드 안에서 호출하면 집계에 안 잡히므로 하한값이다(이 세션 자체가 `tools.memories__read`를 exec 안에서 썼다) | +| rollout_summaries 상한 | 256개 = `max_raw_memories_for_consolidation` 상한 그대로(notes/02) | +| `msgs_fts` 바이트 | `msgs_fts_data` 319,176 블록, 1,252,722,036 바이트(약 1.25GB, 인덱스 12.1GB의 10%) | +| 훅 `--no-refresh`와 신규 워크트리 주입 | wp4로 워크트리가 같은 origin에 묶이므로 신규 슬롯에서도 메인 체크아웃 세션이 Recent work에 들어간다(074 골든). refresh 지연 자체는 별도 측정 안 함 | + +## 개선되지 않은 것 (LOOP-PESSIMIST-01) + +- 표적 회수 제안은 안내문일 뿐 검색을 대신 실행하지 않는다. 에이전트가 제안을 따르는 비율은 이 사이클이 측정하지 못한다. +- 네이티브 요약 포화(92.5%)와 rollout_summaries 256 상한은 codexclaw 코드로 못 줄인다(000 wp7 결정 항목 그대로). +- 방향이 틀렸다는 신호: 회수 안내 한 줄이 들어간 뒤에도 recall 도구 호출이 늘지 않으면 안내 위치(브리핑 끝)가 아니라 모델의 도구 선택 정책이 병목이다. + From 1e757523173e95634415ff584caa62fa92944176 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 10 Sep 2026 11:06:29 +0900 Subject: [PATCH 3/3] docs: set the tests badge to the measured total (3008) --- README.ko.md | 2 +- README.md | 2 +- README.zh.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ko.md b/README.ko.md index 57b3157a..f1edad05 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@

CI - 3,007 tests passing + 3,008 tests passing 29 skills 28 hooks Documentation diff --git a/README.md b/README.md index cdc8b6df..87da7fda 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

CI - 3,007 tests passing + 3,008 tests passing 29 skills 28 hooks Documentation diff --git a/README.zh.md b/README.zh.md index 2a524f8e..1bba86c5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,7 +13,7 @@

CI - 3,007 tests passing + 3,008 tests passing 29 skills 28 hooks Documentation