From 6304d24a1f5d8d8fc32eacb282af84dd59783b92 Mon Sep 17 00:00:00 2001 From: Joonsuh Park Date: Wed, 12 Aug 2026 19:42:29 +0900 Subject: [PATCH 1/2] fix(memory): persist verified host toolchain --- src/memory/bootstrap.ts | 15 +- src/memory/host-toolchain.ts | 457 ++++++++++++++++++++++ src/memory/runtime.ts | 22 +- src/prompt/builder.ts | 16 +- structure/infra.md | 8 +- structure/memory_architecture.md | 13 +- structure/str_func.md | 9 +- tests/unit/host-toolchain-profile.test.ts | 178 +++++++++ 8 files changed, 702 insertions(+), 16 deletions(-) create mode 100644 src/memory/host-toolchain.ts create mode 100644 tests/unit/host-toolchain-profile.test.ts diff --git a/src/memory/bootstrap.ts b/src/memory/bootstrap.ts index 8aaae5dd5..a8c554570 100644 --- a/src/memory/bootstrap.ts +++ b/src/memory/bootstrap.ts @@ -26,6 +26,11 @@ import { } from './shared.js'; import { reindexAll, reindexSingleFile } from './indexing.js'; import { launchSpec } from '../core/exec-name.js'; +import { + type HostToolchainProfile, + renderHostToolchainManagedBlock, + resolveHostToolchainProfile, +} from './host-toolchain.js'; function slug(value: string) { return value @@ -382,7 +387,7 @@ function tryExec(bin: string, args: string[]): string { } /** Scan hardware + project root info to seed profile when no legacy data exists */ -export function scanSystemProfile(): string { +export function scanSystemProfile(hostToolchain?: HostToolchainProfile): string { const lines: string[] = []; // Hardware @@ -407,6 +412,14 @@ export function scanSystemProfile(): string { const bunVer = tryExec('bun', ['--version']); if (bunVer) lines.push(`- bun: ${bunVer}`); + // Durable host capabilities are a managed profile section. Discovery is + // intentionally tool-specific and stores only absolute paths, safe version + // tokens, provenance labels, and verification results. + lines.push(''); + lines.push(renderHostToolchainManagedBlock(hostToolchain ?? resolveHostToolchainProfile(null, { + workingDir: settings["workingDir"] || process.cwd(), + }))); + // Working directory / project root const wd = settings["workingDir"] || process.cwd(); lines.push(''); diff --git a/src/memory/host-toolchain.ts b/src/memory/host-toolchain.ts new file mode 100644 index 000000000..f3b13c0ec --- /dev/null +++ b/src/memory/host-toolchain.ts @@ -0,0 +1,457 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { listCliBinaryCandidates } from '../core/cli-detect.js'; +import { launchSpec } from '../core/exec-name.js'; + +export const HOST_TOOLCHAIN_START = ''; +export const HOST_TOOLCHAIN_END = ''; +export const HOST_TOOLCHAIN_PROMPT_BUDGET = 1800; + +export const HOST_TOOL_NAMES = ['officecli', 'soffice', 'python', 'ripgrep'] as const; +export type HostToolName = typeof HOST_TOOL_NAMES[number]; +export type HostToolVerification = + | 'verified' + | 'not-found' + | 'rejected-store-stub' + | 'verification-failed' + | 'discovery-failed'; + +export interface HostToolEntry { + path: string | null; + version: string | null; + source: string; + verified_at: string; + verification: HostToolVerification; +} + +export interface HostToolchainProfile { + schema_version: 1; + verified_at: string; + tools: Record; +} + +interface ToolCandidate { + path: string; + source: string; +} + +interface CandidateDiscovery { + candidates: ToolCandidate[]; + scanError?: boolean; +} + +interface VerificationAttempt { + ok: boolean; + version?: string | null; + missing?: boolean; +} + +export interface HostToolchainContext { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + workingDir?: string; +} + +export interface HostToolchainDeps { + now?: () => Date; + discover?: (tool: HostToolName) => CandidateDiscovery; + verify?: (tool: HostToolName, candidatePath: string) => VerificationAttempt; +} + +const VERSION_ARGS: Record = { + officecli: ['--version'], + soffice: ['--version'], + python: ['--version'], + ripgrep: ['--version'], +}; + +const ENV_PATHS: Record = { + officecli: ['OFFICECLI_BIN'], + soffice: ['SOFFICE_BIN', 'LIBREOFFICE_BIN'], + python: ['PYTHON'], + ripgrep: ['CLI_JAW_RIPGREP_PATH'], +}; + +function pathNames(tool: HostToolName, platform: NodeJS.Platform): string[] { + if (tool === 'python') return platform === 'win32' ? ['python', 'python3'] : ['python3', 'python']; + if (tool === 'soffice') return ['soffice', 'libreoffice']; + if (tool === 'ripgrep') return ['rg']; + return ['officecli']; +} + +function pathApi(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 { + return platform === 'win32' ? path.win32 : path.posix; +} + +function boundedText(value: unknown, maxChars: number): string { + return String(value || '') + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxChars); +} + +function boundedPathText(value: unknown, maxChars: number): string { + return String(value || '') + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .trim() + .slice(0, maxChars); +} + +function extractSafeVersion(stdout: unknown, stderr: unknown): string | null { + const text = `${String(stdout || '')}\n${String(stderr || '')}`; + const match = text.match(/(?:^|\s)v?(\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?)(?=\s|$)/m); + return match?.[1]?.slice(0, 64) || null; +} + +function canonicalCandidate(candidate: string, platform: NodeJS.Platform, workingDir: string): string | null { + const api = pathApi(platform); + const raw = boundedPathText(candidate, 1024); + if (!raw) return null; + const absolute = api.isAbsolute(raw) ? api.normalize(raw) : api.resolve(workingDir, raw); + return api.isAbsolute(absolute) ? absolute : null; +} + +function isInsideWindowsPath(candidate: string, root: string): boolean { + const normalizedCandidate = path.win32.resolve(candidate).toLowerCase(); + const normalizedRoot = path.win32.resolve(root).toLowerCase(); + const rel = path.win32.relative(normalizedRoot, normalizedCandidate); + return rel === '' || (!!rel && !rel.startsWith('..') && !path.win32.isAbsolute(rel)); +} + +/** WindowsApps python.exe/python3.exe are App Execution Alias redirectors, not interpreters. */ +export function isWindowsStorePythonRedirector( + candidate: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (platform !== 'win32') return false; + const name = path.win32.basename(candidate).toLowerCase(); + if (name !== 'python.exe' && name !== 'python3.exe' && name !== 'python') return false; + const roots = [ + env['LOCALAPPDATA'] ? path.win32.join(env['LOCALAPPDATA'], 'Microsoft', 'WindowsApps') : '', + env['USERPROFILE'] ? path.win32.join(env['USERPROFILE'], 'AppData', 'Local', 'Microsoft', 'WindowsApps') : '', + ].filter(Boolean); + return roots.some(root => isInsideWindowsPath(candidate, root)); +} + +function addCandidate(out: ToolCandidate[], candidatePath: string | undefined, source: string): void { + if (!candidatePath?.trim()) return; + out.push({ path: candidatePath.trim(), source }); +} + +function listImmediateDirectories(root: string, limit = 20): string[] { + try { + return fs.readdirSync(root, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .slice(0, limit) + .map(entry => path.join(root, entry.name)); + } catch { + return []; + } +} + +function bundledRipgrepCandidates(homeDir: string, platform: NodeJS.Platform): string[] { + const binary = platform === 'win32' ? 'rg.exe' : 'rg'; + const out: string[] = []; + const roots = [ + path.join(homeDir, '.bun', 'install', 'global', 'node_modules', '@openai'), + path.join(homeDir, '.npm-global', 'lib', 'node_modules', '@openai'), + ]; + for (const root of roots) { + for (const pkg of listImmediateDirectories(root)) { + if (!path.basename(pkg).startsWith('codex-')) continue; + for (const target of listImmediateDirectories(path.join(pkg, 'vendor'), 10)) { + out.push(path.join(target, 'path', binary)); + } + } + } + return out; +} + +function knownCandidates( + tool: HostToolName, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + homeDir: string, + workingDir: string, +): string[] { + const executable = platform === 'win32' ? '.exe' : ''; + const out = [ + path.join(workingDir, 'bin', tool === 'ripgrep' ? `rg${executable}` : `${tool}${executable}`), + path.join(homeDir, 'bin', tool === 'ripgrep' ? `rg${executable}` : `${tool}${executable}`), + ]; + + if (platform === 'win32') { + const programFiles = [env['ProgramFiles'], env['ProgramFiles(x86)']].filter(Boolean) as string[]; + if (tool === 'soffice') { + out.push(...programFiles.map(root => path.win32.join(root, 'LibreOffice', 'program', 'soffice.exe'))); + if (env['LOCALAPPDATA']) { + out.push(path.win32.join(env['LOCALAPPDATA'], 'Programs', 'LibreOffice', 'program', 'soffice.exe')); + } + } + if (tool === 'python' && env['LOCALAPPDATA']) { + const pythonRoot = path.win32.join(env['LOCALAPPDATA'], 'Programs', 'Python'); + out.push(...listImmediateDirectories(pythonRoot).map(dir => path.join(dir, 'python.exe'))); + } + } else if (platform === 'darwin') { + if (tool === 'soffice') out.push('/Applications/LibreOffice.app/Contents/MacOS/soffice'); + } else { + if (tool === 'soffice') out.push('/usr/bin/soffice', '/usr/local/bin/soffice', '/snap/bin/libreoffice'); + if (tool === 'python') out.push('/usr/bin/python3', '/usr/local/bin/python3'); + if (tool === 'ripgrep') out.push('/usr/bin/rg', '/usr/local/bin/rg'); + } + if (tool === 'ripgrep') out.push(...bundledRipgrepCandidates(homeDir, platform)); + return out; +} + +function defaultDiscover( + tool: HostToolName, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + homeDir: string, + workingDir: string, +): CandidateDiscovery { + const candidates: ToolCandidate[] = []; + for (const envName of ENV_PATHS[tool]) addCandidate(candidates, env[envName], `env:${envName}`); + + let scanError = false; + for (const name of pathNames(tool, platform)) { + const scan = listCliBinaryCandidates(name, env['PATH'] || env['Path'] || env['path'] || ''); + scanError ||= !!scan.scanError; + for (const candidate of scan.candidates) addCandidate(candidates, candidate.path, 'PATH'); + } + for (const candidate of knownCandidates(tool, platform, env, homeDir, workingDir)) { + addCandidate(candidates, candidate, 'known-location'); + } + return { candidates, ...(scanError ? { scanError: true } : {}) }; +} + +function defaultVerify( + tool: HostToolName, + candidatePath: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): VerificationAttempt { + if (tool === 'python' && isWindowsStorePythonRedirector(candidatePath, platform, env)) return { ok: false }; + try { + const stat = fs.statSync(candidatePath); + if (!stat.isFile()) return { ok: false }; + if (platform !== 'win32') fs.accessSync(candidatePath, fs.constants.X_OK); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { ok: false, missing: code === 'ENOENT' || code === 'ENOTDIR' }; + } + + try { + const spec = launchSpec(candidatePath, VERSION_ARGS[tool]); + const result = spawnSync(spec.file, spec.args, { + encoding: 'utf8', + timeout: 1800, + windowsHide: true, + env, + }); + if (result.error || result.status !== 0) return { ok: false }; + return { ok: true, version: extractSafeVersion(result.stdout, result.stderr) }; + } catch { + return { ok: false }; + } +} + +function isKnownVerification(value: unknown): value is HostToolVerification { + return [ + 'verified', + 'not-found', + 'rejected-store-stub', + 'verification-failed', + 'discovery-failed', + ].includes(String(value)); +} + +function validatedEntry(value: unknown): HostToolEntry | null { + if (!value || typeof value !== 'object') return null; + const raw = value as Partial; + if (!isKnownVerification(raw.verification)) return null; + const entryPath = raw.path === null ? null : boundedPathText(raw.path, 1024); + if (raw.path !== null && !entryPath) return null; + return { + path: entryPath, + version: raw.version === null ? null : boundedText(raw.version, 64) || null, + source: boundedText(raw.source, 80) || 'unknown', + verified_at: boundedText(raw.verified_at, 64), + verification: raw.verification, + }; +} + +export function parseHostToolchainProfile(content: string): HostToolchainProfile | null { + const start = content.indexOf(HOST_TOOLCHAIN_START); + const end = content.indexOf(HOST_TOOLCHAIN_END, start + HOST_TOOLCHAIN_START.length); + if (start < 0 || end < 0) return null; + const block = content.slice(start + HOST_TOOLCHAIN_START.length, end); + const json = /```json\s*([\s\S]*?)```/i.exec(block)?.[1]; + if (!json) return null; + try { + const raw = JSON.parse(json) as Partial; + const tools = {} as Record; + for (const tool of HOST_TOOL_NAMES) { + const entry = validatedEntry(raw.tools?.[tool]); + if (!entry) return null; + tools[tool] = entry; + } + return { + schema_version: 1, + verified_at: boundedText(raw.verified_at, 64), + tools, + }; + } catch { + return null; + } +} + +export function renderHostToolchainManagedBlock(profile: HostToolchainProfile): string { + return `${HOST_TOOLCHAIN_START}\n## Host Toolchain\n\n` + + `\`\`\`json\n${JSON.stringify(profile, null, 2)}\n\`\`\`\n${HOST_TOOLCHAIN_END}`; +} + +export function mergeHostToolchainProfileContent(existing: string, profile: HostToolchainProfile): string { + const block = renderHostToolchainManagedBlock(profile); + const start = existing.indexOf(HOST_TOOLCHAIN_START); + const end = existing.indexOf(HOST_TOOLCHAIN_END, start + HOST_TOOLCHAIN_START.length); + if (start >= 0 && end >= start) { + return existing.slice(0, start) + block + existing.slice(end + HOST_TOOLCHAIN_END.length); + } + return `${existing.trimEnd()}\n\n${block}\n`; +} + +export function stripHostToolchainManagedBlock(content: string): string { + const start = content.indexOf(HOST_TOOLCHAIN_START); + const end = content.indexOf(HOST_TOOLCHAIN_END, start + HOST_TOOLCHAIN_START.length); + if (start < 0 || end < 0) return content; + return (content.slice(0, start) + content.slice(end + HOST_TOOLCHAIN_END.length)) + .replace(/\n{3,}/g, '\n\n'); +} + +export function resolveHostToolchainProfile( + previous: HostToolchainProfile | null, + context: HostToolchainContext = {}, + deps: HostToolchainDeps = {}, +): HostToolchainProfile { + const platform = context.platform ?? process.platform; + const env = context.env ?? process.env; + const homeDir = context.homeDir ?? os.homedir(); + const workingDir = context.workingDir ?? process.cwd(); + const now = (deps.now ?? (() => new Date()))().toISOString(); + const discover = deps.discover + ?? ((tool: HostToolName) => defaultDiscover(tool, platform, env, homeDir, workingDir)); + const verify = deps.verify + ?? ((tool: HostToolName, candidatePath: string) => defaultVerify(tool, candidatePath, platform, env)); + const tools = {} as Record; + + for (const tool of HOST_TOOL_NAMES) { + const cached = previous?.tools[tool]; + if (cached?.verification === 'verified' && cached.path) { + const cachedPath = canonicalCandidate(cached.path, platform, workingDir); + if (cachedPath && !(tool === 'python' && isWindowsStorePythonRedirector(cachedPath, platform, env))) { + const checked = verify(tool, cachedPath); + if (checked.ok) { + tools[tool] = { + path: cachedPath, + version: checked.version ?? cached.version, + source: cached.source, + verified_at: now, + verification: 'verified', + }; + continue; + } + } + } + + const found = discover(tool); + let sawStoreStub = false; + let sawVerificationFailure = false; + const seen = new Set(); + for (const candidate of found.candidates) { + const candidatePath = canonicalCandidate(candidate.path, platform, workingDir); + if (!candidatePath) continue; + const key = platform === 'win32' ? candidatePath.toLowerCase() : candidatePath; + if (seen.has(key)) continue; + seen.add(key); + if (tool === 'python' && isWindowsStorePythonRedirector(candidatePath, platform, env)) { + sawStoreStub = true; + continue; + } + const checked = verify(tool, candidatePath); + if (!checked.ok) { + sawVerificationFailure ||= checked.missing !== true; + continue; + } + tools[tool] = { + path: candidatePath, + version: checked.version ?? null, + source: boundedText(candidate.source, 80) || 'discovery', + verified_at: now, + verification: 'verified', + }; + break; + } + if (!tools[tool]) { + tools[tool] = { + path: null, + version: null, + source: 'discovery', + verified_at: now, + verification: sawStoreStub + ? 'rejected-store-stub' + : sawVerificationFailure + ? 'verification-failed' + : found.scanError + ? 'discovery-failed' + : 'not-found', + }; + } + } + + return { schema_version: 1, verified_at: now, tools }; +} + +export function refreshHostToolchainProfileFile( + profilePath: string, + context: HostToolchainContext = {}, + deps: HostToolchainDeps = {}, +): HostToolchainProfile { + const existing = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf8') : ''; + const profile = resolveHostToolchainProfile(parseHostToolchainProfile(existing), context, deps); + const next = mergeHostToolchainProfileContent(existing, profile); + fs.mkdirSync(path.dirname(profilePath), { recursive: true }); + if (next !== existing) fs.writeFileSync(profilePath, next, 'utf8'); + return profile; +} + +function promptPath(value: string): string { + return `\`${boundedPathText(value, 420).replaceAll('`', '\\`')}\``; +} + +export function renderHostToolchainPromptBlock( + profile: HostToolchainProfile | null, + maxChars = HOST_TOOLCHAIN_PROMPT_BUDGET, +): string { + if (!profile) return ''; + const lines = [ + '## Host toolchain', + '- Use a `verified` absolute path directly; skip discovery first. If fast invocation fails, rediscover and refresh the profile.', + ]; + for (const tool of HOST_TOOL_NAMES) { + const entry = profile.tools[tool]; + if (entry.verification === 'verified' && entry.path) { + const version = entry.version ? `; version ${boundedText(entry.version, 64)}` : ''; + lines.push(`- ${tool}: ${promptPath(entry.path)} (verified${version}; source ${boundedText(entry.source, 80)}; ${boundedText(entry.verified_at, 64)})`); + } else { + lines.push(`- ${tool}: unavailable (${entry.verification}; checked ${boundedText(entry.verified_at, 64)})`); + } + } + const rendered = lines.join('\n'); + return rendered.length <= maxChars ? rendered : `${rendered.slice(0, Math.max(0, maxChars - 16))}\n...(truncated)`; +} diff --git a/src/memory/runtime.ts b/src/memory/runtime.ts index b9e93a9ae..3c1406ae7 100644 --- a/src/memory/runtime.ts +++ b/src/memory/runtime.ts @@ -73,8 +73,9 @@ import { } from './shared.js'; import { getLastExpansionTerms } from './keyword-expand.js'; -import { reindexAll, searchIndex, formatHits, reindexIndexCounts, reindexIntegratedMemoryFile } from './indexing.js'; +import { reindexAll, reindexSingleFile, searchIndex, formatHits, reindexIndexCounts, reindexIntegratedMemoryFile } from './indexing.js'; import { ensureAdvancedMemoryStructure, bootstrapAdvancedMemory, syncCoreProfile, scanSystemProfile } from './bootstrap.js'; +import { refreshHostToolchainProfileFile, stripHostToolchainManagedBlock } from './host-toolchain.js'; import { reflectRecentEpisodes, type ReflectionResult } from './reflect.js'; import { log } from '../core/logger.js'; @@ -130,7 +131,7 @@ export function loadAdvancedProfileSummary(maxChars = 800) { const file = join(getAdvancedMemoryDir(), 'profile.md'); if (!fs.existsSync(file)) return ''; const { body } = parseMarkdownFileLight(safeReadFile(file)); - const trimmed = body.trim(); + const trimmed = stripHostToolchainManagedBlock(body).trim(); if (!trimmed) return ''; return trimmed.length > maxChars ? trimmed.slice(0, maxChars) + '\n...(truncated)' : trimmed; } @@ -279,13 +280,25 @@ function getLegacyClaudeMemoryDir() { return join(os.homedir(), '.claude', 'projects', hash, 'memory'); } +function refreshHostToolchain(root: string) { + const profilePath = join(root, 'profile.md'); + const profile = refreshHostToolchainProfileFile(profilePath, { + workingDir: expandHomePath(settings["workingDir"] || process.cwd(), os.homedir()), + }); + reindexSingleFile(root, profilePath); + return profile; +} + export function ensureIntegratedMemoryReady() { const created = ensureAdvancedMemoryStructure(); syncCoreProfile(getAdvancedMemoryDir(), { force: false }); const status = getAdvancedMemoryStatus(); const meta = readMeta(); const alreadyBootstrapped = meta?.bootstrapStatus === 'done'; - if (status.indexState === 'ready' && alreadyBootstrapped) return { created, bootstrapped: false, status }; + if (status.indexState === 'ready' && alreadyBootstrapped) { + refreshHostToolchain(getAdvancedMemoryDir()); + return { created, bootstrapped: false, status: getAdvancedMemoryStatus() }; + } const hasLegacy = fs.existsSync(join(JAW_HOME, 'memory', 'MEMORY.md')) || fs.existsSync(join(JAW_HOME, 'memory', 'daily')) || fs.existsSync(getLegacyClaudeMemoryDir()) @@ -312,6 +325,8 @@ export function ensureIntegratedMemoryReady() { }); writeText(profilePath, fm + `# Profile\n\n${systemInfo}\n`); log.info('[jaw:bootstrap] seeded profile from system scan (no legacy data found)'); + } else { + refreshHostToolchain(root); } const result = reindexAll(root); writeMeta({ bootstrapStatus: 'done', lastBootstrapAt: new Date().toISOString() }); @@ -323,6 +338,7 @@ export function ensureIntegratedMemoryReady() { importKv: true, importClaudeSession: true, }); + refreshHostToolchain(getAdvancedMemoryDir()); return { created, bootstrapped: true, status: getAdvancedMemoryStatus(), result }; } diff --git a/src/prompt/builder.ts b/src/prompt/builder.ts index a6962ba29..1c55dfa1e 100644 --- a/src/prompt/builder.ts +++ b/src/prompt/builder.ts @@ -10,7 +10,8 @@ import { getActiveChatSession, listChatSessions } from '../core/chat-sessions.js import { currentSessionScope } from '../core/session-context.js'; import { memoryFlushCounter } from '../agent/spawn.js'; import { describeHeartbeatSchedule, normalizeHeartbeatSchedule } from '../memory/heartbeat-schedule.js'; -import { buildTaskSnapshot, hasSoulFile, loadProfileSummary, loadSoulSummary } from '../memory/runtime.js'; +import { buildTaskSnapshot, getAdvancedMemoryDir, hasSoulFile, loadProfileSummary, loadSoulSummary } from '../memory/runtime.js'; +import { parseHostToolchainProfile, renderHostToolchainPromptBlock } from '../memory/host-toolchain.js'; import { buildMemoryInjection } from '../memory/injection.js'; import { loadAndRender, loadTemplate, renderTemplate, parseWorkerContexts, clearTemplateCache } from './template-loader.js'; import { findStaticEmployee } from '../core/employees.js'; @@ -668,6 +669,17 @@ function loadDiskSoul(): string { } } +function loadDiskHostToolchain(): string { + try { + const profilePath = join(getAdvancedMemoryDir(), 'profile.md'); + if (!fs.existsSync(profilePath)) return ''; + return renderHostToolchainPromptBlock(parseHostToolchainProfile(fs.readFileSync(profilePath, 'utf8'))); + } catch (error) { + log.warn('[memory] disk host toolchain load failed:', (error as Error).message); + return ''; + } +} + function getCurrentSessionIdentityLine(): string { const sessionId = currentSessionScope()?.chatSessionId ?? getActiveChatSession(); const session = listChatSessions().find(row => row.id === sessionId); @@ -716,6 +728,8 @@ export function getSystemPrompt(opts: { currentPrompt?: string; forDisk?: boolea prompt += `- JAW_HOME: ${diskPromptPath(JAW_HOME)}\n`; prompt += `- Working directory: ${diskPromptPath(diskWorkingDir)}\n`; prompt += '- These resolved instance paths override placeholder paths in older/custom prompt files.\n'; + const hostToolchain = loadDiskHostToolchain(); + if (hostToolchain) prompt += `\n\n---\n${hostToolchain}\n`; const soul = loadDiskSoul(); try { const profile = loadProfileSummary(600); diff --git a/structure/infra.md b/structure/infra.md index c7b8bc640..5673d92d0 100644 --- a/structure/infra.md +++ b/structure/infra.md @@ -648,9 +648,9 @@ Channel narrowing helpers and slash-command registration. --- -## src/memory/ — persistent + advanced memory runtime (13 files, 3155L) +## src/memory/ — persistent + advanced memory runtime (16 files, 4267L) -`memory.ts`, `runtime.ts`, `shared.ts`, `heartbeat.ts`, `heartbeat-schedule.ts`, `indexing.ts`, `keyword-expand.ts`, `bootstrap.ts`, `injection.ts`, `identity.ts`, `reflect.ts`, `advanced.ts`, `worklog.ts`. +`memory.ts`, `runtime.ts`, `shared.ts`, `heartbeat.ts`, `heartbeat-report.ts`, `heartbeat-schedule.ts`, `indexing.ts`, `keyword-expand.ts`, `bootstrap.ts`, `host-toolchain.ts`, `injection.ts`, `identity.ts`, `reflect.ts`, `synonyms.ts`, `advanced.ts`, `worklog.ts`. ### memory.ts (154L) @@ -677,9 +677,9 @@ Advanced memory runtime의 entry point. FTS5 인덱스, search routing, task sna 주기 작업과 스케줄 파싱/실행을 담당한다. 현재 소스 오브 트루스는 `~/.cli-jaw/heartbeat.json`이며, schedule은 `every`/`cron` + `timeZone`을 지원한다. PABCD 활성, heartbeat 중첩, main agent busy 상태에서는 `pendingJobs` 큐로 밀어두고, user message queue가 먼저 비워진 뒤 heartbeat pending을 drain한다. 프롬프트 앞에는 memory search 지시를 자동 주입한다. (#252) job별 opt-in `runner: main|employee|script`(기본 main; employee는 `claimWorker`+`runSingleAgent`, busy 시 `skipped: employee busy` 경고 리포트; script는 argv `execFile` no-shell)와 `reportPolicy: always|anomaly_only|silent` + 구조화 리포트 계약(`heartbeat-report.ts`: status/changed/record_required/user_visible/summary/evidence/next_action)을 지원한다. `[SILENT]`/quiet marker는 정책과 무관하게 우선하며, silent 정책 anchor는 `delivered_at NULL` + "recorded (not sent)" 주입 문구로 구분된다. main runner는 `orchestrateAndCollectData`의 `agyPlannerOnly` 신호(#251)에 1회 한정 재시도한다. PUT `/api/heartbeat`는 UI가 모르는 runner 필드를 job id 기준 merge-by-id로 보존한다. -### indexing.ts / keyword-expand.ts / bootstrap.ts +### indexing.ts / keyword-expand.ts / bootstrap.ts / host-toolchain.ts -index 준비, BM25/expansion, bootstrapping/import 흐름을 담당한다. +index 준비, BM25/expansion, bootstrapping/import 흐름을 담당한다. `host-toolchain.ts`는 검증한 호스트 도구 경로를 marker-owned profile 구역에 보존하고 bounded disk-prompt 요약을 만든다. ### worklog.ts / shared.ts diff --git a/structure/memory_architecture.md b/structure/memory_architecture.md index ea9841f7a..e35b17ca0 100644 --- a/structure/memory_architecture.md +++ b/structure/memory_architecture.md @@ -8,8 +8,8 @@ aliases: [CLI-JAW Memory Architecture, advanced memory runtime, memory architect # Memory Architecture — 통합 메모리 시스템 -> 최종 갱신: 2026-06-03 -> 소스: `src/memory/runtime.ts` 375L (사실상 facade), `src/memory/shared.ts` 265L, `src/memory/bootstrap.ts` 524L, `src/memory/indexing.ts` 569L, `src/memory/keyword-expand.ts` 98L, `src/memory/synonyms.ts` 60L, `src/memory/reflect.ts` 379L, `src/memory/identity.ts` 86L, `src/memory/injection.ts` 69L, `src/memory/memory.ts` 154L, `src/memory/worklog.ts` 200L, `src/memory/heartbeat.ts` 209L, `src/memory/heartbeat-schedule.ts` 410L, `src/memory/advanced.ts` 1L (re-export shim), `src/agent/memory-flush-controller.ts` 185L, `src/agent/spawn.ts` 2011L, `src/prompt/builder.ts` 1040L, `src/orchestrator/pipeline.ts` 538L, `src/routes/memory.ts`, `src/routes/jaw-memory.ts`, `src/cli/command-context.ts`, `src/cli/handlers-runtime.ts` +> 최종 갱신: 2026-08-12 +> 소스: `src/memory/runtime.ts` 396L (사실상 facade), `src/memory/shared.ts` 266L, `src/memory/bootstrap.ts` 597L, `src/memory/host-toolchain.ts` 457L, `src/memory/indexing.ts` 721L, `src/memory/keyword-expand.ts` 98L, `src/memory/synonyms.ts` 60L, `src/memory/reflect.ts` 380L, `src/memory/identity.ts` 87L, `src/memory/injection.ts` 69L, `src/memory/memory.ts` 165L, `src/memory/worklog.ts` 200L, `src/memory/heartbeat.ts` 311L, `src/memory/heartbeat-schedule.ts` 410L, `src/memory/advanced.ts` 1L (re-export shim), `src/agent/memory-flush-controller.ts` 185L, `src/agent/spawn.ts` 2011L, `src/prompt/builder.ts` 1159L, `src/orchestrator/pipeline.ts` 538L, `src/routes/memory.ts`, `src/routes/jaw-memory.ts`, `src/cli/command-context.ts`, `src/cli/handlers-runtime.ts` > 임베딩: `src/manager/memory/embedding/` — `provider.ts`, `vec-store.ts`, `sync.ts`, `state-machine.ts`, `hybrid-search.ts`, `index.ts` + `src/manager/routes/dashboard-memory.ts` --- @@ -164,7 +164,14 @@ Prefers ES Module only, no CommonJS. - 즉, raw conversation dump가 아니라 섹션 머리말 중심의 요약 주입이다. - `forDisk: true` 경로는 legacy fallback을 먼저 조립한 뒤, `shared/soul.md`를 인덱스 상태와 무관하게 최대 6000자로 읽고, 준비된 경우 advanced `profile.md` 요약(600자)과 `buildTaskSnapshot('current session context', 1500)` 결과를 `## Disk Memory Context` 아래에 추가한다. 6000자 초과, 빈 파일, 읽기 실패는 warning 로그로 드러난다. disk prompt에는 실제 `JAW_HOME`과 `settings.workingDir`도 함께 기록된다. 즉 `B.md`/workspace `AGENTS.md`는 런타임 boss prompt와 동일하지는 않지만, 더 이상 legacy-only 스냅샷은 아니다. -### 3-E: Core Memory +### 3-E: Durable Host Toolchain + +- `src/memory/host-toolchain.ts` owns a bounded managed block in `memory/structured/profile.md`; it records only absolute paths, safe version tokens, source labels, `verified_at`, and a verification result for `officecli`, `soffice`, Python, and ripgrep. +- Startup fast-verifies a cached absolute path directly. A failed or stale cache entry falls back to bounded tool-specific discovery; Windows Store Python redirectors are recorded as rejected rather than usable interpreters. +- The managed markers replace only cli-jaw-owned content, preserving curated profile sections and the independent `cli-jaw:core-memory` block (#262). +- Disk prompt generation strips the raw managed JSON from profile summaries and emits one human-readable `## Host toolchain` block capped at 1800 characters. Verified paths are used before discovery. + +### 3-F: Core Memory | 항목 | 값 | |------|-----| diff --git a/structure/str_func.md b/structure/str_func.md index 9e8484403..0d9042124 100644 --- a/structure/str_func.md +++ b/structure/str_func.md @@ -153,7 +153,7 @@ cli-jaw/ │ │ ├── sanitize.ts ← Interview tracker strip helper + stripPhaseAttestation re-export (79L) │ │ └── attestation.ts ← Phase60 PABCD evidence gate: parse/validate (tagged block + --attest object) + form-only checkAttestationGate (gates P→A/A→B/B→C/C→D; narrative did required, C→D needs checkOutput) + stripPhaseAttestation + warn-only no-state narration detector (217L) │ ├── prompt/ ← 프롬프트 조립 (4 files + templates/ 10 files) -│ │ ├── builder.ts ← A-1/A-2 + 스킬 + 직원 프롬프트 v2 + promptCache (4-segment key: emp:role:phase:workingDir) + on-demand dev skill path contract + advanced memory mode branch + bounded disk soul/instance context + task snapshot injection + dashboard-connector anchor preserve + Phase60 inline PABCD guide --attest evidence note (1145L) +│ │ ├── builder.ts ← A-1/A-2 + 스킬 + 직원 프롬프트 v2 + promptCache (4-segment key: emp:role:phase:workingDir) + on-demand dev skill path contract + advanced memory mode branch + bounded disk soul/instance context + task snapshot injection + dashboard-connector anchor preserve + Phase60 inline PABCD guide --attest evidence note (1159L) │ │ ├── runtime-context.ts ← 런타임 컨텍스트 주입 (RuntimeContextEntry, loadEntries, getActiveEntries, addEntry, removeEntry, clearAll, buildInjectionBlock) (80L) │ │ ├── soul-bootstrap-prompt.ts ← LLM 기반 soul.md 개인화 부트스트랩 프롬프트 빌더 (52L) │ │ ├── template-loader.ts ← 프롬프트 템플릿 로더 (50L) @@ -207,18 +207,19 @@ cli-jaw/ │ │ ├── coordinator.ts ← ready-only 예산 배분 + cursor 상태 기계 + 부분 실패 인벤토리 (160L) ✨ │ │ ├── providers/chat.ts ← chat 어댑터 (FTS/trigram/LIKE 폴백, 실제 session_id provenance) (124L) ✨ │ │ └── providers/memory.ts ← memory 어댑터 (고정 64-candidate universe, session provenance 표시, sessionFilter 미적용 경고) (55L) ✨ -│ ├── memory/ ← 데이터 영속화 + advanced memory runtime (14 files) +│ ├── memory/ ← 데이터 영속화 + advanced memory runtime (15 files) │ │ ├── advanced.ts ← Advanced Memory re-export stub (1L) -│ │ ├── bootstrap.ts ← legacy memory/bootstrap import + structured root 초기화 (584L) +│ │ ├── bootstrap.ts ← legacy memory/bootstrap import + structured root 초기화 (597L) │ │ ├── heartbeat.ts ← Heartbeat 잡 스케줄 + cron/every timer orchestration + minute-slot dedupe + fs.watch (311L) │ │ ├── heartbeat-schedule.ts ← Heartbeat schedule normalize + cron validate/match + timezone validate + immediate cron loop helper (410L) +│ │ ├── host-toolchain.ts ← durable verified host paths + profile managed block + bounded AGENTS summary (457L) │ │ ├── identity.ts ← `shared/soul.md` 관리 + soul runtime helper (87L) │ │ ├── indexing.ts ← FTS5/BM25 reindex + indexed file/chunk 상태 집계 (721L) │ │ ├── injection.ts ← memory injection policy + advanced/basic search routing (69L) │ │ ├── keyword-expand.ts ← search keyword expansion + provider config normalize (98L) │ │ ├── memory.ts ← Persistent Memory grep 기반 (165L) │ │ ├── reflect.ts ← episode → shared/procedures reflection + promoted fact 정리 (380L) -│ │ ├── runtime.ts ← Advanced Memory 런타임: bootstrap/import/FTS5 인덱스/BM25 검색/task snapshot/delta reindex (380L) +│ │ ├── runtime.ts ← Advanced Memory 런타임: bootstrap/import/FTS5 인덱스/BM25 검색/task snapshot/delta reindex (396L) │ │ ├── shared.ts ← file/meta/frontmatter 공용 헬퍼 (266L) │ │ ├── synonyms.ts ← keyword synonym expansion helper (60L) ✨ │ │ └── worklog.ts ← Worklog CRUD + phase matrix (201L) diff --git a/tests/unit/host-toolchain-profile.test.ts b/tests/unit/host-toolchain-profile.test.ts new file mode 100644 index 000000000..39569da7d --- /dev/null +++ b/tests/unit/host-toolchain-profile.test.ts @@ -0,0 +1,178 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { + HOST_TOOL_NAMES, + HOST_TOOLCHAIN_PROMPT_BUDGET, + type HostToolName, + type HostToolchainProfile, + isWindowsStorePythonRedirector, + mergeHostToolchainProfileContent, + parseHostToolchainProfile, + renderHostToolchainManagedBlock, + renderHostToolchainPromptBlock, + resolveHostToolchainProfile, +} from '../../src/memory/host-toolchain.ts'; +import { getAdvancedMemoryDir } from '../../src/memory/shared.ts'; +import { scanSystemProfile } from '../../src/memory/bootstrap.ts'; +import { getSystemPrompt } from '../../src/prompt/builder.ts'; +import { settings } from '../../src/core/config.ts'; + +const FIRST_SCAN_AT = new Date('2026-08-12T01:00:00.000Z'); +const RESTART_AT = new Date('2026-08-12T02:00:00.000Z'); + +function verifiedProfile(prefix = '/cached'): HostToolchainProfile { + const tools = {} as HostToolchainProfile['tools']; + for (const tool of HOST_TOOL_NAMES) { + tools[tool] = { + path: `${prefix}/${tool}`, + version: '1.2.3', + source: 'PATH', + verified_at: FIRST_SCAN_AT.toISOString(), + verification: 'verified', + }; + } + return { + schema_version: 1, + verified_at: FIRST_SCAN_AT.toISOString(), + tools, + }; +} + +test('first scan discovers and records verified absolute tool paths', () => { + const discovered: HostToolName[] = []; + const profile = resolveHostToolchainProfile(null, { platform: 'linux', workingDir: '/work' }, { + now: () => FIRST_SCAN_AT, + discover(tool) { + discovered.push(tool); + return { candidates: [{ path: `/opt/tools/${tool}`, source: 'PATH' }] }; + }, + verify: () => ({ ok: true, version: '9.8.7' }), + }); + + assert.deepEqual(discovered, [...HOST_TOOL_NAMES]); + for (const tool of HOST_TOOL_NAMES) { + assert.equal(profile.tools[tool].path, `/opt/tools/${tool}`); + assert.equal(profile.tools[tool].verification, 'verified'); + assert.equal(profile.tools[tool].version, '9.8.7'); + assert.equal(profile.tools[tool].verified_at, FIRST_SCAN_AT.toISOString()); + } +}); + +test('restart fast-verifies cached paths without rediscovery', () => { + const previous = verifiedProfile(); + const verified: string[] = []; + const profile = resolveHostToolchainProfile(previous, { platform: 'linux', workingDir: '/work' }, { + now: () => RESTART_AT, + discover() { + assert.fail('verified cached paths must skip discovery'); + }, + verify(tool, candidatePath) { + verified.push(`${tool}:${candidatePath}`); + return { ok: true, version: '1.2.4' }; + }, + }); + + assert.equal(verified.length, HOST_TOOL_NAMES.length); + assert.equal(profile.tools.officecli.path, '/cached/officecli'); + assert.equal(profile.tools.officecli.source, 'PATH'); + assert.equal(profile.tools.officecli.version, '1.2.4'); + assert.equal(profile.tools.officecli.verified_at, RESTART_AT.toISOString()); +}); + +test('stale cached path falls back to discovery and replaces only that entry', () => { + const previous = verifiedProfile(); + let officeDiscovery = 0; + const profile = resolveHostToolchainProfile(previous, { platform: 'linux', workingDir: '/work' }, { + now: () => RESTART_AT, + discover(tool) { + assert.equal(tool, 'officecli'); + officeDiscovery++; + return { candidates: [{ path: '/new/officecli', source: 'known-location' }] }; + }, + verify(_tool, candidatePath) { + return { ok: candidatePath !== '/cached/officecli', version: '2.0.0' }; + }, + }); + + assert.equal(officeDiscovery, 1); + assert.equal(profile.tools.officecli.path, '/new/officecli'); + assert.equal(profile.tools.officecli.source, 'known-location'); + assert.equal(profile.tools.soffice.path, '/cached/soffice'); +}); + +test('Windows Store Python redirector is excluded and never verified as an interpreter', () => { + const localAppData = 'C:\\Users\\operator\\AppData\\Local'; + const stub = `${localAppData}\\Microsoft\\WindowsApps\\python.exe`; + let pythonVerifyCalls = 0; + + assert.equal(isWindowsStorePythonRedirector(stub, 'win32', { LOCALAPPDATA: localAppData }), true); + const profile = resolveHostToolchainProfile(null, { + platform: 'win32', + workingDir: 'C:\\work', + env: { LOCALAPPDATA: localAppData }, + }, { + now: () => FIRST_SCAN_AT, + discover(tool) { + return { candidates: tool === 'python' ? [{ path: stub, source: 'PATH' }] : [] }; + }, + verify(tool) { + if (tool === 'python') pythonVerifyCalls++; + return { ok: false }; + }, + }); + + assert.equal(pythonVerifyCalls, 0); + assert.equal(profile.tools.python.path, null); + assert.equal(profile.tools.python.verification, 'rejected-store-stub'); +}); + +test('managed toolchain updates preserve curated and core-memory profile content', () => { + const original = `---\nsource: curated\n---\n# Profile\n\n## Personal Context\n- Keep this\n\n\n- Also keep core sync\n\n`; + const first = mergeHostToolchainProfileContent(original, verifiedProfile('/first')); + const second = mergeHostToolchainProfileContent(first, verifiedProfile('/second')); + + assert.match(second, /## Personal Context\n- Keep this/); + assert.match(second, /cli-jaw:core-memory:start/); + assert.doesNotMatch(second, /\/first\/officecli/); + assert.match(second, /\/second\/officecli/); + assert.equal((second.match(/cli-jaw:host-toolchain:start/g) || []).length, 1); + assert.equal(parseHostToolchainProfile(second)?.tools.ripgrep.path, '/second/ripgrep'); +}); + +test('system scan seeds its profile with the durable managed toolchain section', () => { + const previousWorkingDir = settings["workingDir"]; + settings["workingDir"] = process.cwd(); + let scanned: string; + try { + scanned = scanSystemProfile(verifiedProfile('/scan')); + } finally { + settings["workingDir"] = previousWorkingDir; + } + const parsed = parseHostToolchainProfile(scanned); + + assert.match(scanned, /## System/); + assert.match(scanned, /## Runtime/); + assert.equal(parsed?.tools.officecli.path, '/scan/officecli'); +}); + +test('generated disk AGENTS has one short human-readable Host toolchain block', () => { + const huge = verifiedProfile(`/${'x'.repeat(900)}`); + const bounded = renderHostToolchainPromptBlock(huge); + assert.ok(bounded.length <= HOST_TOOLCHAIN_PROMPT_BUDGET); + assert.match(bounded, /^## Host toolchain/m); + assert.doesNotMatch(bounded, /schema_version/); + + const profilePath = join(getAdvancedMemoryDir(), 'profile.md'); + mkdirSync(dirname(profilePath), { recursive: true }); + writeFileSync(profilePath, `# Profile\n\n## Curated\n- keep\n\n${renderHostToolchainManagedBlock(verifiedProfile('/verified'))}\n`, 'utf8'); + const prompt = getSystemPrompt({ forDisk: true }); + + assert.equal((prompt.match(/^## Host toolchain$/gm) || []).length, 1); + assert.match(prompt, /officecli: `\/verified\/officecli`/); + assert.doesNotMatch(prompt, /cli-jaw:host-toolchain:start/); + assert.doesNotMatch(prompt, /schema_version/); +}); From e7525c05866e14de4266365e94a365605d39724d Mon Sep 17 00:00:00 2001 From: Joonsuh Park Date: Wed, 12 Aug 2026 20:04:06 +0900 Subject: [PATCH 2/2] fix(memory): address host toolchain review feedback --- src/memory/host-toolchain.ts | 16 +++++- src/memory/runtime.ts | 47 ++++++++++++++++- src/prompt/builder.ts | 3 +- structure/memory_architecture.md | 4 +- structure/str_func.md | 9 ++-- tests/unit/host-toolchain-profile.test.ts | 64 +++++++++++++++++++++-- 6 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/memory/host-toolchain.ts b/src/memory/host-toolchain.ts index f3b13c0ec..c0d024bc8 100644 --- a/src/memory/host-toolchain.ts +++ b/src/memory/host-toolchain.ts @@ -8,6 +8,7 @@ import { launchSpec } from '../core/exec-name.js'; export const HOST_TOOLCHAIN_START = ''; export const HOST_TOOLCHAIN_END = ''; export const HOST_TOOLCHAIN_PROMPT_BUDGET = 1800; +export const HOST_TOOLCHAIN_PATH_CANDIDATE_LIMIT = 6; export const HOST_TOOL_NAMES = ['officecli', 'soffice', 'python', 'ripgrep'] as const; export type HostToolName = typeof HOST_TOOL_NAMES[number]; @@ -219,10 +220,17 @@ function defaultDiscover( for (const envName of ENV_PATHS[tool]) addCandidate(candidates, env[envName], `env:${envName}`); let scanError = false; + const pathCandidateKeys = new Set(); for (const name of pathNames(tool, platform)) { const scan = listCliBinaryCandidates(name, env['PATH'] || env['Path'] || env['path'] || ''); scanError ||= !!scan.scanError; - for (const candidate of scan.candidates) addCandidate(candidates, candidate.path, 'PATH'); + for (const candidate of scan.candidates) { + const key = platform === 'win32' ? candidate.path.toLowerCase() : candidate.path; + if (pathCandidateKeys.has(key)) continue; + if (pathCandidateKeys.size >= HOST_TOOLCHAIN_PATH_CANDIDATE_LIMIT) continue; + pathCandidateKeys.add(key); + addCandidate(candidates, candidate.path, 'PATH'); + } } for (const candidate of knownCandidates(tool, platform, env, homeDir, workingDir)) { addCandidate(candidates, candidate, 'known-location'); @@ -453,5 +461,9 @@ export function renderHostToolchainPromptBlock( } } const rendered = lines.join('\n'); - return rendered.length <= maxChars ? rendered : `${rendered.slice(0, Math.max(0, maxChars - 16))}\n...(truncated)`; + const budget = Math.max(0, maxChars); + const suffix = '\n...(truncated)'; + if (rendered.length <= budget) return rendered; + if (budget <= suffix.length) return suffix.slice(0, budget); + return `${rendered.slice(0, budget - suffix.length)}${suffix}`; } diff --git a/src/memory/runtime.ts b/src/memory/runtime.ts index 3c1406ae7..a933cdf85 100644 --- a/src/memory/runtime.ts +++ b/src/memory/runtime.ts @@ -280,15 +280,57 @@ function getLegacyClaudeMemoryDir() { return join(os.homedir(), '.claude', 'projects', hash, 'memory'); } +const HOST_TOOLCHAIN_REFRESH_INTERVAL_MS = 5 * 60_000; +let hostToolchainRefreshState: { + root: string; + workingDir: string; + profileMtimeMs: number; + refreshedAt: number; +} | null = null; + +function resolvedHostWorkingDir(): string { + return expandHomePath(settings["workingDir"] || process.cwd(), os.homedir()); +} + +function rememberHostToolchainRefresh(root: string, workingDir: string): void { + const profilePath = join(root, 'profile.md'); + const profileMtimeMs = fs.existsSync(profilePath) ? fs.statSync(profilePath).mtimeMs : -1; + hostToolchainRefreshState = { root, workingDir, profileMtimeMs, refreshedAt: Date.now() }; +} + function refreshHostToolchain(root: string) { const profilePath = join(root, 'profile.md'); + const workingDir = resolvedHostWorkingDir(); const profile = refreshHostToolchainProfileFile(profilePath, { - workingDir: expandHomePath(settings["workingDir"] || process.cwd(), os.homedir()), + workingDir, }); - reindexSingleFile(root, profilePath); + try { + reindexSingleFile(root, profilePath); + } catch (error) { + log.warn('[jaw:toolchain] profile refresh indexing skipped:', (error as Error).message); + } + rememberHostToolchainRefresh(root, workingDir); return profile; } +/** Refresh before disk prompt generation without probing again on every agent spawn. */ +export function refreshHostToolchainForDiskPrompt(): void { + const root = getAdvancedMemoryDir(); + const profilePath = join(root, 'profile.md'); + if (!fs.existsSync(profilePath)) return; + const workingDir = resolvedHostWorkingDir(); + const profileMtimeMs = fs.statSync(profilePath).mtimeMs; + const current = hostToolchainRefreshState; + if (current + && current.root === root + && current.workingDir === workingDir + && current.profileMtimeMs === profileMtimeMs + && Date.now() - current.refreshedAt < HOST_TOOLCHAIN_REFRESH_INTERVAL_MS) { + return; + } + refreshHostToolchain(root); +} + export function ensureIntegratedMemoryReady() { const created = ensureAdvancedMemoryStructure(); syncCoreProfile(getAdvancedMemoryDir(), { force: false }); @@ -324,6 +366,7 @@ export function ensureIntegratedMemoryReady() { updated_at: new Date().toISOString(), }); writeText(profilePath, fm + `# Profile\n\n${systemInfo}\n`); + rememberHostToolchainRefresh(root, resolvedHostWorkingDir()); log.info('[jaw:bootstrap] seeded profile from system scan (no legacy data found)'); } else { refreshHostToolchain(root); diff --git a/src/prompt/builder.ts b/src/prompt/builder.ts index 1c55dfa1e..773400f43 100644 --- a/src/prompt/builder.ts +++ b/src/prompt/builder.ts @@ -10,7 +10,7 @@ import { getActiveChatSession, listChatSessions } from '../core/chat-sessions.js import { currentSessionScope } from '../core/session-context.js'; import { memoryFlushCounter } from '../agent/spawn.js'; import { describeHeartbeatSchedule, normalizeHeartbeatSchedule } from '../memory/heartbeat-schedule.js'; -import { buildTaskSnapshot, getAdvancedMemoryDir, hasSoulFile, loadProfileSummary, loadSoulSummary } from '../memory/runtime.js'; +import { buildTaskSnapshot, getAdvancedMemoryDir, hasSoulFile, loadProfileSummary, loadSoulSummary, refreshHostToolchainForDiskPrompt } from '../memory/runtime.js'; import { parseHostToolchainProfile, renderHostToolchainPromptBlock } from '../memory/host-toolchain.js'; import { buildMemoryInjection } from '../memory/injection.js'; import { loadAndRender, loadTemplate, renderTemplate, parseWorkerContexts, clearTemplateCache } from './template-loader.js'; @@ -671,6 +671,7 @@ function loadDiskSoul(): string { function loadDiskHostToolchain(): string { try { + refreshHostToolchainForDiskPrompt(); const profilePath = join(getAdvancedMemoryDir(), 'profile.md'); if (!fs.existsSync(profilePath)) return ''; return renderHostToolchainPromptBlock(parseHostToolchainProfile(fs.readFileSync(profilePath, 'utf8'))); diff --git a/structure/memory_architecture.md b/structure/memory_architecture.md index e35b17ca0..f8568bba6 100644 --- a/structure/memory_architecture.md +++ b/structure/memory_architecture.md @@ -9,7 +9,7 @@ aliases: [CLI-JAW Memory Architecture, advanced memory runtime, memory architect # Memory Architecture — 통합 메모리 시스템 > 최종 갱신: 2026-08-12 -> 소스: `src/memory/runtime.ts` 396L (사실상 facade), `src/memory/shared.ts` 266L, `src/memory/bootstrap.ts` 597L, `src/memory/host-toolchain.ts` 457L, `src/memory/indexing.ts` 721L, `src/memory/keyword-expand.ts` 98L, `src/memory/synonyms.ts` 60L, `src/memory/reflect.ts` 380L, `src/memory/identity.ts` 87L, `src/memory/injection.ts` 69L, `src/memory/memory.ts` 165L, `src/memory/worklog.ts` 200L, `src/memory/heartbeat.ts` 311L, `src/memory/heartbeat-schedule.ts` 410L, `src/memory/advanced.ts` 1L (re-export shim), `src/agent/memory-flush-controller.ts` 185L, `src/agent/spawn.ts` 2011L, `src/prompt/builder.ts` 1159L, `src/orchestrator/pipeline.ts` 538L, `src/routes/memory.ts`, `src/routes/jaw-memory.ts`, `src/cli/command-context.ts`, `src/cli/handlers-runtime.ts` +> 소스: `src/memory/runtime.ts` 439L (사실상 facade), `src/memory/shared.ts` 266L, `src/memory/bootstrap.ts` 597L, `src/memory/host-toolchain.ts` 469L, `src/memory/indexing.ts` 721L, `src/memory/keyword-expand.ts` 98L, `src/memory/synonyms.ts` 60L, `src/memory/reflect.ts` 380L, `src/memory/identity.ts` 87L, `src/memory/injection.ts` 69L, `src/memory/memory.ts` 165L, `src/memory/worklog.ts` 201L, `src/memory/heartbeat.ts` 311L, `src/memory/heartbeat-report.ts` 49L, `src/memory/heartbeat-schedule.ts` 410L, `src/memory/advanced.ts` 1L (re-export shim), `src/agent/memory-flush-controller.ts` 185L, `src/agent/spawn.ts` 2011L, `src/prompt/builder.ts` 1160L, `src/orchestrator/pipeline.ts` 538L, `src/routes/memory.ts`, `src/routes/jaw-memory.ts`, `src/cli/command-context.ts`, `src/cli/handlers-runtime.ts` > 임베딩: `src/manager/memory/embedding/` — `provider.ts`, `vec-store.ts`, `sync.ts`, `state-machine.ts`, `hybrid-search.ts`, `index.ts` + `src/manager/routes/dashboard-memory.ts` --- @@ -167,7 +167,7 @@ Prefers ES Module only, no CommonJS. ### 3-E: Durable Host Toolchain - `src/memory/host-toolchain.ts` owns a bounded managed block in `memory/structured/profile.md`; it records only absolute paths, safe version tokens, source labels, `verified_at`, and a verification result for `officecli`, `soffice`, Python, and ripgrep. -- Startup fast-verifies a cached absolute path directly. A failed or stale cache entry falls back to bounded tool-specific discovery; Windows Store Python redirectors are recorded as rejected rather than usable interpreters. +- Startup fast-verifies a cached absolute path directly. Disk prompt generation refreshes an old or externally changed profile through a five-minute, mtime, and working-directory freshness gate so repeated agent spawns do not reprobe. A failed or stale cache entry falls back to bounded tool-specific discovery; PATH verification is capped at six unique candidates per tool, and Windows Store Python redirectors are recorded as rejected rather than usable interpreters. - The managed markers replace only cli-jaw-owned content, preserving curated profile sections and the independent `cli-jaw:core-memory` block (#262). - Disk prompt generation strips the raw managed JSON from profile summaries and emits one human-readable `## Host toolchain` block capped at 1800 characters. Verified paths are used before discovery. diff --git a/structure/str_func.md b/structure/str_func.md index 3693a848a..9783b32bb 100644 --- a/structure/str_func.md +++ b/structure/str_func.md @@ -153,7 +153,7 @@ cli-jaw/ │ │ ├── sanitize.ts ← Interview tracker strip helper + stripPhaseAttestation re-export (79L) │ │ └── attestation.ts ← Phase60 PABCD evidence gate: parse/validate (tagged block + --attest object) + form-only checkAttestationGate (gates P→A/A→B/B→C/C→D; narrative did required, C→D needs checkOutput) + stripPhaseAttestation + warn-only no-state narration detector (217L) │ ├── prompt/ ← 프롬프트 조립 (4 files + templates/ 10 files) -│ │ ├── builder.ts ← A-1/A-2 + 스킬 + 직원 프롬프트 v2 + promptCache (4-segment key: emp:role:phase:workingDir) + on-demand dev skill path contract + advanced memory mode branch + bounded disk soul/instance context + task snapshot injection + dashboard-connector anchor preserve + Phase60 inline PABCD guide --attest evidence note (1159L) +│ │ ├── builder.ts ← A-1/A-2 + 스킬 + 직원 프롬프트 v2 + promptCache (4-segment key: emp:role:phase:workingDir) + on-demand dev skill path contract + advanced memory mode branch + bounded disk soul/instance context + task snapshot injection + dashboard-connector anchor preserve + Phase60 inline PABCD guide --attest evidence note (1160L) │ │ ├── runtime-context.ts ← 런타임 컨텍스트 주입 (RuntimeContextEntry, loadEntries, getActiveEntries, addEntry, removeEntry, clearAll, buildInjectionBlock) (80L) │ │ ├── soul-bootstrap-prompt.ts ← LLM 기반 soul.md 개인화 부트스트랩 프롬프트 빌더 (52L) │ │ ├── template-loader.ts ← 프롬프트 템플릿 로더 (50L) @@ -207,19 +207,20 @@ cli-jaw/ │ │ ├── coordinator.ts ← ready-only 예산 배분 + cursor 상태 기계 + 부분 실패 인벤토리 (160L) ✨ │ │ ├── providers/chat.ts ← chat 어댑터 (FTS/trigram/LIKE 폴백, 실제 session_id provenance) (124L) ✨ │ │ └── providers/memory.ts ← memory 어댑터 (고정 64-candidate universe, session provenance 표시, sessionFilter 미적용 경고) (55L) ✨ -│ ├── memory/ ← 데이터 영속화 + advanced memory runtime (15 files) +│ ├── memory/ ← 데이터 영속화 + advanced memory runtime (16 files) │ │ ├── advanced.ts ← Advanced Memory re-export stub (1L) │ │ ├── bootstrap.ts ← legacy memory/bootstrap import + structured root 초기화 (597L) │ │ ├── heartbeat.ts ← Heartbeat 잡 스케줄 + cron/every timer orchestration + minute-slot dedupe + fs.watch (311L) +│ │ ├── heartbeat-report.ts ← Heartbeat run/report persistence helper (49L) │ │ ├── heartbeat-schedule.ts ← Heartbeat schedule normalize + cron validate/match + timezone validate + immediate cron loop helper (410L) -│ │ ├── host-toolchain.ts ← durable verified host paths + profile managed block + bounded AGENTS summary (457L) +│ │ ├── host-toolchain.ts ← durable verified host paths + profile managed block + bounded AGENTS summary (469L) │ │ ├── identity.ts ← `shared/soul.md` 관리 + soul runtime helper (87L) │ │ ├── indexing.ts ← FTS5/BM25 reindex + indexed file/chunk 상태 집계 (721L) │ │ ├── injection.ts ← memory injection policy + advanced/basic search routing (69L) │ │ ├── keyword-expand.ts ← search keyword expansion + provider config normalize (98L) │ │ ├── memory.ts ← Persistent Memory grep 기반 (165L) │ │ ├── reflect.ts ← episode → shared/procedures reflection + promoted fact 정리 (380L) -│ │ ├── runtime.ts ← Advanced Memory 런타임: bootstrap/import/FTS5 인덱스/BM25 검색/task snapshot/delta reindex (396L) +│ │ ├── runtime.ts ← Advanced Memory 런타임: bootstrap/import/FTS5 인덱스/BM25 검색/task snapshot/delta reindex (439L) │ │ ├── shared.ts ← file/meta/frontmatter 공용 헬퍼 (266L) │ │ ├── synonyms.ts ← keyword synonym expansion helper (60L) ✨ │ │ └── worklog.ts ← Worklog CRUD + phase matrix (201L) diff --git a/tests/unit/host-toolchain-profile.test.ts b/tests/unit/host-toolchain-profile.test.ts index 39569da7d..e366b3f28 100644 --- a/tests/unit/host-toolchain-profile.test.ts +++ b/tests/unit/host-toolchain-profile.test.ts @@ -1,11 +1,13 @@ import '../setup/isolated-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, isAbsolute, join } from 'node:path'; import { HOST_TOOL_NAMES, + HOST_TOOLCHAIN_PATH_CANDIDATE_LIMIT, HOST_TOOLCHAIN_PROMPT_BUDGET, type HostToolName, type HostToolchainProfile, @@ -62,6 +64,46 @@ test('first scan discovers and records verified absolute tool paths', () => { } }); +test('PATH discovery verifies at most the bounded unique candidate count per tool', () => { + const root = mkdtempSync(join(tmpdir(), 'jaw-host-toolchain-cap-')); + const paths: string[] = []; + const candidates = new Set(); + try { + for (let i = 0; i < HOST_TOOLCHAIN_PATH_CANDIDATE_LIMIT + 4; i++) { + const dir = join(root, `candidate-${i}`); + const candidate = join(dir, process.platform === 'win32' ? 'rg.cmd' : 'rg'); + mkdirSync(dir, { recursive: true }); + writeFileSync(candidate, process.platform === 'win32' + ? '@echo off\r\necho ripgrep 1.0.0\r\n' + : '#!/bin/sh\necho ripgrep 1.0.0\n', 'utf8'); + if (process.platform !== 'win32') chmodSync(candidate, 0o755); + paths.push(dir); + candidates.add(process.platform === 'win32' ? candidate.toLowerCase() : candidate); + } + + const checked: string[] = []; + const pathValue = paths.join(delimiter); + resolveHostToolchainProfile(null, { + platform: process.platform, + env: process.platform === 'win32' ? { Path: pathValue, PATHEXT: '.CMD;.EXE' } : { PATH: pathValue }, + homeDir: root, + workingDir: root, + }, { + now: () => FIRST_SCAN_AT, + verify(tool, candidatePath) { + const key = process.platform === 'win32' ? candidatePath.toLowerCase() : candidatePath; + if (tool === 'ripgrep' && candidates.has(key)) checked.push(key); + return { ok: false, missing: true }; + }, + }); + + assert.equal(checked.length, HOST_TOOLCHAIN_PATH_CANDIDATE_LIMIT); + assert.equal(new Set(checked).size, checked.length, 'PATH candidates remain deduplicated'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('restart fast-verifies cached paths without rediscovery', () => { const previous = verifiedProfile(); const verified: string[] = []; @@ -165,14 +207,30 @@ test('generated disk AGENTS has one short human-readable Host toolchain block', assert.ok(bounded.length <= HOST_TOOLCHAIN_PROMPT_BUDGET); assert.match(bounded, /^## Host toolchain/m); assert.doesNotMatch(bounded, /schema_version/); + for (const budget of [-10, 0, 1, 5, 14, 15]) { + assert.ok( + renderHostToolchainPromptBlock(huge, budget).length <= Math.max(0, budget), + `tiny prompt budget ${budget} must be honored exactly`, + ); + } const profilePath = join(getAdvancedMemoryDir(), 'profile.md'); mkdirSync(dirname(profilePath), { recursive: true }); writeFileSync(profilePath, `# Profile\n\n## Curated\n- keep\n\n${renderHostToolchainManagedBlock(verifiedProfile('/verified'))}\n`, 'utf8'); const prompt = getSystemPrompt({ forDisk: true }); + const refreshed = readFileSync(profilePath, 'utf8'); + const refreshedProfile = parseHostToolchainProfile(refreshed); assert.equal((prompt.match(/^## Host toolchain$/gm) || []).length, 1); - assert.match(prompt, /officecli: `\/verified\/officecli`/); + assert.doesNotMatch(prompt, /\/verified\/officecli/, 'disk generation must not publish an unverified cached path'); + assert.notEqual(refreshedProfile?.verified_at, FIRST_SCAN_AT.toISOString()); + for (const tool of HOST_TOOL_NAMES) { + const entry = refreshedProfile?.tools[tool]; + if (entry?.verification === 'verified') assert.ok(isAbsolute(entry.path || '')); + } assert.doesNotMatch(prompt, /cli-jaw:host-toolchain:start/); assert.doesNotMatch(prompt, /schema_version/); + + getSystemPrompt({ forDisk: true }); + assert.equal(readFileSync(profilePath, 'utf8'), refreshed, 'fresh disk generations skip redundant subprocess probes'); });