From f3c88a089eb8d9a0a50f49fecbe73514fe668e14 Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Thu, 8 Oct 2026 11:37:47 +0200 Subject: [PATCH] feat(qmd): warm the index and daemon from a SessionStart hook Co-Authored-By: Claude Opus 5.5 --- adapters/claude/config/settings.json | 5 ++- adapters/codex/config/config.toml | 9 +++- scripts/commands/build.ps1 | 3 +- scripts/commands/build.sh | 3 ++ scripts/lib/install-options.py | 52 ++++++++++++++++++++--- scripts/lib/manifest.ps1 | 10 +++-- scripts/lib/manifest.sh | 9 +++- scripts/tests/test-install-options.py | 25 +++++++++++ scripts/tests/test-qmd-warm.mjs | 25 +++++++++++ shared/hooks/scripts/Start-QmdWarm.ps1 | 11 +++++ shared/hooks/scripts/qmd-warm.sh | 7 ++++ shared/qmd/qmd-warm.mjs | 57 ++++++++++++++++++++++++++ 12 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 scripts/tests/test-qmd-warm.mjs create mode 100644 shared/hooks/scripts/Start-QmdWarm.ps1 create mode 100644 shared/hooks/scripts/qmd-warm.sh create mode 100644 shared/qmd/qmd-warm.mjs diff --git a/adapters/claude/config/settings.json b/adapters/claude/config/settings.json index c209483..13d4678 100644 --- a/adapters/claude/config/settings.json +++ b/adapters/claude/config/settings.json @@ -16,7 +16,10 @@ }, "hooks": { "PreCompact": [{"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__COMPACT_SCRIPT__\"", "timeout": 4}]}], - "SessionStart": [{"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__SESSION_POINTER_SCRIPT__\"", "timeout": 4}]}], + "SessionStart": [ + {"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__SESSION_POINTER_SCRIPT__\"", "timeout": 4}]}, + {"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__QMD_WARM_SCRIPT__\"", "timeout": 4}]} + ], "Stop": [ { "hooks": [ diff --git a/adapters/codex/config/config.toml b/adapters/codex/config/config.toml index dd565b0..b2e6769 100644 --- a/adapters/codex/config/config.toml +++ b/adapters/codex/config/config.toml @@ -4,7 +4,7 @@ sandbox_mode = "workspace-write" [sandbox_workspace_write] network_access = true -writable_roots = ['__AI_CONFIG_ROOT__/../.my-ai-configuration/semantic-retrieval/data'] +writable_roots = ['__AI_CONFIG_ROOT__/../.my-ai-configuration/qmd', '__AI_CONFIG_ROOT__/../.cache/qmd'] [windows] sandbox = "unelevated" @@ -26,3 +26,10 @@ type = "command" command = '__HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__HOOK_SCRIPT__"' command_windows = '__POWERSHELL_HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__POWERSHELL_HOOK_SCRIPT__"' timeout = 8 + +[[hooks.SessionStart]] +[[hooks.SessionStart.hooks]] +type = "command" +command = '__HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/scripts/__QMD_WARM_SCRIPT__"' +command_windows = '__POWERSHELL_HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/scripts/__QMD_WARM_SCRIPT__"' +timeout = 4 diff --git a/scripts/commands/build.ps1 b/scripts/commands/build.ps1 index 0743df8..03428f2 100644 --- a/scripts/commands/build.ps1 +++ b/scripts/commands/build.ps1 @@ -209,7 +209,8 @@ foreach ($shell in @('powershell','bash')) { $allowRulesJson = ConvertTo-Json -InputObject @('Agent', "$shellTool(mcporter call context7.*)") -Compress $compactScript = if ($shell -eq 'powershell') { 'Record-Compact.ps1' } else { 'record-compact.sh' } $pointerScript = if ($shell -eq 'powershell') { 'Show-SessionStatePointer.ps1' } else { 'show-session-state-pointer.sh' } - $content = $content.Replace('__HOOK_COMMAND__', $command).Replace('__POWERSHELL_HOOK_COMMAND__', $command).Replace('__HOOK_SCRIPT__', $script).Replace('__POWERSHELL_HOOK_SCRIPT__', $script).Replace('__COMPACT_SCRIPT__', $compactScript).Replace('__SESSION_POINTER_SCRIPT__', $pointerScript).Replace('__STATUSLINE_COMMAND__', $command).Replace('__STATUSLINE_SCRIPT__', $statusLineScript).Replace('__CLAUDE_SANDBOX__', $claudeSandbox).Replace('__CLAUDE_DENY_RULES__', $denyRulesJson).Replace('__CLAUDE_ALLOW_RULES__', $allowRulesJson) + $qmdWarmScript = if ($shell -eq 'powershell') { 'Start-QmdWarm.ps1' } else { 'qmd-warm.sh' } + $content = $content.Replace('__HOOK_COMMAND__', $command).Replace('__POWERSHELL_HOOK_COMMAND__', $command).Replace('__HOOK_SCRIPT__', $script).Replace('__POWERSHELL_HOOK_SCRIPT__', $script).Replace('__COMPACT_SCRIPT__', $compactScript).Replace('__SESSION_POINTER_SCRIPT__', $pointerScript).Replace('__QMD_WARM_SCRIPT__', $qmdWarmScript).Replace('__STATUSLINE_COMMAND__', $command).Replace('__STATUSLINE_SCRIPT__', $statusLineScript).Replace('__CLAUDE_SANDBOX__', $claudeSandbox).Replace('__CLAUDE_DENY_RULES__', $denyRulesJson).Replace('__CLAUDE_ALLOW_RULES__', $allowRulesJson) Set-Content -LiteralPath $path -Value $content -Encoding UTF8 } diff --git a/scripts/commands/build.sh b/scripts/commands/build.sh index 004446a..0f11511 100644 --- a/scripts/commands/build.sh +++ b/scripts/commands/build.sh @@ -195,12 +195,14 @@ for client in codex claude; do statusline_script=statusline.sh compact_script=record-compact.sh pointer_script=show-session-state-pointer.sh + qmd_warm_script=qmd-warm.sh if [ "$shell" = powershell ]; then command='__POWERSHELL_COMMAND__' script=flashbang.ps1 statusline_script=statusline.ps1 compact_script=Record-Compact.ps1 pointer_script=Show-SessionStatePointer.ps1 + qmd_warm_script=Start-QmdWarm.ps1 fi path="$output/$client-$shell/$file" content=$(<"$path") @@ -217,6 +219,7 @@ for client in codex claude; do content=${content//__POWERSHELL_HOOK_SCRIPT__/$script} content=${content//__COMPACT_SCRIPT__/$compact_script} content=${content//__SESSION_POINTER_SCRIPT__/$pointer_script} + content=${content//__QMD_WARM_SCRIPT__/$qmd_warm_script} content=${content//__STATUSLINE_COMMAND__/$command} content=${content//__STATUSLINE_SCRIPT__/$statusline_script} content=${content//__CLAUDE_SANDBOX__/$sandbox} diff --git a/scripts/lib/install-options.py b/scripts/lib/install-options.py index 3480212..ef48786 100644 --- a/scripts/lib/install-options.py +++ b/scripts/lib/install-options.py @@ -11,7 +11,27 @@ def is_flashbang(hook): for key in ('command', 'command_windows')) -def filter_options(path, flashbang_enabled=True, statusline_enabled=True): +def is_qmd_warm(hook): + return any(re.search(r'(?:qmd-warm\.sh|Start-QmdWarm\.ps1)', str(hook.get(key, '')), re.I) + for key in ('command', 'command_windows')) + + +def without_hooks(groups, predicate): + remaining = [] + for group in groups: + hooks = [hook for hook in group.get('hooks', []) if not predicate(hook)] + if hooks: + remaining.append(dict(group, hooks=hooks)) + return remaining + + +def filter_instructions(text, semantic_retrieval_enabled): + if semantic_retrieval_enabled: + return text + return ''.join(line for line in text.splitlines(keepends=True) if '`semantic-search`' not in line) + + +def filter_options(path, flashbang_enabled=True, statusline_enabled=True, semantic_retrieval_enabled=True): content = path.read_text(encoding='utf-8-sig') if path.name == 'settings.json': settings = json.loads(content) @@ -26,6 +46,12 @@ def filter_options(path, flashbang_enabled=True, statusline_enabled=True): settings['hooks']['Stop'] = remaining elif 'Stop' in settings.get('hooks', {}): del settings['hooks']['Stop'] + if not semantic_retrieval_enabled: + remaining = without_hooks(settings.get('hooks', {}).get('SessionStart', []), is_qmd_warm) + if remaining: + settings['hooks']['SessionStart'] = remaining + else: + settings.get('hooks', {}).pop('SessionStart', None) if not statusline_enabled: settings.pop('statusLine', None) return json.dumps(settings, indent=2, ensure_ascii=False) + '\n' @@ -44,6 +70,17 @@ def filter_options(path, flashbang_enabled=True, statusline_enabled=True): filtered.append(section) result = ''.join(filtered) result = re.sub(r'(?m)^\[\[hooks\.Stop\]\][ \t]*\r?\n\s*(?=\[|\Z)(?!\[\[hooks\.Stop\.hooks\]\])', '', result) + if not semantic_retrieval_enabled: + sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', result) + kept = [] + for section in sections: + if re.match(r'\s*\[\[hooks\.SessionStart\.hooks\]\]', section): + hook = tomllib.loads(section)['hooks']['SessionStart']['hooks'][0] + if is_qmd_warm(hook): + continue + kept.append(section) + result = ''.join(kept) + result = re.sub(r'(?m)^\[\[hooks\.SessionStart\]\][ \t]*\r?\n\s*(?=\[|\Z)(?!\[\[hooks\.SessionStart\.hooks\]\])', '', result) if not statusline_enabled: sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', result) for index, section in enumerate(sections): @@ -69,7 +106,7 @@ def merge_json(current, managed): for key, value in managed.items(): if key == 'hooks' and isinstance(value, dict) and isinstance(result.get(key), dict): hooks = dict(result[key]) - for event in set(value) | {'Stop'}: + for event in set(value) | {'Stop', 'SessionStart'}: groups = value.get(event, []) old_groups = hooks.get(event, []) if isinstance(groups, list) and isinstance(old_groups, list): @@ -79,7 +116,7 @@ def merge_json(current, managed): retained.append(group) continue foreign = [hook for hook in group['hooks'] if not (isinstance(hook, dict) and re.search( - r'(?:flashbang|statusline|record-compact|session-state-pointer)\.(?:ps1|sh)', + r'(?:flashbang|statusline|record-compact|session-state-pointer|qmd-warm|Start-QmdWarm)\.(?:ps1|sh)', str(hook.get('command', '')) + str(hook.get('command_windows', '')), re.I))] if foreign: retained.append(dict(group, hooks=foreign)) @@ -131,7 +168,7 @@ def merge_toml(current_text, managed_text, statusline_enabled=True): for header, block in current_sections: plugin_table = re.match(r'^\[(?:plugins|marketplaces)(?:\.|\])', header) array_table = header.startswith('[[') - owned_hook = any(re.search(r'(?:flashbang|statusline|record-compact|session-state-pointer)\.(?:ps1|sh)', line, re.I) for line in block) + owned_hook = any(re.search(r'(?:flashbang|statusline|record-compact|session-state-pointer|qmd-warm|Start-QmdWarm)\.(?:ps1|sh)', line, re.I) for line in block) if plugin_table and header in managed_headers: raise ValueError('Cannot merge conflicting managed plugin tables.') if (header not in managed_headers and not array_table) or (array_table and not owned_hook): @@ -173,17 +210,20 @@ def merge_toml(current_text, managed_text, statusline_enabled=True): if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument('action', choices=('filter', 'merge-json', 'merge-toml')) + parser.add_argument('action', choices=('filter', 'filter-instructions', 'merge-json', 'merge-toml')) parser.add_argument('--path', type=Path) parser.add_argument('--current', type=Path) parser.add_argument('--flashbang', choices=('true', 'false')) parser.add_argument('--statusline', choices=('true', 'false'), default='true') + parser.add_argument('--semantic-retrieval', choices=('true', 'false'), default='true') parser.add_argument('--managed', type=Path) parser.add_argument('--output', type=Path) args = parser.parse_args() try: if args.action == 'filter': - result = filter_options(args.path, args.flashbang == 'true', args.statusline == 'true') + result = filter_options(args.path, args.flashbang == 'true', args.statusline == 'true', args.semantic_retrieval == 'true') + elif args.action == 'filter-instructions': + result = filter_instructions(args.path.read_text(encoding='utf-8-sig'), args.semantic_retrieval == 'true') else: managed = args.managed.read_text(encoding='utf-8-sig') if args.managed else sys.stdin.buffer.read().decode('utf-8-sig') current = args.current.read_text(encoding='utf-8-sig') diff --git a/scripts/lib/manifest.ps1 b/scripts/lib/manifest.ps1 index 2b49182..8df05ef 100644 --- a/scripts/lib/manifest.ps1 +++ b/scripts/lib/manifest.ps1 @@ -227,13 +227,17 @@ function Invoke-ManagedSync { if (-not $SemanticRetrievalEnabled -and $relative -match '(^|/)semantic-search/') { return } $target = Resolve-ManagedPath -Destination $Destination -Relative $relative $rawContent = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8 - $filterOptions = ((-not $FlashbangEnabled) -or (-not $StatusLineEnabled)) -and $relative -in @('settings.json', 'config.toml') + $filterOptions = ((-not $FlashbangEnabled) -or (-not $StatusLineEnabled) -or (-not $SemanticRetrievalEnabled)) -and $relative -in @('settings.json', 'config.toml') if ($filterOptions) { $flashbangOption = if ($FlashbangEnabled) { 'true' } else { 'false' } $statusLineOption = if ($StatusLineEnabled) { 'true' } else { 'false' } - $rawContent = Invoke-InstallOptions -Arguments @('filter', '--path', $_.FullName, '--flashbang', $flashbangOption, '--statusline', $statusLineOption) -ErrorMessage 'Could not configure install options.' + $rawContent = Invoke-InstallOptions -Arguments @('filter', '--path', $_.FullName, '--flashbang', $flashbangOption, '--statusline', $statusLineOption, '--semantic-retrieval', $(if ($SemanticRetrievalEnabled) { 'true' } else { 'false' })) -ErrorMessage 'Could not configure install options.' } - $needsSubstitution = $filterOptions -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__') -or $rawContent.Contains('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__') + $filterInstructions = (-not $SemanticRetrievalEnabled) -and $relative -in @('CLAUDE.md', 'AGENTS.md', 'rules/general.md') + if ($filterInstructions) { + $rawContent = Invoke-InstallOptions -Arguments @('filter-instructions', '--path', $_.FullName, '--semantic-retrieval', 'false') -ErrorMessage 'Could not configure install options.' + } + $needsSubstitution = $filterOptions -or $filterInstructions -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__') -or $rawContent.Contains('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__') if ($needsSubstitution) { $finalContent = $rawContent.Replace('__AI_CONFIG_ROOT__', $AiConfigRoot).Replace('__HOOK_COMMAND__', $ShellCommand).Replace('__POWERSHELL_HOOK_COMMAND__', $PowerShellCommand).Replace('__POWERSHELL_COMMAND__', $PowerShellCommand).Replace('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__', $ClaudeConcurrency).Replace('__HOOK_SCRIPT__', 'flashbang.ps1').Replace('__POWERSHELL_HOOK_SCRIPT__', 'flashbang.ps1') $newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent) diff --git a/scripts/lib/manifest.sh b/scripts/lib/manifest.sh index a9f3d82..3ba140b 100644 --- a/scripts/lib/manifest.sh +++ b/scripts/lib/manifest.sh @@ -210,8 +210,13 @@ sys.stdout.buffer.write(("\n".join(lines) + "\n").encode("utf-8")) content=$(cat -- "$source_file" && printf '\034') || return content=${content%$'\034'} needs_sub=false - if { [ "$flashbang_enabled" = false ] || [ "$statusline_enabled" = false ]; } && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then - content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter --path "$source_file" --flashbang "$flashbang_enabled" --statusline "$statusline_enabled" && printf '\034') || return + if { [ "$flashbang_enabled" = false ] || [ "$statusline_enabled" = false ] || [ "$semantic_retrieval_enabled" = false ]; } && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then + content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter --path "$source_file" --flashbang "$flashbang_enabled" --statusline "$statusline_enabled" --semantic-retrieval "$semantic_retrieval_enabled" && printf '\034') || return + content=${content%$'\034'} + needs_sub=true + fi + if [ "$semantic_retrieval_enabled" = false ] && [[ "$relative" = CLAUDE.md || "$relative" = AGENTS.md || "$relative" = rules/general.md ]]; then + content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter-instructions --path "$source_file" --semantic-retrieval false && printf '\034') || return content=${content%$'\034'} needs_sub=true fi diff --git a/scripts/tests/test-install-options.py b/scripts/tests/test-install-options.py index 6ff1da6..ea795e7 100644 --- a/scripts/tests/test-install-options.py +++ b/scripts/tests/test-install-options.py @@ -73,6 +73,31 @@ class InstallOptionsTests(unittest.TestCase): subprocess.run([sys.executable, script, 'merge-toml', '--current', str(current), '--managed', str(managed_path), '--output', str(output)], check=True) self.assertIn('# Größe', output.read_text(encoding='utf-8')) + def test_semantic_retrieval_disabled_removes_qmd_session_hook(self): + with tempfile.TemporaryDirectory() as directory: + settings = Path(directory) / 'settings.json' + settings.write_text(json.dumps({'hooks': {'SessionStart': [ + {'hooks': [{'type': 'command', 'command': 'bash "/c/hooks/scripts/show-session-state-pointer.sh"'}]}, + {'hooks': [{'type': 'command', 'command': 'bash "/c/hooks/scripts/qmd-warm.sh"'}]}]}}), encoding='utf-8') + result = json.loads(OPTIONS.filter_options(settings, semantic_retrieval_enabled=False)) + commands = [hook['command'] for group in result['hooks']['SessionStart'] for hook in group['hooks']] + self.assertEqual(commands, ['bash "/c/hooks/scripts/show-session-state-pointer.sh"']) + + def test_semantic_retrieval_disabled_removes_codex_session_hook(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'config.toml' + config.write_text('[features]\nhooks = true\n\n[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\n' + 'command = \'bash "/c/hooks/scripts/qmd-warm.sh"\'\ncommand_windows = \'pwsh "/c/hooks/scripts/Start-QmdWarm.ps1"\'\ntimeout = 4\n', + encoding='utf-8') + result = OPTIONS.filter_options(config, semantic_retrieval_enabled=False) + self.assertNotIn('qmd-warm', result) + self.assertNotIn('hooks.SessionStart', result) + + def test_instruction_line_only_with_semantic_search(self): + text = 'Line one.\nWhen `semantic-search` is installed, run it first.\nLine three.\n' + self.assertEqual(OPTIONS.filter_instructions(text, False), 'Line one.\nLine three.\n') + self.assertEqual(OPTIONS.filter_instructions(text, True), text) + def test_filter_preserves_other_claude_hooks_and_status(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'settings.json' diff --git a/scripts/tests/test-qmd-warm.mjs b/scripts/tests/test-qmd-warm.mjs new file mode 100644 index 0000000..f9a3922 --- /dev/null +++ b/scripts/tests/test-qmd-warm.mjs @@ -0,0 +1,25 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { withLock } from '../../shared/qmd/qmd-warm.mjs'; + +test('a fresh lock skips the second warm-up and is released afterwards', async () => { + const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock'); + let runs = 0; + await withLock(lock, async () => { + runs += 1; + await withLock(lock, async () => { runs += 1; }); + }); + assert.equal(runs, 1); + assert.equal(existsSync(lock), false); +}); + +test('a stale lock older than 30 minutes is replaced', async () => { + const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock'); + writeFileSync(lock, String(Date.now() - 31 * 60_000)); + let runs = 0; + await withLock(lock, async () => { runs += 1; }); + assert.equal(runs, 1); +}); diff --git a/shared/hooks/scripts/Start-QmdWarm.ps1 b/shared/hooks/scripts/Start-QmdWarm.ps1 new file mode 100644 index 0000000..832ec3f --- /dev/null +++ b/shared/hooks/scripts/Start-QmdWarm.ps1 @@ -0,0 +1,11 @@ +# SessionStart: warm the QMD index and daemon in the background; never blocks or fails the session. +$ErrorActionPreference = 'SilentlyContinue' +$script = Join-Path $HOME '.my-ai-configuration/qmd/qmd-warm.mjs' +$node = Get-Command node -ErrorAction SilentlyContinue +if ((Test-Path -LiteralPath $script) -and $node) { + $null = git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -eq 0) { + Start-Process -FilePath $node.Source -ArgumentList @("`"$script`"") -WindowStyle Hidden -WorkingDirectory (Get-Location).Path + } +} +exit 0 diff --git a/shared/hooks/scripts/qmd-warm.sh b/shared/hooks/scripts/qmd-warm.sh new file mode 100644 index 0000000..1c06846 --- /dev/null +++ b/shared/hooks/scripts/qmd-warm.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# SessionStart: warm the QMD index and daemon in the background; never blocks or fails the session. +script="$HOME/.my-ai-configuration/qmd/qmd-warm.mjs" +if [ -f "$script" ] && command -v node >/dev/null 2>&1 && git rev-parse --show-toplevel >/dev/null 2>&1; then + nohup node "$script" >/dev/null 2>&1 & +fi +exit 0 diff --git a/shared/qmd/qmd-warm.mjs b/shared/qmd/qmd-warm.mjs new file mode 100644 index 0000000..f9b5a89 --- /dev/null +++ b/shared/qmd/qmd-warm.mjs @@ -0,0 +1,57 @@ +// Background warm-up started by the SessionStart hook; never fails the session. +import { execFileSync } from 'node:child_process'; +import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { collectionName, readJson, stateDir } from './qmd-lib.mjs'; +import { startDaemon } from './qmd-search.mjs'; + +const STALE_MS = 30 * 60_000; + +export async function withLock(lockPath, fn, now = Date.now) { + try { + writeFileSync(lockPath, String(now()), { flag: 'wx' }); + } catch { + const age = now() - Number(readFileSync(lockPath, 'utf8')); + if (!(age > STALE_MS)) return false; + writeFileSync(lockPath, String(now())); + } + try { + await fn(); + return true; + } finally { + rmSync(lockPath, { force: true }); + } +} + +async function refresh(root) { + const info = readJson(join(stateDir(), 'daemon.json'), null); + if (!info?.port) throw new Error('daemon not running'); + const response = await fetch(`http://127.0.0.1:${info.port}/refresh`, { method: 'POST', + headers: { 'x-qmd-token': info.token, 'content-type': 'application/json' }, body: JSON.stringify({ repo: root }), + signal: AbortSignal.timeout(35 * 60_000) }); + if (!response.ok) throw new Error(`refresh answered ${response.status}`); +} + +async function main() { + const { values } = parseArgs({ options: { root: { type: 'string' } } }); + const root = resolve(values.root || execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()); + const dir = stateDir(); + mkdirSync(dir, { recursive: true }); + // A refresh through the daemon indexes the repository incrementally; start the daemon first if needed. + await withLock(join(dir, `${collectionName(root)}.lock`), async () => { + try { + await refresh(root); + } catch { + try { await startDaemon(); } catch { /* another daemon may have won the start race */ } + await refresh(root); + } + }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + main().catch((error) => { + try { appendFileSync(join(stateDir(), 'warm.log'), `${new Date().toISOString()} ${error.message}\n`); } catch { /* ignore */ } + }).finally(() => process.exit(0)); +}