create-egregore 0.20.2 → 0.21.0-runtime-mvp.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/egregore-launcher.js +1013 -16
- package/bin/cli.js +11 -0
- package/lib/claude-runtime.js +192 -0
- package/lib/local.js +38 -31
- package/lib/pi-runtime.js +1 -0
- package/lib/prime-runtime.js +1 -0
- package/lib/runtime-identity.js +146 -0
- package/lib/setup.js +116 -17
- package/package.json +10 -6
- package/runtime/claude/.claude/context/commit-format.md +110 -0
- package/runtime/claude/.claude/context/git-language.md +46 -0
- package/runtime/claude/.claude/context/notification-consent.md +66 -0
- package/runtime/claude/.claude/context/pr-format.md +67 -0
- package/runtime/claude/.claude/context/telemetry.md +133 -0
- package/runtime/claude/.claude/hooks/boundary-check.sh +333 -0
- package/runtime/claude/.claude/hooks/branch-guard.sh +231 -0
- package/runtime/claude/.claude/hooks/card-context.sh +38 -0
- package/runtime/claude/.claude/hooks/emissary-detect.sh +53 -0
- package/runtime/claude/.claude/hooks/memory-refresh.sh +67 -0
- package/runtime/claude/.claude/hooks/onboarding-guard.sh +86 -0
- package/runtime/claude/.claude/hooks/retrieval-context.sh +43 -0
- package/runtime/claude/.claude/rules/voice-bedrock.md +73 -0
- package/runtime/claude/.claude/settings.json +131 -0
- package/runtime/claude/.claude/skills/activity/SKILL.md +68 -0
- package/runtime/claude/.claude/skills/announce/SKILL.md +54 -0
- package/runtime/claude/.claude/skills/archive/SKILL.md +71 -0
- package/runtime/claude/.claude/skills/branch/SKILL.md +147 -0
- package/runtime/claude/.claude/skills/checkup/SKILL.md +159 -0
- package/runtime/claude/.claude/skills/commit/SKILL.md +103 -0
- package/runtime/claude/.claude/skills/contribute/SKILL.md +343 -0
- package/runtime/claude/.claude/skills/create-skill/SKILL.md +135 -0
- package/runtime/claude/.claude/skills/dark-mode/SKILL.md +410 -0
- package/runtime/claude/.claude/skills/dashboard/SKILL.md +43 -0
- package/runtime/claude/.claude/skills/deep-reflect/REPORT.md +140 -0
- package/runtime/claude/.claude/skills/deep-reflect/RESEARCH.md +132 -0
- package/runtime/claude/.claude/skills/deep-reflect/ROLES.md +104 -0
- package/runtime/claude/.claude/skills/deep-reflect/SKILL.md +170 -0
- package/runtime/claude/.claude/skills/delete-user/SKILL.md +150 -0
- package/runtime/claude/.claude/skills/emissary/SKILL.md +220 -0
- package/runtime/claude/.claude/skills/handoff/SKILL.md +124 -0
- package/runtime/claude/.claude/skills/harvest/AUDIT.md +546 -0
- package/runtime/claude/.claude/skills/harvest/FORMAT.md +436 -0
- package/runtime/claude/.claude/skills/harvest/PROCESS.md +395 -0
- package/runtime/claude/.claude/skills/harvest/QUESTION_PALETTE.md +76 -0
- package/runtime/claude/.claude/skills/harvest/SKILL.md +100 -0
- package/runtime/claude/.claude/skills/ingest/SKILL.md +112 -0
- package/runtime/claude/.claude/skills/ingest-corpus/SKILL.md +187 -0
- package/runtime/claude/.claude/skills/ingest-notion/SKILL.md +214 -0
- package/runtime/claude/.claude/skills/ingest-user-interview/SKILL.md +65 -0
- package/runtime/claude/.claude/skills/ingest-user-interview/references/analysis-contract.md +125 -0
- package/runtime/claude/.claude/skills/invite/SKILL.md +111 -0
- package/runtime/claude/.claude/skills/issue/SKILL.md +104 -0
- package/runtime/claude/.claude/skills/me/SKILL.md +81 -0
- package/runtime/claude/.claude/skills/meeting/SKILL.md +76 -0
- package/runtime/claude/.claude/skills/meeting/references/analysis-contract.md +102 -0
- package/runtime/claude/.claude/skills/note/SKILL.md +71 -0
- package/runtime/claude/.claude/skills/notion-connect/SKILL.md +124 -0
- package/runtime/claude/.claude/skills/onboarding/SKILL.md +159 -0
- package/runtime/claude/.claude/skills/pr/SKILL.md +85 -0
- package/runtime/claude/.claude/skills/pull/SKILL.md +79 -0
- package/runtime/claude/.claude/skills/push/SKILL.md +47 -0
- package/runtime/claude/.claude/skills/quest/SKILL.md +121 -0
- package/runtime/claude/.claude/skills/reflect/SKILL.md +69 -0
- package/runtime/claude/.claude/skills/review-pr/SKILL.md +276 -0
- package/runtime/claude/.claude/skills/save/SKILL.md +92 -0
- package/runtime/claude/.claude/skills/slack-connect/SKILL.md +148 -0
- package/runtime/claude/.claude/skills/summon/SKILL.md +240 -0
- package/runtime/claude/.claude/skills/sync-repos/SKILL.md +82 -0
- package/runtime/claude/.claude/skills/teams-connect/SKILL.md +216 -0
- package/runtime/claude/.claude/skills/telegram-connect/SKILL.md +110 -0
- package/runtime/claude/.claude/skills/telemetry/SKILL.md +117 -0
- package/runtime/claude/.claude/skills/test/SKILL.md +87 -0
- package/runtime/claude/.claude/skills/the-spiral/SKILL.md +492 -0
- package/runtime/claude/.claude/skills/tui-design/SKILL.md +188 -0
- package/runtime/claude/.claude/skills/tutorial/SKILL.md +100 -0
- package/runtime/claude/.claude/skills/update/SKILL.md +265 -0
- package/runtime/claude/.claude/skills/view/SKILL.md +393 -0
- package/runtime/claude/.claude/skills/view/compose-scaffold.html +221 -0
- package/runtime/claude/.claude/skills/visual-explain/SKILL.md +132 -0
- package/runtime/claude/.claude/skills/wrap/SKILL.md +98 -0
- package/runtime/claude/CLAUDE.md +447 -0
- package/runtime/claude/bin/activity-action.sh +92 -0
- package/runtime/claude/bin/activity-data.sh +17 -0
- package/runtime/claude/bin/agent.sh +783 -0
- package/runtime/claude/bin/artifact-register.sh +164 -0
- package/runtime/claude/bin/artifact-writeback.sh +53 -0
- package/runtime/claude/bin/artifacts.sh +288 -0
- package/runtime/claude/bin/attendant.sh +707 -0
- package/runtime/claude/bin/autosave.sh +112 -0
- package/runtime/claude/bin/boundary.sh +253 -0
- package/runtime/claude/bin/build-sealed-dataroom.mjs +722 -0
- package/runtime/claude/bin/capture-reconcile.sh +69 -0
- package/runtime/claude/bin/capture-run.sh +357 -0
- package/runtime/claude/bin/codex-install-skills.sh +78 -0
- package/runtime/claude/bin/codex-render-spec.mjs +462 -0
- package/runtime/claude/bin/codex-session-start.sh +275 -0
- package/runtime/claude/bin/codex-skill-render.mjs +349 -0
- package/runtime/claude/bin/codex-sync-skills.sh +237 -0
- package/runtime/claude/bin/connect-refresh.sh +33 -0
- package/runtime/claude/bin/contribute-guard.sh +79 -0
- package/runtime/claude/bin/corpus_map.py +806 -0
- package/runtime/claude/bin/corpus_survey.py +304 -0
- package/runtime/claude/bin/dashboard-data.sh +18 -0
- package/runtime/claude/bin/github-auth.sh +121 -0
- package/runtime/claude/bin/graph-batch.sh +86 -0
- package/runtime/claude/bin/graph-op.sh +1106 -0
- package/runtime/claude/bin/graph-projection.sh +54 -0
- package/runtime/claude/bin/graph-wal.sh +242 -0
- package/runtime/claude/bin/graph.sh +267 -0
- package/runtime/claude/bin/handoff-lifecycle-job.sh +146 -0
- package/runtime/claude/bin/handoff-lifecycle.sh +280 -0
- package/runtime/claude/bin/handoff-pr-backfill.sh +86 -0
- package/runtime/claude/bin/handoff-preview.sh +169 -0
- package/runtime/claude/bin/handoff-run.sh +692 -0
- package/runtime/claude/bin/handoff-save-egregore.sh +224 -0
- package/runtime/claude/bin/index-handoff.sh +322 -0
- package/runtime/claude/bin/ingest-graph.sh +86 -0
- package/runtime/claude/bin/ingest-item.sh +10 -0
- package/runtime/claude/bin/ingest-surface.html +495 -0
- package/runtime/claude/bin/ingest.py +1011 -0
- package/runtime/claude/bin/ingest.sh +308 -0
- package/runtime/claude/bin/ingest_graph.py +856 -0
- package/runtime/claude/bin/ingest_surface.py +544 -0
- package/runtime/claude/bin/invite.sh +6 -0
- package/runtime/claude/bin/issue.sh +10 -0
- package/runtime/claude/bin/knowledge.sh +10 -0
- package/runtime/claude/bin/lib/artifact-id.sh +46 -0
- package/runtime/claude/bin/lib/config.sh +136 -0
- package/runtime/claude/bin/lib/context.sh +642 -0
- package/runtime/claude/bin/lib/dashboard-artifact.sh +242 -0
- package/runtime/claude/bin/lib/ensure-develop.sh +183 -0
- package/runtime/claude/bin/lib/git-message.sh +37 -0
- package/runtime/claude/bin/lib/git-sync.sh +494 -0
- package/runtime/claude/bin/lib/greeting.sh +548 -0
- package/runtime/claude/bin/lib/handoff-meta.sh +97 -0
- package/runtime/claude/bin/lib/hash.sh +13 -0
- package/runtime/claude/bin/lib/identity.sh +213 -0
- package/runtime/claude/bin/lib/metrics.sh +374 -0
- package/runtime/claude/bin/lib/noncode.sh +31 -0
- package/runtime/claude/bin/lib/permissions.sh +106 -0
- package/runtime/claude/bin/lib/time.sh +56 -0
- package/runtime/claude/bin/lib/worktree-links.sh +85 -0
- package/runtime/claude/bin/node-run.sh +42 -0
- package/runtime/claude/bin/notification.sh +6 -0
- package/runtime/claude/bin/notify.sh +636 -0
- package/runtime/claude/bin/observe-context.sh +14 -0
- package/runtime/claude/bin/onboarding.sh +6 -0
- package/runtime/claude/bin/person.py +634 -0
- package/runtime/claude/bin/person.sh +481 -0
- package/runtime/claude/bin/pi-render-spec.mjs +140 -0
- package/runtime/claude/bin/post-update.sh +37 -0
- package/runtime/claude/bin/prime-render-spec.mjs +147 -0
- package/runtime/claude/bin/publish-artifact.sh +324 -0
- package/runtime/claude/bin/publish-references.sh +144 -0
- package/runtime/claude/bin/quest.sh +9 -0
- package/runtime/claude/bin/question.sh +8 -0
- package/runtime/claude/bin/repo-state.sh +96 -0
- package/runtime/claude/bin/research-ingest.sh +7 -0
- package/runtime/claude/bin/restore-owned-skills.sh +67 -0
- package/runtime/claude/bin/retrieval_eval.py +385 -0
- package/runtime/claude/bin/runtime-identity.sh +70 -0
- package/runtime/claude/bin/runtime-upgrade.sh +43 -0
- package/runtime/claude/bin/scroll.sh +9 -0
- package/runtime/claude/bin/search.sh +318 -0
- package/runtime/claude/bin/session-autosave.sh +220 -0
- package/runtime/claude/bin/session-log.sh +199 -0
- package/runtime/claude/bin/session-start.sh +696 -0
- package/runtime/claude/bin/settings.sh +532 -0
- package/runtime/claude/bin/startup-check.sh +136 -0
- package/runtime/claude/bin/statusline.sh +41 -0
- package/runtime/claude/bin/telemetry.sh +666 -0
- package/runtime/claude/bin/test-changes.sh +344 -0
- package/runtime/claude/bin/thread.sh +9 -0
- package/runtime/claude/bin/todo.sh +9 -0
- package/runtime/claude/bin/view-render.sh +126 -0
- package/runtime/claude/bin/worktree-create.sh +145 -0
- package/runtime/claude/bin/worktree-remove.sh +50 -0
- package/runtime/claude/bin/worktree.sh +198 -0
- package/runtime/claude/egregore_runtime/__init__.py +228 -0
- package/runtime/claude/egregore_runtime/adapters/__init__.py +5 -0
- package/runtime/claude/egregore_runtime/adapters/invitation.py +237 -0
- package/runtime/claude/egregore_runtime/adapters/notification.py +59 -0
- package/runtime/claude/egregore_runtime/adapters/qmd.py +1701 -0
- package/runtime/claude/egregore_runtime/adapters/qmd_cli.py +210 -0
- package/runtime/claude/egregore_runtime/adapters/qmd_embed_worker.py +59 -0
- package/runtime/claude/egregore_runtime/admin_gate.py +102 -0
- package/runtime/claude/egregore_runtime/artifacts.py +523 -0
- package/runtime/claude/egregore_runtime/contracts.py +499 -0
- package/runtime/claude/egregore_runtime/entitlements.py +85 -0
- package/runtime/claude/egregore_runtime/errors.py +10 -0
- package/runtime/claude/egregore_runtime/graph.py +356 -0
- package/runtime/claude/egregore_runtime/handoff_lookup.py +196 -0
- package/runtime/claude/egregore_runtime/harness_cli.py +1221 -0
- package/runtime/claude/egregore_runtime/identity.py +734 -0
- package/runtime/claude/egregore_runtime/identity_cli.py +91 -0
- package/runtime/claude/egregore_runtime/ingest.py +575 -0
- package/runtime/claude/egregore_runtime/ingest_cli.py +254 -0
- package/runtime/claude/egregore_runtime/ingest_index_cli.py +147 -0
- package/runtime/claude/egregore_runtime/invitation_cli.py +51 -0
- package/runtime/claude/egregore_runtime/invitations.py +116 -0
- package/runtime/claude/egregore_runtime/issue_cli.py +305 -0
- package/runtime/claude/egregore_runtime/issues.py +359 -0
- package/runtime/claude/egregore_runtime/knowledge.py +376 -0
- package/runtime/claude/egregore_runtime/knowledge_cli.py +173 -0
- package/runtime/claude/egregore_runtime/lifecycle.py +888 -0
- package/runtime/claude/egregore_runtime/lifecycle_cli.py +269 -0
- package/runtime/claude/egregore_runtime/notification_cli.py +125 -0
- package/runtime/claude/egregore_runtime/notifications.py +230 -0
- package/runtime/claude/egregore_runtime/observe.py +342 -0
- package/runtime/claude/egregore_runtime/onboarding.py +290 -0
- package/runtime/claude/egregore_runtime/onboarding_cli.py +110 -0
- package/runtime/claude/egregore_runtime/policy.py +144 -0
- package/runtime/claude/egregore_runtime/profile.py +193 -0
- package/runtime/claude/egregore_runtime/quest_cli.py +216 -0
- package/runtime/claude/egregore_runtime/question_cli.py +180 -0
- package/runtime/claude/egregore_runtime/questions.py +304 -0
- package/runtime/claude/egregore_runtime/quests.py +419 -0
- package/runtime/claude/egregore_runtime/readiness.py +379 -0
- package/runtime/claude/egregore_runtime/research_ingest.py +256 -0
- package/runtime/claude/egregore_runtime/research_ingest_cli.py +44 -0
- package/runtime/claude/egregore_runtime/runtime.py +464 -0
- package/runtime/claude/egregore_runtime/scroll_cli.py +185 -0
- package/runtime/claude/egregore_runtime/scrolls.py +726 -0
- package/runtime/claude/egregore_runtime/status.py +412 -0
- package/runtime/claude/egregore_runtime/status_cli.py +174 -0
- package/runtime/claude/egregore_runtime/sync.py +218 -0
- package/runtime/claude/egregore_runtime/teamsync.py +139 -0
- package/runtime/claude/egregore_runtime/telemetry.py +640 -0
- package/runtime/claude/egregore_runtime/thread_cli.py +173 -0
- package/runtime/claude/egregore_runtime/threads.py +517 -0
- package/runtime/claude/egregore_runtime/todo_cli.py +142 -0
- package/runtime/claude/egregore_runtime/todos.py +244 -0
- package/runtime/claude/egregore_runtime/upgrade.py +770 -0
- package/runtime/claude/egregore_runtime/upgrade_worker.py +53 -0
- package/runtime/claude/egregore_runtime/writeback.py +391 -0
- package/runtime/claude/egregore_runtime/writeback_cli.py +114 -0
- package/runtime/claude/manifest.json +284 -0
- package/runtime/codex/.codex/hooks/search-hint.js +21 -52
- package/runtime/codex/.codex/hooks.json +3 -3
- package/runtime/codex/.codex/skills/activity/SKILL.md +23 -66
- package/runtime/codex/.codex/skills/announce/SKILL.md +27 -50
- package/runtime/codex/.codex/skills/archive/SKILL.md +21 -36
- package/runtime/codex/.codex/skills/checkup/SKILL.md +77 -36
- package/runtime/codex/.codex/skills/dark-mode/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/dashboard/SKILL.md +17 -47
- package/runtime/codex/.codex/skills/handoff/SKILL.md +84 -133
- package/runtime/codex/.codex/skills/harvest/SKILL.md +32 -117
- package/runtime/codex/.codex/skills/ingest/SKILL.md +47 -25
- package/runtime/codex/.codex/skills/ingest-user-interview/SKILL.md +18 -36
- package/runtime/codex/.codex/skills/ingest-user-interview/references/analysis-contract.md +42 -0
- package/runtime/codex/.codex/skills/invite/SKILL.md +21 -35
- package/runtime/codex/.codex/skills/issue/SKILL.md +60 -41
- package/runtime/codex/.codex/skills/me/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/meeting/SKILL.md +16 -36
- package/runtime/codex/.codex/skills/meeting/references/analysis-contract.md +48 -0
- package/runtime/codex/.codex/skills/note/SKILL.md +30 -25
- package/runtime/codex/.codex/skills/onboarding/SKILL.md +36 -41
- package/runtime/codex/.codex/skills/pull/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/quest/SKILL.md +27 -66
- package/runtime/codex/.codex/skills/reflect/SKILL.md +22 -36
- package/runtime/codex/.codex/skills/save/SKILL.md +2 -2
- package/runtime/codex/.codex/skills/telemetry/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/test/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/tutorial/SKILL.md +40 -41
- package/runtime/codex/.codex/skills/view/SKILL.md +115 -111
- package/runtime/codex/.codex/skills/wrap/SKILL.md +45 -41
- package/runtime/codex/.codex/spec-manifest.json +9 -9
- package/runtime/codex/AGENTS.md +62 -58
- package/runtime/codex/bin/activity-action.sh +92 -0
- package/runtime/codex/bin/activity-data.sh +12 -233
- package/runtime/codex/bin/agent.sh +84 -100
- package/runtime/codex/bin/artifact-register.sh +164 -0
- package/runtime/codex/bin/artifact-writeback.sh +53 -0
- package/runtime/codex/bin/artifacts.sh +288 -0
- package/runtime/codex/bin/build-sealed-dataroom.mjs +722 -0
- package/runtime/codex/bin/capture-run.sh +18 -2
- package/runtime/codex/bin/codex-render-spec.mjs +41 -16
- package/runtime/codex/bin/codex-session-start.sh +14 -4
- package/runtime/codex/bin/codex-skill-render.mjs +9 -7
- package/runtime/codex/bin/corpus_map.py +806 -0
- package/runtime/codex/bin/corpus_survey.py +304 -0
- package/runtime/codex/bin/dashboard-data.sh +11 -171
- package/runtime/codex/bin/github-auth.sh +121 -0
- package/runtime/codex/bin/graph-batch.sh +7 -0
- package/runtime/codex/bin/graph-op.sh +4 -0
- package/runtime/codex/bin/graph-projection.sh +54 -0
- package/runtime/codex/bin/graph-wal.sh +13 -0
- package/runtime/codex/bin/graph.sh +44 -0
- package/runtime/codex/bin/handoff-lifecycle-job.sh +146 -0
- package/runtime/codex/bin/handoff-lifecycle.sh +280 -0
- package/runtime/codex/bin/handoff-preview.sh +169 -0
- package/runtime/codex/bin/handoff-run.sh +42 -26
- package/runtime/codex/bin/ingest-graph.sh +6 -0
- package/runtime/codex/bin/ingest-item.sh +10 -0
- package/runtime/codex/bin/ingest.sh +59 -30
- package/runtime/codex/bin/invite.sh +6 -0
- package/runtime/codex/bin/issue.sh +10 -0
- package/runtime/codex/bin/knowledge.sh +10 -0
- package/runtime/codex/bin/lib/config.sh +20 -0
- package/runtime/codex/bin/lib/context.sh +46 -25
- package/runtime/codex/bin/lib/git-sync.sh +70 -6
- package/runtime/codex/bin/lib/greeting.sh +34 -5
- package/runtime/codex/bin/lib/handoff-meta.sh +97 -0
- package/runtime/codex/bin/lib/worktree-links.sh +6 -0
- package/runtime/codex/bin/node-run.sh +42 -0
- package/runtime/codex/bin/notification.sh +6 -0
- package/runtime/codex/bin/observe-context.sh +14 -0
- package/runtime/codex/bin/onboarding.sh +6 -0
- package/runtime/codex/bin/person.py +4 -0
- package/runtime/codex/bin/person.sh +43 -10
- package/runtime/codex/bin/pi-render-spec.mjs +140 -0
- package/runtime/codex/bin/post-update.sh +37 -0
- package/runtime/codex/bin/prime-render-spec.mjs +147 -0
- package/runtime/codex/bin/publish-artifact.sh +55 -2
- package/runtime/codex/bin/quest.sh +9 -0
- package/runtime/codex/bin/question.sh +8 -0
- package/runtime/codex/bin/research-ingest.sh +7 -0
- package/runtime/codex/bin/restore-owned-skills.sh +67 -0
- package/runtime/codex/bin/retrieval_eval.py +385 -0
- package/runtime/codex/bin/runtime-identity.sh +70 -0
- package/runtime/codex/bin/runtime-upgrade.sh +43 -0
- package/runtime/codex/bin/scroll.sh +9 -0
- package/runtime/codex/bin/search.sh +231 -195
- package/runtime/codex/bin/settings.sh +174 -5
- package/runtime/codex/bin/startup-check.sh +136 -0
- package/runtime/codex/bin/telemetry.sh +522 -247
- package/runtime/codex/bin/test-changes.sh +344 -0
- package/runtime/codex/bin/thread.sh +9 -0
- package/runtime/codex/bin/todo.sh +9 -0
- package/runtime/codex/bin/view-render.sh +126 -0
- package/runtime/codex/bin/worktree-create.sh +19 -5
- package/runtime/codex/egregore_runtime/__init__.py +228 -0
- package/runtime/codex/egregore_runtime/adapters/__init__.py +5 -0
- package/runtime/codex/egregore_runtime/adapters/invitation.py +237 -0
- package/runtime/codex/egregore_runtime/adapters/notification.py +59 -0
- package/runtime/codex/egregore_runtime/adapters/qmd.py +1701 -0
- package/runtime/codex/egregore_runtime/adapters/qmd_cli.py +210 -0
- package/runtime/codex/egregore_runtime/adapters/qmd_embed_worker.py +59 -0
- package/runtime/codex/egregore_runtime/admin_gate.py +102 -0
- package/runtime/codex/egregore_runtime/artifacts.py +523 -0
- package/runtime/codex/egregore_runtime/contracts.py +499 -0
- package/runtime/codex/egregore_runtime/entitlements.py +85 -0
- package/runtime/codex/egregore_runtime/errors.py +10 -0
- package/runtime/codex/egregore_runtime/graph.py +356 -0
- package/runtime/codex/egregore_runtime/handoff_lookup.py +196 -0
- package/runtime/codex/egregore_runtime/harness_cli.py +1221 -0
- package/runtime/codex/egregore_runtime/identity.py +734 -0
- package/runtime/codex/egregore_runtime/identity_cli.py +91 -0
- package/runtime/codex/egregore_runtime/ingest.py +575 -0
- package/runtime/codex/egregore_runtime/ingest_cli.py +254 -0
- package/runtime/codex/egregore_runtime/ingest_index_cli.py +147 -0
- package/runtime/codex/egregore_runtime/invitation_cli.py +51 -0
- package/runtime/codex/egregore_runtime/invitations.py +116 -0
- package/runtime/codex/egregore_runtime/issue_cli.py +305 -0
- package/runtime/codex/egregore_runtime/issues.py +359 -0
- package/runtime/codex/egregore_runtime/knowledge.py +376 -0
- package/runtime/codex/egregore_runtime/knowledge_cli.py +173 -0
- package/runtime/codex/egregore_runtime/lifecycle.py +888 -0
- package/runtime/codex/egregore_runtime/lifecycle_cli.py +269 -0
- package/runtime/codex/egregore_runtime/notification_cli.py +125 -0
- package/runtime/codex/egregore_runtime/notifications.py +230 -0
- package/runtime/codex/egregore_runtime/observe.py +342 -0
- package/runtime/codex/egregore_runtime/onboarding.py +290 -0
- package/runtime/codex/egregore_runtime/onboarding_cli.py +110 -0
- package/runtime/codex/egregore_runtime/policy.py +144 -0
- package/runtime/codex/egregore_runtime/profile.py +193 -0
- package/runtime/codex/egregore_runtime/quest_cli.py +216 -0
- package/runtime/codex/egregore_runtime/question_cli.py +180 -0
- package/runtime/codex/egregore_runtime/questions.py +304 -0
- package/runtime/codex/egregore_runtime/quests.py +419 -0
- package/runtime/codex/egregore_runtime/readiness.py +379 -0
- package/runtime/codex/egregore_runtime/research_ingest.py +256 -0
- package/runtime/codex/egregore_runtime/research_ingest_cli.py +44 -0
- package/runtime/codex/egregore_runtime/runtime.py +464 -0
- package/runtime/codex/egregore_runtime/scroll_cli.py +185 -0
- package/runtime/codex/egregore_runtime/scrolls.py +726 -0
- package/runtime/codex/egregore_runtime/status.py +412 -0
- package/runtime/codex/egregore_runtime/status_cli.py +174 -0
- package/runtime/codex/egregore_runtime/sync.py +218 -0
- package/runtime/codex/egregore_runtime/teamsync.py +139 -0
- package/runtime/codex/egregore_runtime/telemetry.py +640 -0
- package/runtime/codex/egregore_runtime/thread_cli.py +173 -0
- package/runtime/codex/egregore_runtime/threads.py +517 -0
- package/runtime/codex/egregore_runtime/todo_cli.py +142 -0
- package/runtime/codex/egregore_runtime/todos.py +244 -0
- package/runtime/codex/egregore_runtime/upgrade.py +770 -0
- package/runtime/codex/egregore_runtime/upgrade_worker.py +53 -0
- package/runtime/codex/egregore_runtime/writeback.py +391 -0
- package/runtime/codex/egregore_runtime/writeback_cli.py +114 -0
- package/runtime/codex/manifest.json +98 -1
- package/runtime/pi/.codex/hooks/search-hint.js +21 -52
- package/runtime/pi/.codex/hooks.json +3 -3
- package/runtime/pi/.codex/skills/activity/SKILL.md +23 -66
- package/runtime/pi/.codex/skills/announce/SKILL.md +27 -50
- package/runtime/pi/.codex/skills/archive/SKILL.md +21 -36
- package/runtime/pi/.codex/skills/checkup/SKILL.md +77 -36
- package/runtime/pi/.codex/skills/dark-mode/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/dashboard/SKILL.md +17 -47
- package/runtime/pi/.codex/skills/handoff/SKILL.md +84 -133
- package/runtime/pi/.codex/skills/harvest/SKILL.md +32 -117
- package/runtime/pi/.codex/skills/ingest/SKILL.md +47 -25
- package/runtime/pi/.codex/skills/ingest-user-interview/SKILL.md +18 -36
- package/runtime/pi/.codex/skills/ingest-user-interview/references/analysis-contract.md +42 -0
- package/runtime/pi/.codex/skills/invite/SKILL.md +21 -35
- package/runtime/pi/.codex/skills/issue/SKILL.md +60 -41
- package/runtime/pi/.codex/skills/me/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/meeting/SKILL.md +16 -36
- package/runtime/pi/.codex/skills/meeting/references/analysis-contract.md +48 -0
- package/runtime/pi/.codex/skills/note/SKILL.md +30 -25
- package/runtime/pi/.codex/skills/onboarding/SKILL.md +36 -41
- package/runtime/pi/.codex/skills/pull/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/quest/SKILL.md +27 -66
- package/runtime/pi/.codex/skills/reflect/SKILL.md +22 -36
- package/runtime/pi/.codex/skills/save/SKILL.md +2 -2
- package/runtime/pi/.codex/skills/telemetry/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/test/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/tutorial/SKILL.md +40 -41
- package/runtime/pi/.codex/skills/view/SKILL.md +115 -111
- package/runtime/pi/.codex/skills/wrap/SKILL.md +45 -41
- package/runtime/pi/.codex/spec-manifest.json +9 -9
- package/runtime/pi/.pi/APPEND_SYSTEM.md +57 -48
- package/runtime/pi/.pi/extensions/egregore.ts +7 -0
- package/runtime/pi/.pi/spec-manifest.json +2 -2
- package/runtime/pi/AGENTS.md +62 -58
- package/runtime/pi/bin/activity-action.sh +92 -0
- package/runtime/pi/bin/activity-data.sh +12 -233
- package/runtime/pi/bin/agent.sh +84 -100
- package/runtime/pi/bin/artifact-register.sh +164 -0
- package/runtime/pi/bin/artifact-writeback.sh +53 -0
- package/runtime/pi/bin/artifacts.sh +288 -0
- package/runtime/pi/bin/build-sealed-dataroom.mjs +722 -0
- package/runtime/pi/bin/capture-run.sh +18 -2
- package/runtime/pi/bin/codex-render-spec.mjs +41 -16
- package/runtime/pi/bin/codex-session-start.sh +14 -4
- package/runtime/pi/bin/codex-skill-render.mjs +9 -7
- package/runtime/pi/bin/corpus_map.py +806 -0
- package/runtime/pi/bin/corpus_survey.py +304 -0
- package/runtime/pi/bin/dashboard-data.sh +11 -171
- package/runtime/pi/bin/github-auth.sh +121 -0
- package/runtime/pi/bin/graph-batch.sh +7 -0
- package/runtime/pi/bin/graph-op.sh +4 -0
- package/runtime/pi/bin/graph-projection.sh +54 -0
- package/runtime/pi/bin/graph-wal.sh +13 -0
- package/runtime/pi/bin/graph.sh +44 -0
- package/runtime/pi/bin/handoff-lifecycle-job.sh +146 -0
- package/runtime/pi/bin/handoff-lifecycle.sh +280 -0
- package/runtime/pi/bin/handoff-preview.sh +169 -0
- package/runtime/pi/bin/handoff-run.sh +42 -26
- package/runtime/pi/bin/ingest-graph.sh +6 -0
- package/runtime/pi/bin/ingest-item.sh +10 -0
- package/runtime/pi/bin/ingest.sh +59 -30
- package/runtime/pi/bin/invite.sh +6 -0
- package/runtime/pi/bin/issue.sh +10 -0
- package/runtime/pi/bin/knowledge.sh +10 -0
- package/runtime/pi/bin/lib/config.sh +20 -0
- package/runtime/pi/bin/lib/context.sh +46 -25
- package/runtime/pi/bin/lib/git-sync.sh +70 -6
- package/runtime/pi/bin/lib/greeting.sh +34 -5
- package/runtime/pi/bin/lib/handoff-meta.sh +97 -0
- package/runtime/pi/bin/lib/worktree-links.sh +6 -0
- package/runtime/pi/bin/node-run.sh +42 -0
- package/runtime/pi/bin/notification.sh +6 -0
- package/runtime/pi/bin/observe-context.sh +14 -0
- package/runtime/pi/bin/onboarding.sh +6 -0
- package/runtime/pi/bin/person.py +4 -0
- package/runtime/pi/bin/person.sh +43 -10
- package/runtime/pi/bin/pi-observe-context.mjs +93 -0
- package/runtime/pi/bin/pi-product-workflows.mjs +1 -1
- package/runtime/pi/bin/pi-render-spec.mjs +1 -1
- package/runtime/pi/bin/post-update.sh +37 -0
- package/runtime/pi/bin/prime-render-spec.mjs +147 -0
- package/runtime/pi/bin/publish-artifact.sh +55 -2
- package/runtime/pi/bin/quest.sh +9 -0
- package/runtime/pi/bin/question.sh +8 -0
- package/runtime/pi/bin/research-ingest.sh +7 -0
- package/runtime/pi/bin/restore-owned-skills.sh +67 -0
- package/runtime/pi/bin/retrieval_eval.py +385 -0
- package/runtime/pi/bin/runtime-identity.sh +70 -0
- package/runtime/pi/bin/runtime-upgrade.sh +43 -0
- package/runtime/pi/bin/scroll.sh +9 -0
- package/runtime/pi/bin/search.sh +231 -195
- package/runtime/pi/bin/settings.sh +174 -5
- package/runtime/pi/bin/startup-check.sh +136 -0
- package/runtime/pi/bin/telemetry.sh +522 -247
- package/runtime/pi/bin/test-changes.sh +344 -0
- package/runtime/pi/bin/thread.sh +9 -0
- package/runtime/pi/bin/todo.sh +9 -0
- package/runtime/pi/bin/view-render.sh +126 -0
- package/runtime/pi/bin/worktree-create.sh +19 -5
- package/runtime/pi/egregore_runtime/__init__.py +228 -0
- package/runtime/pi/egregore_runtime/adapters/__init__.py +5 -0
- package/runtime/pi/egregore_runtime/adapters/invitation.py +237 -0
- package/runtime/pi/egregore_runtime/adapters/notification.py +59 -0
- package/runtime/pi/egregore_runtime/adapters/qmd.py +1701 -0
- package/runtime/pi/egregore_runtime/adapters/qmd_cli.py +210 -0
- package/runtime/pi/egregore_runtime/adapters/qmd_embed_worker.py +59 -0
- package/runtime/pi/egregore_runtime/admin_gate.py +102 -0
- package/runtime/pi/egregore_runtime/artifacts.py +523 -0
- package/runtime/pi/egregore_runtime/contracts.py +499 -0
- package/runtime/pi/egregore_runtime/entitlements.py +85 -0
- package/runtime/pi/egregore_runtime/errors.py +10 -0
- package/runtime/pi/egregore_runtime/graph.py +356 -0
- package/runtime/pi/egregore_runtime/handoff_lookup.py +196 -0
- package/runtime/pi/egregore_runtime/harness_cli.py +1221 -0
- package/runtime/pi/egregore_runtime/identity.py +734 -0
- package/runtime/pi/egregore_runtime/identity_cli.py +91 -0
- package/runtime/pi/egregore_runtime/ingest.py +575 -0
- package/runtime/pi/egregore_runtime/ingest_cli.py +254 -0
- package/runtime/pi/egregore_runtime/ingest_index_cli.py +147 -0
- package/runtime/pi/egregore_runtime/invitation_cli.py +51 -0
- package/runtime/pi/egregore_runtime/invitations.py +116 -0
- package/runtime/pi/egregore_runtime/issue_cli.py +305 -0
- package/runtime/pi/egregore_runtime/issues.py +359 -0
- package/runtime/pi/egregore_runtime/knowledge.py +376 -0
- package/runtime/pi/egregore_runtime/knowledge_cli.py +173 -0
- package/runtime/pi/egregore_runtime/lifecycle.py +888 -0
- package/runtime/pi/egregore_runtime/lifecycle_cli.py +269 -0
- package/runtime/pi/egregore_runtime/notification_cli.py +125 -0
- package/runtime/pi/egregore_runtime/notifications.py +230 -0
- package/runtime/pi/egregore_runtime/observe.py +342 -0
- package/runtime/pi/egregore_runtime/onboarding.py +290 -0
- package/runtime/pi/egregore_runtime/onboarding_cli.py +110 -0
- package/runtime/pi/egregore_runtime/policy.py +144 -0
- package/runtime/pi/egregore_runtime/profile.py +193 -0
- package/runtime/pi/egregore_runtime/quest_cli.py +216 -0
- package/runtime/pi/egregore_runtime/question_cli.py +180 -0
- package/runtime/pi/egregore_runtime/questions.py +304 -0
- package/runtime/pi/egregore_runtime/quests.py +419 -0
- package/runtime/pi/egregore_runtime/readiness.py +379 -0
- package/runtime/pi/egregore_runtime/research_ingest.py +256 -0
- package/runtime/pi/egregore_runtime/research_ingest_cli.py +44 -0
- package/runtime/pi/egregore_runtime/runtime.py +464 -0
- package/runtime/pi/egregore_runtime/scroll_cli.py +185 -0
- package/runtime/pi/egregore_runtime/scrolls.py +726 -0
- package/runtime/pi/egregore_runtime/status.py +412 -0
- package/runtime/pi/egregore_runtime/status_cli.py +174 -0
- package/runtime/pi/egregore_runtime/sync.py +218 -0
- package/runtime/pi/egregore_runtime/teamsync.py +139 -0
- package/runtime/pi/egregore_runtime/telemetry.py +640 -0
- package/runtime/pi/egregore_runtime/thread_cli.py +173 -0
- package/runtime/pi/egregore_runtime/threads.py +517 -0
- package/runtime/pi/egregore_runtime/todo_cli.py +142 -0
- package/runtime/pi/egregore_runtime/todos.py +244 -0
- package/runtime/pi/egregore_runtime/upgrade.py +770 -0
- package/runtime/pi/egregore_runtime/upgrade_worker.py +53 -0
- package/runtime/pi/egregore_runtime/writeback.py +391 -0
- package/runtime/pi/egregore_runtime/writeback_cli.py +114 -0
- package/runtime/pi/manifest.json +98 -1
- package/runtime/prime/.codex/hooks/search-hint.js +21 -52
- package/runtime/prime/.codex/hooks.json +3 -3
- package/runtime/prime/.codex/skills/activity/SKILL.md +23 -66
- package/runtime/prime/.codex/skills/announce/SKILL.md +27 -50
- package/runtime/prime/.codex/skills/archive/SKILL.md +21 -36
- package/runtime/prime/.codex/skills/checkup/SKILL.md +77 -36
- package/runtime/prime/.codex/skills/dark-mode/SKILL.md +1 -1
- package/runtime/prime/.codex/skills/dashboard/SKILL.md +17 -47
- package/runtime/prime/.codex/skills/handoff/SKILL.md +84 -133
- package/runtime/prime/.codex/skills/harvest/SKILL.md +32 -117
- package/runtime/prime/.codex/skills/ingest/SKILL.md +47 -25
- package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +18 -36
- package/runtime/prime/.codex/skills/ingest-user-interview/references/analysis-contract.md +42 -0
- package/runtime/prime/.codex/skills/invite/SKILL.md +21 -35
- package/runtime/prime/.codex/skills/issue/SKILL.md +60 -41
- package/runtime/prime/.codex/skills/me/SKILL.md +1 -1
- package/runtime/prime/.codex/skills/meeting/SKILL.md +16 -36
- package/runtime/prime/.codex/skills/meeting/references/analysis-contract.md +48 -0
- package/runtime/prime/.codex/skills/note/SKILL.md +30 -25
- package/runtime/prime/.codex/skills/onboarding/SKILL.md +36 -41
- package/runtime/prime/.codex/skills/pull/SKILL.md +1 -1
- package/runtime/prime/.codex/skills/quest/SKILL.md +27 -66
- package/runtime/prime/.codex/skills/reflect/SKILL.md +22 -36
- package/runtime/prime/.codex/skills/save/SKILL.md +2 -2
- package/runtime/prime/.codex/skills/telemetry/SKILL.md +1 -1
- package/runtime/prime/.codex/skills/test/SKILL.md +1 -1
- package/runtime/prime/.codex/skills/tutorial/SKILL.md +40 -41
- package/runtime/prime/.codex/skills/view/SKILL.md +115 -111
- package/runtime/prime/.codex/skills/wrap/SKILL.md +45 -41
- package/runtime/prime/.codex/spec-manifest.json +9 -9
- package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +57 -48
- package/runtime/prime/.prime/agent/extensions/egregore.ts +7 -0
- package/runtime/prime/.prime/agent/spec-manifest.json +2 -2
- package/runtime/prime/AGENTS.md +62 -58
- package/runtime/prime/bin/activity-action.sh +92 -0
- package/runtime/prime/bin/activity-data.sh +12 -233
- package/runtime/prime/bin/agent.sh +84 -100
- package/runtime/prime/bin/artifact-register.sh +164 -0
- package/runtime/prime/bin/artifact-writeback.sh +53 -0
- package/runtime/prime/bin/artifacts.sh +288 -0
- package/runtime/prime/bin/build-sealed-dataroom.mjs +722 -0
- package/runtime/prime/bin/capture-run.sh +18 -2
- package/runtime/prime/bin/codex-render-spec.mjs +41 -16
- package/runtime/prime/bin/codex-session-start.sh +14 -4
- package/runtime/prime/bin/codex-skill-render.mjs +9 -7
- package/runtime/prime/bin/corpus_map.py +806 -0
- package/runtime/prime/bin/corpus_survey.py +304 -0
- package/runtime/prime/bin/dashboard-data.sh +11 -171
- package/runtime/prime/bin/github-auth.sh +121 -0
- package/runtime/prime/bin/graph-batch.sh +7 -0
- package/runtime/prime/bin/graph-op.sh +4 -0
- package/runtime/prime/bin/graph-projection.sh +54 -0
- package/runtime/prime/bin/graph-wal.sh +13 -0
- package/runtime/prime/bin/graph.sh +44 -0
- package/runtime/prime/bin/handoff-lifecycle-job.sh +146 -0
- package/runtime/prime/bin/handoff-lifecycle.sh +280 -0
- package/runtime/prime/bin/handoff-preview.sh +169 -0
- package/runtime/prime/bin/handoff-run.sh +42 -26
- package/runtime/prime/bin/ingest-graph.sh +6 -0
- package/runtime/prime/bin/ingest-item.sh +10 -0
- package/runtime/prime/bin/ingest.sh +59 -30
- package/runtime/prime/bin/invite.sh +6 -0
- package/runtime/prime/bin/issue.sh +10 -0
- package/runtime/prime/bin/knowledge.sh +10 -0
- package/runtime/prime/bin/lib/config.sh +20 -0
- package/runtime/prime/bin/lib/context.sh +46 -25
- package/runtime/prime/bin/lib/git-sync.sh +70 -6
- package/runtime/prime/bin/lib/greeting.sh +34 -5
- package/runtime/prime/bin/lib/handoff-meta.sh +97 -0
- package/runtime/prime/bin/lib/worktree-links.sh +6 -0
- package/runtime/prime/bin/node-run.sh +42 -0
- package/runtime/prime/bin/notification.sh +6 -0
- package/runtime/prime/bin/observe-context.sh +14 -0
- package/runtime/prime/bin/onboarding.sh +6 -0
- package/runtime/prime/bin/person.py +4 -0
- package/runtime/prime/bin/person.sh +43 -10
- package/runtime/prime/bin/pi-observe-context.mjs +93 -0
- package/runtime/prime/bin/pi-product-workflows.mjs +1 -1
- package/runtime/prime/bin/pi-render-spec.mjs +140 -0
- package/runtime/prime/bin/post-update.sh +37 -0
- package/runtime/prime/bin/prime-render-spec.mjs +1 -1
- package/runtime/prime/bin/publish-artifact.sh +55 -2
- package/runtime/prime/bin/quest.sh +9 -0
- package/runtime/prime/bin/question.sh +8 -0
- package/runtime/prime/bin/research-ingest.sh +7 -0
- package/runtime/prime/bin/restore-owned-skills.sh +67 -0
- package/runtime/prime/bin/retrieval_eval.py +385 -0
- package/runtime/prime/bin/runtime-identity.sh +70 -0
- package/runtime/prime/bin/runtime-upgrade.sh +43 -0
- package/runtime/prime/bin/scroll.sh +9 -0
- package/runtime/prime/bin/search.sh +231 -195
- package/runtime/prime/bin/settings.sh +174 -5
- package/runtime/prime/bin/startup-check.sh +136 -0
- package/runtime/prime/bin/telemetry.sh +522 -247
- package/runtime/prime/bin/test-changes.sh +344 -0
- package/runtime/prime/bin/thread.sh +9 -0
- package/runtime/prime/bin/todo.sh +9 -0
- package/runtime/prime/bin/view-render.sh +126 -0
- package/runtime/prime/bin/worktree-create.sh +19 -5
- package/runtime/prime/egregore_runtime/__init__.py +228 -0
- package/runtime/prime/egregore_runtime/adapters/__init__.py +5 -0
- package/runtime/prime/egregore_runtime/adapters/invitation.py +237 -0
- package/runtime/prime/egregore_runtime/adapters/notification.py +59 -0
- package/runtime/prime/egregore_runtime/adapters/qmd.py +1701 -0
- package/runtime/prime/egregore_runtime/adapters/qmd_cli.py +210 -0
- package/runtime/prime/egregore_runtime/adapters/qmd_embed_worker.py +59 -0
- package/runtime/prime/egregore_runtime/admin_gate.py +102 -0
- package/runtime/prime/egregore_runtime/artifacts.py +523 -0
- package/runtime/prime/egregore_runtime/contracts.py +499 -0
- package/runtime/prime/egregore_runtime/entitlements.py +85 -0
- package/runtime/prime/egregore_runtime/errors.py +10 -0
- package/runtime/prime/egregore_runtime/graph.py +356 -0
- package/runtime/prime/egregore_runtime/handoff_lookup.py +196 -0
- package/runtime/prime/egregore_runtime/harness_cli.py +1221 -0
- package/runtime/prime/egregore_runtime/identity.py +734 -0
- package/runtime/prime/egregore_runtime/identity_cli.py +91 -0
- package/runtime/prime/egregore_runtime/ingest.py +575 -0
- package/runtime/prime/egregore_runtime/ingest_cli.py +254 -0
- package/runtime/prime/egregore_runtime/ingest_index_cli.py +147 -0
- package/runtime/prime/egregore_runtime/invitation_cli.py +51 -0
- package/runtime/prime/egregore_runtime/invitations.py +116 -0
- package/runtime/prime/egregore_runtime/issue_cli.py +305 -0
- package/runtime/prime/egregore_runtime/issues.py +359 -0
- package/runtime/prime/egregore_runtime/knowledge.py +376 -0
- package/runtime/prime/egregore_runtime/knowledge_cli.py +173 -0
- package/runtime/prime/egregore_runtime/lifecycle.py +888 -0
- package/runtime/prime/egregore_runtime/lifecycle_cli.py +269 -0
- package/runtime/prime/egregore_runtime/notification_cli.py +125 -0
- package/runtime/prime/egregore_runtime/notifications.py +230 -0
- package/runtime/prime/egregore_runtime/observe.py +342 -0
- package/runtime/prime/egregore_runtime/onboarding.py +290 -0
- package/runtime/prime/egregore_runtime/onboarding_cli.py +110 -0
- package/runtime/prime/egregore_runtime/policy.py +144 -0
- package/runtime/prime/egregore_runtime/profile.py +193 -0
- package/runtime/prime/egregore_runtime/quest_cli.py +216 -0
- package/runtime/prime/egregore_runtime/question_cli.py +180 -0
- package/runtime/prime/egregore_runtime/questions.py +304 -0
- package/runtime/prime/egregore_runtime/quests.py +419 -0
- package/runtime/prime/egregore_runtime/readiness.py +379 -0
- package/runtime/prime/egregore_runtime/research_ingest.py +256 -0
- package/runtime/prime/egregore_runtime/research_ingest_cli.py +44 -0
- package/runtime/prime/egregore_runtime/runtime.py +464 -0
- package/runtime/prime/egregore_runtime/scroll_cli.py +185 -0
- package/runtime/prime/egregore_runtime/scrolls.py +726 -0
- package/runtime/prime/egregore_runtime/status.py +412 -0
- package/runtime/prime/egregore_runtime/status_cli.py +174 -0
- package/runtime/prime/egregore_runtime/sync.py +218 -0
- package/runtime/prime/egregore_runtime/teamsync.py +139 -0
- package/runtime/prime/egregore_runtime/telemetry.py +640 -0
- package/runtime/prime/egregore_runtime/thread_cli.py +173 -0
- package/runtime/prime/egregore_runtime/threads.py +517 -0
- package/runtime/prime/egregore_runtime/todo_cli.py +142 -0
- package/runtime/prime/egregore_runtime/todos.py +244 -0
- package/runtime/prime/egregore_runtime/upgrade.py +770 -0
- package/runtime/prime/egregore_runtime/upgrade_worker.py +53 -0
- package/runtime/prime/egregore_runtime/writeback.py +391 -0
- package/runtime/prime/egregore_runtime/writeback_cli.py +114 -0
- package/runtime/prime/manifest.json +98 -1
|
@@ -0,0 +1,1701 @@
|
|
|
1
|
+
"""Local QMD implementation of the Egregore ``Retriever`` contract.
|
|
2
|
+
|
|
3
|
+
QMD-specific commands, query syntax, output parsing, model detection, and
|
|
4
|
+
daemon lifecycle are deliberately confined to this module. Callers submit
|
|
5
|
+
typed domain requests and receive domain results.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import socket
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
import uuid
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from dataclasses import dataclass, replace
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Mapping, Sequence
|
|
28
|
+
|
|
29
|
+
import fcntl
|
|
30
|
+
|
|
31
|
+
from egregore_runtime.contracts import (
|
|
32
|
+
RetrievalHit,
|
|
33
|
+
RetrievalMode,
|
|
34
|
+
RetrievalRequest,
|
|
35
|
+
RetrievalResult,
|
|
36
|
+
RetrieverHealth,
|
|
37
|
+
)
|
|
38
|
+
from egregore_runtime.artifacts import (
|
|
39
|
+
ArtifactSchemaError,
|
|
40
|
+
canonical_artifact_id,
|
|
41
|
+
split_frontmatter,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
QMD_VERSION = "2.8.3"
|
|
46
|
+
INDEX_SPEC_VERSION = "egregore-qmd-local/v2"
|
|
47
|
+
RUNTIME_SCHEMA_VERSION = "egregore-qmd-runtime/v1"
|
|
48
|
+
EMBEDDING_MODEL = "EmbeddingGemma"
|
|
49
|
+
MAX_AUTHORIZED_CANDIDATES = 2_000
|
|
50
|
+
# Every query this adapter issues — daemon and CLI — disables model
|
|
51
|
+
# reranking; results come from typed lex/vec rank fusion only. Readiness
|
|
52
|
+
# surfaces report this posture from here rather than restating it.
|
|
53
|
+
RERANKING_ENABLED = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class QmdAdapterError(RuntimeError):
|
|
57
|
+
"""Raised when the pinned local retrieval adapter cannot execute safely."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class _DocumentMetadata:
|
|
62
|
+
artifact_id: str
|
|
63
|
+
title: str | None = None
|
|
64
|
+
artifact_type: str | None = None
|
|
65
|
+
workstream: str | None = None
|
|
66
|
+
revision: str | None = None
|
|
67
|
+
content_hash: str | None = None
|
|
68
|
+
observed_at: datetime | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class QmdRuntimeMetadata:
|
|
73
|
+
schema_version: str
|
|
74
|
+
instance_id: str
|
|
75
|
+
generation: str
|
|
76
|
+
pid: int
|
|
77
|
+
port: int
|
|
78
|
+
endpoint: str
|
|
79
|
+
qmd_version: str
|
|
80
|
+
index_spec_version: str
|
|
81
|
+
index_name: str
|
|
82
|
+
index_path: str
|
|
83
|
+
collection: str
|
|
84
|
+
memory_root: str
|
|
85
|
+
started_at: str
|
|
86
|
+
process_started_at: str
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> "QmdRuntimeMetadata":
|
|
90
|
+
return cls(
|
|
91
|
+
schema_version=str(value["schema_version"]),
|
|
92
|
+
instance_id=str(value["instance_id"]),
|
|
93
|
+
generation=str(value["generation"]),
|
|
94
|
+
pid=int(value["pid"]),
|
|
95
|
+
port=int(value["port"]),
|
|
96
|
+
endpoint=str(value["endpoint"]),
|
|
97
|
+
qmd_version=str(value["qmd_version"]),
|
|
98
|
+
index_spec_version=str(value["index_spec_version"]),
|
|
99
|
+
index_name=str(value["index_name"]),
|
|
100
|
+
index_path=str(value["index_path"]),
|
|
101
|
+
collection=str(value["collection"]),
|
|
102
|
+
memory_root=str(value["memory_root"]),
|
|
103
|
+
started_at=str(value["started_at"]),
|
|
104
|
+
process_started_at=str(value["process_started_at"]),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> dict[str, str | int]:
|
|
108
|
+
return {
|
|
109
|
+
"schema_version": self.schema_version,
|
|
110
|
+
"instance_id": self.instance_id,
|
|
111
|
+
"generation": self.generation,
|
|
112
|
+
"pid": self.pid,
|
|
113
|
+
"port": self.port,
|
|
114
|
+
"endpoint": self.endpoint,
|
|
115
|
+
"qmd_version": self.qmd_version,
|
|
116
|
+
"index_spec_version": self.index_spec_version,
|
|
117
|
+
"index_name": self.index_name,
|
|
118
|
+
"index_path": self.index_path,
|
|
119
|
+
"collection": self.collection,
|
|
120
|
+
"memory_root": self.memory_root,
|
|
121
|
+
"started_at": self.started_at,
|
|
122
|
+
"process_started_at": self.process_started_at,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class QmdLocalRetriever:
|
|
127
|
+
"""QMD 2.8.3-backed local retriever with explicit typed rank fusion.
|
|
128
|
+
|
|
129
|
+
The persistent loopback daemon is preferred because it keeps local models
|
|
130
|
+
warm. Every daemon request supplies ``searches`` (never QMD's untyped
|
|
131
|
+
expansion input) and disables reranking. The exact same typed query is
|
|
132
|
+
used by the pinned CLI fallback.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
*,
|
|
138
|
+
repository_root: Path,
|
|
139
|
+
memory_root: Path | None = None,
|
|
140
|
+
collection: str | None = None,
|
|
141
|
+
collection_purpose: str = "memory",
|
|
142
|
+
qmd_command: Sequence[str] | None = None,
|
|
143
|
+
environment: Mapping[str, str] | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
self.repository_root = repository_root.resolve()
|
|
146
|
+
configured_memory = memory_root or self.repository_root / "memory"
|
|
147
|
+
self.memory_root = configured_memory.resolve()
|
|
148
|
+
self.environment = dict(os.environ if environment is None else environment)
|
|
149
|
+
self.collection_purpose = re.sub(
|
|
150
|
+
r"[^A-Za-z0-9_.-]", "-", collection_purpose.strip()
|
|
151
|
+
) or "memory"
|
|
152
|
+
self.collection = collection or self._default_collection()
|
|
153
|
+
self.index_name = self.environment.get("EGREGORE_QMD_INDEX_NAME") or (
|
|
154
|
+
f"egregore-{self.collection_purpose}-{self._instance_hash()[:12]}-v2"
|
|
155
|
+
)
|
|
156
|
+
self._configured_command = tuple(qmd_command) if qmd_command else None
|
|
157
|
+
self._resolved_command: tuple[str, ...] | None = None
|
|
158
|
+
self._version_checked = False
|
|
159
|
+
self._daemon_available = False
|
|
160
|
+
self._source_revision_cache: str | None = None
|
|
161
|
+
self._configure_isolated_environment()
|
|
162
|
+
self._prefer_usable_node_runtime()
|
|
163
|
+
|
|
164
|
+
def _configure_isolated_environment(self) -> None:
|
|
165
|
+
"""Give QMD its own database, config, PID files, logs, and models."""
|
|
166
|
+
|
|
167
|
+
runtime = self._runtime_root()
|
|
168
|
+
cache = runtime / "cache"
|
|
169
|
+
config = runtime / "config"
|
|
170
|
+
index = self._index_path()
|
|
171
|
+
self.environment["INDEX_PATH"] = str(index)
|
|
172
|
+
self.environment["QMD_CONFIG_DIR"] = str(config)
|
|
173
|
+
self.environment["XDG_CACHE_HOME"] = str(cache)
|
|
174
|
+
self.environment["XDG_CONFIG_HOME"] = str(config)
|
|
175
|
+
|
|
176
|
+
def _prefer_usable_node_runtime(self) -> None:
|
|
177
|
+
"""Put a working Node installation ahead of broken version-manager shims.
|
|
178
|
+
|
|
179
|
+
Harnesses do not always inherit the interactive shell's fully expanded
|
|
180
|
+
PATH. In particular, Volta's shim directory can remain visible while
|
|
181
|
+
the selected Node image directory is absent, causing every QMD/npm/npx
|
|
182
|
+
shebang to fail with ``Node is not available``. Validate candidates
|
|
183
|
+
instead of trusting their presence, then prepend the first usable
|
|
184
|
+
runtime without changing the parent process or the user's global Node
|
|
185
|
+
configuration.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
configured = self.environment.get("EGREGORE_QMD_NODE_BIN")
|
|
189
|
+
path_value = self.environment.get("PATH", "")
|
|
190
|
+
candidates: list[Path] = []
|
|
191
|
+
if configured:
|
|
192
|
+
candidates.append(Path(configured).expanduser())
|
|
193
|
+
resolved = shutil.which("node", path=path_value)
|
|
194
|
+
if resolved:
|
|
195
|
+
candidates.append(Path(resolved))
|
|
196
|
+
|
|
197
|
+
home = Path(self.environment.get("HOME", str(Path.home()))).expanduser()
|
|
198
|
+
volta_images = home / ".volta" / "tools" / "image" / "node"
|
|
199
|
+
if volta_images.is_dir():
|
|
200
|
+
candidates.extend(
|
|
201
|
+
sorted(
|
|
202
|
+
volta_images.glob("*/bin/node"),
|
|
203
|
+
key=lambda candidate: self._node_version_key(candidate),
|
|
204
|
+
reverse=True,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
candidates.extend((Path("/opt/homebrew/bin/node"), Path("/usr/local/bin/node")))
|
|
208
|
+
|
|
209
|
+
seen: set[str] = set()
|
|
210
|
+
for candidate in candidates:
|
|
211
|
+
try:
|
|
212
|
+
executable = candidate.resolve()
|
|
213
|
+
except OSError:
|
|
214
|
+
continue
|
|
215
|
+
key = str(executable)
|
|
216
|
+
if key in seen or not executable.is_file() or not os.access(executable, os.X_OK):
|
|
217
|
+
continue
|
|
218
|
+
seen.add(key)
|
|
219
|
+
try:
|
|
220
|
+
checked = subprocess.run(
|
|
221
|
+
(str(executable), "--version"),
|
|
222
|
+
cwd=self.repository_root,
|
|
223
|
+
env=self.environment,
|
|
224
|
+
text=True,
|
|
225
|
+
capture_output=True,
|
|
226
|
+
timeout=10,
|
|
227
|
+
check=False,
|
|
228
|
+
umask=0o077,
|
|
229
|
+
)
|
|
230
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
231
|
+
continue
|
|
232
|
+
if checked.returncode != 0 or not re.search(r"^v\d+\.\d+\.\d+", checked.stdout.strip()):
|
|
233
|
+
continue
|
|
234
|
+
node_dir = str(executable.parent)
|
|
235
|
+
entries = [entry for entry in path_value.split(os.pathsep) if entry]
|
|
236
|
+
entries = [entry for entry in entries if Path(entry).expanduser() != executable.parent]
|
|
237
|
+
self.environment["PATH"] = os.pathsep.join((node_dir, *entries))
|
|
238
|
+
self.environment["EGREGORE_QMD_NODE_BIN"] = str(executable)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _node_version_key(candidate: Path) -> tuple[int, ...]:
|
|
243
|
+
try:
|
|
244
|
+
return tuple(int(part) for part in candidate.parents[1].name.split("."))
|
|
245
|
+
except (ValueError, IndexError):
|
|
246
|
+
return ()
|
|
247
|
+
|
|
248
|
+
def _ensure_runtime_layout(self) -> None:
|
|
249
|
+
runtime = self._runtime_root()
|
|
250
|
+
for directory in (
|
|
251
|
+
runtime,
|
|
252
|
+
runtime / "cache",
|
|
253
|
+
runtime / "config",
|
|
254
|
+
self._index_path().parent,
|
|
255
|
+
self._models_path(),
|
|
256
|
+
):
|
|
257
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
258
|
+
os.chmod(directory, 0o700)
|
|
259
|
+
for private_file in (
|
|
260
|
+
self._index_path(),
|
|
261
|
+
self._runtime_state_path(),
|
|
262
|
+
self._index_state_path(),
|
|
263
|
+
self._semantic_state_path(),
|
|
264
|
+
):
|
|
265
|
+
if private_file.exists():
|
|
266
|
+
os.chmod(private_file, 0o600)
|
|
267
|
+
self._reuse_embedding_model()
|
|
268
|
+
|
|
269
|
+
def _reuse_embedding_model(self) -> None:
|
|
270
|
+
if self._embedding_model_available():
|
|
271
|
+
return
|
|
272
|
+
configured = (
|
|
273
|
+
self.environment.get("EGREGORE_QMD_REUSE_EMBEDDING_MODEL")
|
|
274
|
+
or self.environment.get("EGREGORE_QMD_MODELS_PATH")
|
|
275
|
+
)
|
|
276
|
+
candidates: list[Path] = []
|
|
277
|
+
if configured:
|
|
278
|
+
configured_path = Path(configured).expanduser()
|
|
279
|
+
if configured_path.is_dir():
|
|
280
|
+
candidates.extend(sorted(configured_path.glob("*embeddinggemma*")))
|
|
281
|
+
else:
|
|
282
|
+
candidates.append(configured_path)
|
|
283
|
+
home = Path(self.environment.get("HOME", str(Path.home()))).expanduser()
|
|
284
|
+
candidates.extend(
|
|
285
|
+
sorted((home / ".cache" / "qmd" / "models").glob("*embeddinggemma*"))
|
|
286
|
+
)
|
|
287
|
+
target_dir = self._models_path()
|
|
288
|
+
for source in candidates:
|
|
289
|
+
if not source.is_file():
|
|
290
|
+
continue
|
|
291
|
+
target = target_dir / source.name
|
|
292
|
+
if not target.exists():
|
|
293
|
+
target.symlink_to(source.resolve())
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
def _runtime_root(self) -> Path:
|
|
297
|
+
override = self.environment.get("EGREGORE_QMD_RUNTIME_DIR")
|
|
298
|
+
if override:
|
|
299
|
+
return Path(override).expanduser().resolve()
|
|
300
|
+
home = Path(self.environment.get("HOME", str(Path.home()))).expanduser()
|
|
301
|
+
return home / ".egregore" / "runtime" / "qmd" / self._instance_hash()
|
|
302
|
+
|
|
303
|
+
def _runtime_state_path(self) -> Path:
|
|
304
|
+
return self._runtime_root() / "runtime.json"
|
|
305
|
+
|
|
306
|
+
def _lifecycle_lock_path(self) -> Path:
|
|
307
|
+
return self._runtime_root() / "lifecycle.lock"
|
|
308
|
+
|
|
309
|
+
@contextmanager
|
|
310
|
+
def _lifecycle_lock(self):
|
|
311
|
+
self._ensure_runtime_layout()
|
|
312
|
+
path = self._lifecycle_lock_path()
|
|
313
|
+
with path.open("a+", encoding="utf-8") as handle:
|
|
314
|
+
os.chmod(path, 0o600)
|
|
315
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
316
|
+
try:
|
|
317
|
+
yield
|
|
318
|
+
finally:
|
|
319
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
320
|
+
|
|
321
|
+
# -- Retriever contract -------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def health(self) -> RetrieverHealth:
|
|
324
|
+
warnings: list[str] = []
|
|
325
|
+
canonical_ready = self.memory_root.is_dir()
|
|
326
|
+
qmd_available = True
|
|
327
|
+
if not canonical_ready:
|
|
328
|
+
warnings.append(f"canonical memory is unavailable: {self.memory_root}")
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
self._ensure_runtime_layout()
|
|
332
|
+
self._ensure_version()
|
|
333
|
+
except QmdAdapterError as exc:
|
|
334
|
+
qmd_available = False
|
|
335
|
+
warnings.append(str(exc))
|
|
336
|
+
|
|
337
|
+
status = ""
|
|
338
|
+
status_ok = False
|
|
339
|
+
if canonical_ready and qmd_available:
|
|
340
|
+
completed = self._run(("status",), timeout=20)
|
|
341
|
+
if completed.returncode == 0:
|
|
342
|
+
status = completed.stdout
|
|
343
|
+
status_ok = True
|
|
344
|
+
else:
|
|
345
|
+
warnings.append(self._failure_message("qmd status", completed))
|
|
346
|
+
|
|
347
|
+
counts = self._status_counts(status)
|
|
348
|
+
# BM25 readiness is usability, mirroring the semantic split: a
|
|
349
|
+
# canonical write leaves the index trailing, not unusable — queries
|
|
350
|
+
# refresh it inline. Only an identity mismatch makes it stale.
|
|
351
|
+
bm25_ready = bool(
|
|
352
|
+
status_ok
|
|
353
|
+
and counts["documents"] > 0
|
|
354
|
+
and self._index_state_usable()
|
|
355
|
+
)
|
|
356
|
+
semantic_ready = bool(
|
|
357
|
+
bm25_ready
|
|
358
|
+
and counts["vectors"] > 0
|
|
359
|
+
and counts["pending"] == 0
|
|
360
|
+
and self._semantic_state_current()
|
|
361
|
+
and not self._live_lock(self._embed_lock_path())
|
|
362
|
+
)
|
|
363
|
+
embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
|
|
364
|
+
if embedding_state == "missing_model":
|
|
365
|
+
warnings.append(
|
|
366
|
+
f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
runtime = self._owned_runtime()
|
|
370
|
+
if not canonical_ready or not qmd_available or not status_ok:
|
|
371
|
+
runtime_state = "runtime_unavailable"
|
|
372
|
+
elif self._persistent_enabled() and runtime is None:
|
|
373
|
+
runtime_state = "runtime_unavailable"
|
|
374
|
+
elif not self._index_state_usable() and self._index_path().exists():
|
|
375
|
+
runtime_state = "runtime_stale"
|
|
376
|
+
elif not bm25_ready:
|
|
377
|
+
runtime_state = "canonical_state_ready"
|
|
378
|
+
elif not semantic_ready:
|
|
379
|
+
runtime_state = "semantic_index_building"
|
|
380
|
+
else:
|
|
381
|
+
runtime_state = "semantic_ready"
|
|
382
|
+
|
|
383
|
+
return RetrieverHealth(
|
|
384
|
+
available=canonical_ready and qmd_available and status_ok,
|
|
385
|
+
adapter="qmd-local",
|
|
386
|
+
adapter_version=QMD_VERSION,
|
|
387
|
+
index_spec_version=INDEX_SPEC_VERSION,
|
|
388
|
+
index_revision=self._index_revision(),
|
|
389
|
+
source_revision=self._source_revision(),
|
|
390
|
+
embedding_state=embedding_state,
|
|
391
|
+
warnings=tuple(warnings),
|
|
392
|
+
canonical_state_ready=canonical_ready,
|
|
393
|
+
bm25_ready=bm25_ready,
|
|
394
|
+
semantic_ready=semantic_ready,
|
|
395
|
+
runtime_state=runtime_state,
|
|
396
|
+
runtime_pid=runtime.pid if runtime else None,
|
|
397
|
+
runtime_endpoint=runtime.endpoint if runtime else None,
|
|
398
|
+
index_path=str(self._index_path()),
|
|
399
|
+
collection=self.collection,
|
|
400
|
+
index_source_revision=self._indexed_source_revision(),
|
|
401
|
+
semantic_source_revision=self._semantic_source_revision(),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def start(self) -> RetrieverHealth:
|
|
405
|
+
"""Bring BM25 online, start/reuse the owned worker, then embed async."""
|
|
406
|
+
|
|
407
|
+
warnings: list[str] = []
|
|
408
|
+
self._source_revision_cache = None
|
|
409
|
+
self._ensure_ready(warnings)
|
|
410
|
+
if not self._ensure_daemon():
|
|
411
|
+
warnings.append("instance-owned QMD worker is unavailable; CLI fallback remains active")
|
|
412
|
+
if not self._semantic_state_current():
|
|
413
|
+
self._schedule_embed()
|
|
414
|
+
health = self.health()
|
|
415
|
+
if not warnings:
|
|
416
|
+
return health
|
|
417
|
+
return replace(health, warnings=tuple((*health.warnings, *warnings)))
|
|
418
|
+
|
|
419
|
+
def shutdown(self) -> RetrieverHealth:
|
|
420
|
+
"""Stop only a worker whose full instance/process identity is proven."""
|
|
421
|
+
|
|
422
|
+
with self._lifecycle_lock():
|
|
423
|
+
runtime = self._owned_runtime()
|
|
424
|
+
if runtime is not None:
|
|
425
|
+
stopped = self._run(("mcp", "stop"), timeout=20)
|
|
426
|
+
if stopped.returncode != 0 and self._pid_alive(runtime.pid):
|
|
427
|
+
raise QmdAdapterError(
|
|
428
|
+
self._failure_message("owned qmd worker shutdown", stopped)
|
|
429
|
+
)
|
|
430
|
+
self._runtime_state_path().unlink(missing_ok=True)
|
|
431
|
+
self._daemon_available = False
|
|
432
|
+
return self.health()
|
|
433
|
+
|
|
434
|
+
def _diagnostic_trace(self, phase: str, milliseconds: int, **extra: Any) -> None:
|
|
435
|
+
"""Local-only latency diagnostics, independent of telemetry consent.
|
|
436
|
+
|
|
437
|
+
EGREGORE_RETRIEVAL_TRACE=1 appends one JSON line per phase to
|
|
438
|
+
retrieval-trace.jsonl inside the instance runtime dir. Content-free
|
|
439
|
+
by construction: phases, timings, counts — never queries or text.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
if self.environment.get("EGREGORE_RETRIEVAL_TRACE") != "1":
|
|
443
|
+
return
|
|
444
|
+
try:
|
|
445
|
+
line = json.dumps(
|
|
446
|
+
{
|
|
447
|
+
"at": datetime.now(UTC).isoformat(),
|
|
448
|
+
"phase": phase,
|
|
449
|
+
"ms": milliseconds,
|
|
450
|
+
**extra,
|
|
451
|
+
},
|
|
452
|
+
sort_keys=True,
|
|
453
|
+
)
|
|
454
|
+
trace_path = self._runtime_root() / "retrieval-trace.jsonl"
|
|
455
|
+
with trace_path.open("a", encoding="utf-8") as handle:
|
|
456
|
+
handle.write(line + "\n")
|
|
457
|
+
os.chmod(trace_path, 0o600)
|
|
458
|
+
except OSError:
|
|
459
|
+
pass
|
|
460
|
+
|
|
461
|
+
def retrieve(self, request: RetrievalRequest) -> RetrievalResult:
|
|
462
|
+
started = time.monotonic()
|
|
463
|
+
warnings: list[str] = []
|
|
464
|
+
# A long-lived runtime may observe a new canonical Git revision between
|
|
465
|
+
# requests. Cache only within one retrieval operation.
|
|
466
|
+
self._source_revision_cache = None
|
|
467
|
+
self._ensure_ready(warnings)
|
|
468
|
+
|
|
469
|
+
requested_mode = request.mode
|
|
470
|
+
executed_mode = requested_mode
|
|
471
|
+
if requested_mode in (RetrievalMode.HYBRID, RetrievalMode.VEC):
|
|
472
|
+
# Hybrid retrieval stays available across canonical writes: the
|
|
473
|
+
# last complete vectors keep serving alongside current BM25 while
|
|
474
|
+
# a newer embedding builds. Lexical fallback happens only when no
|
|
475
|
+
# usable vector build exists at all.
|
|
476
|
+
if not self._semantic_state_usable():
|
|
477
|
+
executed_mode = RetrievalMode.LEX
|
|
478
|
+
warnings.append(
|
|
479
|
+
"no usable vector index exists yet; request used lexical fallback and embedding was scheduled"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
searches = self._typed_searches(request, executed_mode)
|
|
483
|
+
if not searches:
|
|
484
|
+
raise QmdAdapterError(
|
|
485
|
+
f"retrieval request {request.request_id!r} has no {executed_mode.value} query"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
candidate_limit = max(request.top_k * 8, 64)
|
|
489
|
+
raw_hits: list[dict[str, Any]]
|
|
490
|
+
try:
|
|
491
|
+
raw_hits = self._query(searches, candidate_limit, request.task)
|
|
492
|
+
except QmdAdapterError as exc:
|
|
493
|
+
if executed_mode is RetrievalMode.LEX:
|
|
494
|
+
raise
|
|
495
|
+
warnings.append(f"typed {executed_mode.value} retrieval failed: {exc}; used lexical fallback")
|
|
496
|
+
executed_mode = RetrievalMode.LEX
|
|
497
|
+
searches = self._typed_searches(request, executed_mode)
|
|
498
|
+
if not searches:
|
|
499
|
+
raise
|
|
500
|
+
raw_hits = self._query(searches, candidate_limit, request.task)
|
|
501
|
+
|
|
502
|
+
hits = self._authorized_hits(raw_hits, request, executed_mode)
|
|
503
|
+
while (
|
|
504
|
+
len(hits) < request.top_k
|
|
505
|
+
and len(raw_hits) >= candidate_limit
|
|
506
|
+
and candidate_limit < MAX_AUTHORIZED_CANDIDATES
|
|
507
|
+
):
|
|
508
|
+
candidate_limit = min(candidate_limit * 4, MAX_AUTHORIZED_CANDIDATES)
|
|
509
|
+
raw_hits = self._query(searches, candidate_limit, request.task)
|
|
510
|
+
hits = self._authorized_hits(raw_hits, request, executed_mode)
|
|
511
|
+
if len(hits) < request.top_k and len(raw_hits) >= MAX_AUTHORIZED_CANDIDATES:
|
|
512
|
+
warnings.append(
|
|
513
|
+
"authorized candidates may be incomplete after bounded policy-safe overfetch"
|
|
514
|
+
)
|
|
515
|
+
self._schedule_embed()
|
|
516
|
+
latency_ms = round((time.monotonic() - started) * 1000)
|
|
517
|
+
self._diagnostic_trace(
|
|
518
|
+
"retrieve",
|
|
519
|
+
latency_ms,
|
|
520
|
+
mode=str(requested_mode),
|
|
521
|
+
hits=len(hits[: request.top_k]),
|
|
522
|
+
degraded=executed_mode is not requested_mode or bool(warnings),
|
|
523
|
+
)
|
|
524
|
+
return RetrievalResult(
|
|
525
|
+
request_id=request.request_id,
|
|
526
|
+
mode=requested_mode,
|
|
527
|
+
hits=tuple(hits[: request.top_k]),
|
|
528
|
+
index_spec_version=INDEX_SPEC_VERSION,
|
|
529
|
+
index_revision=self._index_revision(),
|
|
530
|
+
source_revision=self._source_revision(),
|
|
531
|
+
latency_ms=latency_ms,
|
|
532
|
+
degraded=executed_mode is not requested_mode or bool(warnings),
|
|
533
|
+
warnings=tuple(warnings),
|
|
534
|
+
semantic_source_revision=(
|
|
535
|
+
self._semantic_source_revision()
|
|
536
|
+
if executed_mode in (RetrievalMode.HYBRID, RetrievalMode.VEC)
|
|
537
|
+
else None
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def open_source(self, hit: RetrievalHit) -> str:
|
|
542
|
+
started = time.monotonic()
|
|
543
|
+
source = self._source_path(hit.canonical_path)
|
|
544
|
+
if not source.is_file():
|
|
545
|
+
raise QmdAdapterError(f"canonical source is unavailable: {hit.canonical_path}")
|
|
546
|
+
content = source.read_text(encoding="utf-8")
|
|
547
|
+
self._diagnostic_trace(
|
|
548
|
+
"open_source",
|
|
549
|
+
round((time.monotonic() - started) * 1000),
|
|
550
|
+
bytes=len(content),
|
|
551
|
+
)
|
|
552
|
+
return content
|
|
553
|
+
|
|
554
|
+
def update(self, paths: Sequence[str] = ()) -> RetrieverHealth:
|
|
555
|
+
# QMD's update is already incremental. Its v2.8.3 CLI scans registered
|
|
556
|
+
# collections rather than accepting individual paths, so ``paths`` is a
|
|
557
|
+
# writeback hint rather than a different command shape.
|
|
558
|
+
del paths
|
|
559
|
+
self._source_revision_cache = None
|
|
560
|
+
if self._index_state_current():
|
|
561
|
+
# The index already matches the exact canonical revision — there
|
|
562
|
+
# is nothing to scan. Boot-path syncs stop paying QMD subprocess
|
|
563
|
+
# spawns for an unchanged corpus; any revision change (including
|
|
564
|
+
# a dirty-tree hash change) still takes the full update path.
|
|
565
|
+
return self.health()
|
|
566
|
+
# A canonical write makes the semantic build stale, never unusable:
|
|
567
|
+
# staleness is expressed by revision inequality alone, and the last
|
|
568
|
+
# complete vector receipt persists so hybrid keeps serving while the
|
|
569
|
+
# next embedding builds.
|
|
570
|
+
self._ensure_collection(force=True)
|
|
571
|
+
completed = self._run(("update",), timeout=300)
|
|
572
|
+
if completed.returncode != 0:
|
|
573
|
+
raise QmdAdapterError(self._failure_message("qmd update", completed))
|
|
574
|
+
self._write_index_state()
|
|
575
|
+
return self.health()
|
|
576
|
+
|
|
577
|
+
def embed_background(self) -> RetrieverHealth:
|
|
578
|
+
# Snapshot health before starting the worker. Once QMD begins embedding
|
|
579
|
+
# it may hold SQLite's writer lock, so a status call made afterwards can
|
|
580
|
+
# turn this nominally background operation into a foreground wait.
|
|
581
|
+
health = self.health()
|
|
582
|
+
if not self._schedule_embed():
|
|
583
|
+
return health
|
|
584
|
+
return replace(
|
|
585
|
+
health,
|
|
586
|
+
embedding_state="running",
|
|
587
|
+
semantic_ready=False,
|
|
588
|
+
runtime_state="semantic_index_building",
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def install_managed(self) -> Path:
|
|
592
|
+
"""Provision the exact Local runtime for later offline use."""
|
|
593
|
+
|
|
594
|
+
npm = shutil.which("npm", path=self.environment.get("PATH"))
|
|
595
|
+
if not npm:
|
|
596
|
+
raise QmdAdapterError(f"cannot install QMD {QMD_VERSION}: npm is unavailable")
|
|
597
|
+
prefix = self._managed_prefix()
|
|
598
|
+
prefix.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
599
|
+
completed = self._run_raw(
|
|
600
|
+
(
|
|
601
|
+
npm,
|
|
602
|
+
"install",
|
|
603
|
+
"--prefix",
|
|
604
|
+
str(prefix),
|
|
605
|
+
"--no-save",
|
|
606
|
+
"--no-package-lock",
|
|
607
|
+
f"@tobilu/qmd@{QMD_VERSION}",
|
|
608
|
+
),
|
|
609
|
+
timeout=900,
|
|
610
|
+
)
|
|
611
|
+
if completed.returncode != 0:
|
|
612
|
+
raise QmdAdapterError(self._failure_message("managed qmd install", completed))
|
|
613
|
+
binary = self._managed_binary()
|
|
614
|
+
if not binary.is_file() or not os.access(binary, os.X_OK):
|
|
615
|
+
raise QmdAdapterError("managed QMD install completed without an executable")
|
|
616
|
+
self._resolved_command = (str(binary),)
|
|
617
|
+
self._version_checked = False
|
|
618
|
+
self._ensure_version()
|
|
619
|
+
return binary
|
|
620
|
+
|
|
621
|
+
def _schedule_embed(self) -> bool:
|
|
622
|
+
if self.environment.get("EGREGORE_SEARCH_NO_WARM", "0") == "1":
|
|
623
|
+
return False
|
|
624
|
+
if self._semantic_state_current():
|
|
625
|
+
return False
|
|
626
|
+
|
|
627
|
+
with self._lifecycle_lock():
|
|
628
|
+
lock = self._embed_lock_path()
|
|
629
|
+
if self._live_lock(lock):
|
|
630
|
+
return True
|
|
631
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
632
|
+
source_revision = self._source_revision()
|
|
633
|
+
command = [
|
|
634
|
+
sys.executable,
|
|
635
|
+
"-m",
|
|
636
|
+
"egregore_runtime.adapters.qmd_embed_worker",
|
|
637
|
+
"--repository-root",
|
|
638
|
+
str(self.repository_root),
|
|
639
|
+
"--memory-root",
|
|
640
|
+
str(self.memory_root),
|
|
641
|
+
"--collection",
|
|
642
|
+
self.collection,
|
|
643
|
+
"--collection-purpose",
|
|
644
|
+
self.collection_purpose,
|
|
645
|
+
"--source-revision",
|
|
646
|
+
source_revision,
|
|
647
|
+
]
|
|
648
|
+
worker_environment = dict(self.environment)
|
|
649
|
+
package_root = str(Path(__file__).resolve().parents[2])
|
|
650
|
+
existing_pythonpath = worker_environment.get("PYTHONPATH")
|
|
651
|
+
worker_environment["PYTHONPATH"] = (
|
|
652
|
+
f"{package_root}{os.pathsep}{existing_pythonpath}"
|
|
653
|
+
if existing_pythonpath
|
|
654
|
+
else package_root
|
|
655
|
+
)
|
|
656
|
+
try:
|
|
657
|
+
process = subprocess.Popen(
|
|
658
|
+
command,
|
|
659
|
+
cwd=self.repository_root,
|
|
660
|
+
env=worker_environment,
|
|
661
|
+
stdin=subprocess.DEVNULL,
|
|
662
|
+
stdout=subprocess.DEVNULL,
|
|
663
|
+
stderr=subprocess.DEVNULL,
|
|
664
|
+
start_new_session=True,
|
|
665
|
+
umask=0o077,
|
|
666
|
+
)
|
|
667
|
+
except OSError as exc:
|
|
668
|
+
raise QmdAdapterError(f"could not start background embedding: {exc}") from exc
|
|
669
|
+
self._atomic_json(
|
|
670
|
+
lock,
|
|
671
|
+
{
|
|
672
|
+
"pid": process.pid,
|
|
673
|
+
"started_at": int(time.time()),
|
|
674
|
+
"source_revision": source_revision,
|
|
675
|
+
"index_spec_version": INDEX_SPEC_VERSION,
|
|
676
|
+
},
|
|
677
|
+
)
|
|
678
|
+
return True
|
|
679
|
+
|
|
680
|
+
# Explicit maintenance operation used by the compatibility shell adapter.
|
|
681
|
+
def embed_foreground(self) -> RetrieverHealth:
|
|
682
|
+
self._ensure_collection(force=True)
|
|
683
|
+
# Read the covered revision before embedding: the vectors will cover
|
|
684
|
+
# at least everything indexed now. Under-claiming is safe (the next
|
|
685
|
+
# cycle re-embeds); claiming a revision the vectors do not cover
|
|
686
|
+
# would silently strand newer documents without embeddings.
|
|
687
|
+
covered_revision = self._indexed_source_revision()
|
|
688
|
+
started = time.monotonic()
|
|
689
|
+
completed = self._run(("embed", "-c", self.collection), timeout=3600)
|
|
690
|
+
log = self._runtime_root() / "embedding.log"
|
|
691
|
+
log.write_text(
|
|
692
|
+
f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}\n",
|
|
693
|
+
encoding="utf-8",
|
|
694
|
+
)
|
|
695
|
+
os.chmod(log, 0o600)
|
|
696
|
+
if completed.returncode != 0:
|
|
697
|
+
raise QmdAdapterError(self._failure_message("qmd embed", completed))
|
|
698
|
+
self._source_revision_cache = None
|
|
699
|
+
if covered_revision != "unknown":
|
|
700
|
+
# Atomic advance: the semantic revision moves to what the vectors
|
|
701
|
+
# actually represent — never to a newer canonical revision that
|
|
702
|
+
# changed mid-embed. A stale-but-complete receipt keeps hybrid
|
|
703
|
+
# serving until the next build completes.
|
|
704
|
+
self._write_semantic_state(
|
|
705
|
+
source_revision=covered_revision,
|
|
706
|
+
duration_seconds=time.monotonic() - started,
|
|
707
|
+
)
|
|
708
|
+
return self.health()
|
|
709
|
+
|
|
710
|
+
# -- Query execution ----------------------------------------------------
|
|
711
|
+
|
|
712
|
+
def _query(
|
|
713
|
+
self,
|
|
714
|
+
searches: list[dict[str, str]],
|
|
715
|
+
limit: int,
|
|
716
|
+
intent: str,
|
|
717
|
+
) -> list[dict[str, Any]]:
|
|
718
|
+
if self._persistent_enabled() and self._ensure_daemon():
|
|
719
|
+
payload = {
|
|
720
|
+
"searches": searches,
|
|
721
|
+
"collections": [self.collection],
|
|
722
|
+
"limit": limit,
|
|
723
|
+
"candidateLimit": max(limit, 40),
|
|
724
|
+
"rerank": False,
|
|
725
|
+
"intent": intent,
|
|
726
|
+
}
|
|
727
|
+
try:
|
|
728
|
+
request = urllib.request.Request(
|
|
729
|
+
f"{self._daemon_url()}/query",
|
|
730
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
731
|
+
headers={"Content-Type": "application/json"},
|
|
732
|
+
method="POST",
|
|
733
|
+
)
|
|
734
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
735
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
736
|
+
results = body.get("results") if isinstance(body, dict) else None
|
|
737
|
+
if isinstance(results, list):
|
|
738
|
+
return [item for item in results if isinstance(item, dict)]
|
|
739
|
+
except (OSError, ValueError, urllib.error.URLError) as exc:
|
|
740
|
+
self._daemon_available = False
|
|
741
|
+
# A healthy daemon is only an accelerator; the pinned CLI is
|
|
742
|
+
# the safe compatibility fallback.
|
|
743
|
+
daemon_error = str(exc)
|
|
744
|
+
else:
|
|
745
|
+
daemon_error = "daemon returned an invalid result payload"
|
|
746
|
+
else:
|
|
747
|
+
daemon_error = "daemon unavailable"
|
|
748
|
+
|
|
749
|
+
query_document = "\n".join(
|
|
750
|
+
f"{item['type']}: {self._single_line(item['query'])}" for item in searches
|
|
751
|
+
)
|
|
752
|
+
completed = self._run(
|
|
753
|
+
(
|
|
754
|
+
"query",
|
|
755
|
+
query_document,
|
|
756
|
+
"-c",
|
|
757
|
+
self.collection,
|
|
758
|
+
"-n",
|
|
759
|
+
str(limit),
|
|
760
|
+
"--no-rerank",
|
|
761
|
+
"--format",
|
|
762
|
+
"json",
|
|
763
|
+
"--full-path",
|
|
764
|
+
),
|
|
765
|
+
timeout=60,
|
|
766
|
+
)
|
|
767
|
+
if completed.returncode != 0:
|
|
768
|
+
message = self._failure_message("typed qmd query", completed)
|
|
769
|
+
if daemon_error != "daemon unavailable":
|
|
770
|
+
message = f"{message} (persistent path: {daemon_error})"
|
|
771
|
+
raise QmdAdapterError(message)
|
|
772
|
+
return self._parse_cli_results(completed.stdout)
|
|
773
|
+
|
|
774
|
+
def _typed_searches(
|
|
775
|
+
self, request: RetrievalRequest, mode: RetrievalMode
|
|
776
|
+
) -> list[dict[str, str]]:
|
|
777
|
+
if mode is RetrievalMode.LEX:
|
|
778
|
+
terms = request.lex or request.vec
|
|
779
|
+
return [{"type": "lex", "query": term} for term in terms if term.strip()]
|
|
780
|
+
if mode is RetrievalMode.VEC:
|
|
781
|
+
terms = request.vec or request.lex
|
|
782
|
+
return [{"type": "vec", "query": term} for term in terms if term.strip()]
|
|
783
|
+
searches = [
|
|
784
|
+
{"type": "lex", "query": term} for term in request.lex if term.strip()
|
|
785
|
+
]
|
|
786
|
+
searches.extend(
|
|
787
|
+
{"type": "vec", "query": term} for term in request.vec if term.strip()
|
|
788
|
+
)
|
|
789
|
+
return searches
|
|
790
|
+
|
|
791
|
+
def _authorized_hits(
|
|
792
|
+
self,
|
|
793
|
+
raw_hits: Sequence[Mapping[str, Any]],
|
|
794
|
+
request: RetrievalRequest,
|
|
795
|
+
executed_mode: RetrievalMode,
|
|
796
|
+
) -> list[RetrievalHit]:
|
|
797
|
+
accepted: list[tuple[Mapping[str, Any], str, _DocumentMetadata]] = []
|
|
798
|
+
for raw in raw_hits:
|
|
799
|
+
canonical_path = self._canonical_path(str(raw.get("file", "")))
|
|
800
|
+
if canonical_path is None or not self._scope_allows(canonical_path, request):
|
|
801
|
+
continue
|
|
802
|
+
metadata = self._metadata(canonical_path)
|
|
803
|
+
if request.allowed_artifact_ids and metadata.artifact_id not in request.allowed_artifact_ids:
|
|
804
|
+
continue
|
|
805
|
+
if request.artifact_types and metadata.artifact_type not in request.artifact_types:
|
|
806
|
+
continue
|
|
807
|
+
if request.workstream and metadata.workstream != request.workstream:
|
|
808
|
+
continue
|
|
809
|
+
if request.not_before is not None:
|
|
810
|
+
if metadata.observed_at is None:
|
|
811
|
+
continue
|
|
812
|
+
if metadata.observed_at < request.not_before.astimezone(UTC):
|
|
813
|
+
continue
|
|
814
|
+
accepted.append((raw, canonical_path, metadata))
|
|
815
|
+
|
|
816
|
+
# Exact canonical names are source-selection metadata, not generated
|
|
817
|
+
# query terms or a model reranker. If the user names a returned title
|
|
818
|
+
# verbatim, prefer that source while preserving QMD/RRF order otherwise.
|
|
819
|
+
accepted.sort(
|
|
820
|
+
key=lambda candidate: (
|
|
821
|
+
0 if self._task_names_document(request.task, candidate[2]) else 1
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
retrieval_types = {
|
|
826
|
+
RetrievalMode.LEX: (RetrievalMode.LEX,),
|
|
827
|
+
RetrievalMode.VEC: (RetrievalMode.VEC,),
|
|
828
|
+
RetrievalMode.HYBRID: (RetrievalMode.LEX, RetrievalMode.VEC),
|
|
829
|
+
}[executed_mode]
|
|
830
|
+
return [
|
|
831
|
+
RetrievalHit(
|
|
832
|
+
artifact_id=metadata.artifact_id,
|
|
833
|
+
canonical_path=canonical_path,
|
|
834
|
+
rank=rank,
|
|
835
|
+
score=float(raw.get("score") or 0),
|
|
836
|
+
retrieval_types=retrieval_types,
|
|
837
|
+
passage=self._passage(raw),
|
|
838
|
+
passage_id=(
|
|
839
|
+
f"{metadata.artifact_id}:L{raw['line']}" if raw.get("line") else None
|
|
840
|
+
),
|
|
841
|
+
revision=metadata.revision,
|
|
842
|
+
content_hash=metadata.content_hash,
|
|
843
|
+
observed_at=metadata.observed_at,
|
|
844
|
+
)
|
|
845
|
+
for rank, (raw, canonical_path, metadata) in enumerate(accepted, start=1)
|
|
846
|
+
]
|
|
847
|
+
|
|
848
|
+
# -- QMD lifecycle ------------------------------------------------------
|
|
849
|
+
|
|
850
|
+
def _ensure_ready(self, warnings: list[str]) -> None:
|
|
851
|
+
if not self.memory_root.is_dir():
|
|
852
|
+
raise QmdAdapterError(f"canonical memory is unavailable: {self.memory_root}")
|
|
853
|
+
self._ensure_version()
|
|
854
|
+
if self._index_state_current():
|
|
855
|
+
return
|
|
856
|
+
self._ensure_collection(force=True)
|
|
857
|
+
completed = self._run(("update",), timeout=300)
|
|
858
|
+
if completed.returncode != 0:
|
|
859
|
+
warnings.append(
|
|
860
|
+
self._failure_message(
|
|
861
|
+
"incremental qmd update",
|
|
862
|
+
completed,
|
|
863
|
+
)
|
|
864
|
+
+ "; results may omit recent canonical writes"
|
|
865
|
+
)
|
|
866
|
+
else:
|
|
867
|
+
self._write_index_state()
|
|
868
|
+
|
|
869
|
+
def _ensure_collection(self, *, force: bool = False) -> None:
|
|
870
|
+
self._ensure_version()
|
|
871
|
+
if not force and self._index_state_current():
|
|
872
|
+
return
|
|
873
|
+
listed = self._run(("collection", "list"), timeout=30)
|
|
874
|
+
names = self._collection_names(listed.stdout) if listed.returncode == 0 else ()
|
|
875
|
+
for name in names:
|
|
876
|
+
registered_path = self._collection_path(name)
|
|
877
|
+
if name == self.collection:
|
|
878
|
+
if registered_path == self.memory_root:
|
|
879
|
+
return
|
|
880
|
+
raise QmdAdapterError(
|
|
881
|
+
f"QMD collection {name!r} belongs to {registered_path or 'an unknown path'}; "
|
|
882
|
+
"refusing to reuse a foreign corpus"
|
|
883
|
+
)
|
|
884
|
+
if registered_path == self.memory_root:
|
|
885
|
+
# QMD permits only one collection registration per path. Keep
|
|
886
|
+
# the existing disposable index and migrate its legacy name to
|
|
887
|
+
# the instance-scoped name instead of deleting/re-embedding.
|
|
888
|
+
renamed = self._run(
|
|
889
|
+
("collection", "rename", name, self.collection), timeout=60
|
|
890
|
+
)
|
|
891
|
+
if renamed.returncode != 0:
|
|
892
|
+
raise QmdAdapterError(
|
|
893
|
+
self._failure_message("qmd collection name migration", renamed)
|
|
894
|
+
)
|
|
895
|
+
return
|
|
896
|
+
added = self._run(
|
|
897
|
+
("collection", "add", str(self.memory_root), "--name", self.collection),
|
|
898
|
+
timeout=60,
|
|
899
|
+
)
|
|
900
|
+
if added.returncode != 0:
|
|
901
|
+
raise QmdAdapterError(self._failure_message("qmd collection add", added))
|
|
902
|
+
|
|
903
|
+
@staticmethod
|
|
904
|
+
def _collection_names(output: str) -> tuple[str, ...]:
|
|
905
|
+
return tuple(
|
|
906
|
+
match.group(1)
|
|
907
|
+
for match in re.finditer(
|
|
908
|
+
r"^\s*([A-Za-z0-9_.-]+)\s+\(qmd://[^)]+/\)\s*$",
|
|
909
|
+
output,
|
|
910
|
+
re.MULTILINE,
|
|
911
|
+
)
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
def _collection_path(self, name: str) -> Path | None:
|
|
915
|
+
shown = self._run(("collection", "show", name), timeout=30)
|
|
916
|
+
if shown.returncode != 0:
|
|
917
|
+
return None
|
|
918
|
+
match = re.search(r"^\s*Path:\s+(.+?)\s*$", shown.stdout, re.MULTILINE)
|
|
919
|
+
if not match:
|
|
920
|
+
return None
|
|
921
|
+
return Path(match.group(1)).expanduser().resolve()
|
|
922
|
+
|
|
923
|
+
def _ensure_version(self) -> None:
|
|
924
|
+
if self._version_checked:
|
|
925
|
+
return
|
|
926
|
+
command = self._command()
|
|
927
|
+
if self._version_checked:
|
|
928
|
+
return
|
|
929
|
+
completed = self._run_raw((*command, "--version"), timeout=20)
|
|
930
|
+
output = f"{completed.stdout}\n{completed.stderr}"
|
|
931
|
+
if completed.returncode != 0 or not re.search(
|
|
932
|
+
rf"\b{re.escape(QMD_VERSION)}\b", output
|
|
933
|
+
):
|
|
934
|
+
actual = output.strip() or f"exit {completed.returncode}"
|
|
935
|
+
raise QmdAdapterError(f"QMD {QMD_VERSION} is required; adapter resolved {actual!r}")
|
|
936
|
+
self._version_checked = True
|
|
937
|
+
|
|
938
|
+
def _command(self) -> tuple[str, ...]:
|
|
939
|
+
if self._resolved_command is not None:
|
|
940
|
+
return self._resolved_command
|
|
941
|
+
if self._configured_command:
|
|
942
|
+
self._resolved_command = self._configured_command
|
|
943
|
+
return self._resolved_command
|
|
944
|
+
|
|
945
|
+
override = self.environment.get("EGREGORE_QMD_BIN")
|
|
946
|
+
if override:
|
|
947
|
+
self._resolved_command = (override,)
|
|
948
|
+
return self._resolved_command
|
|
949
|
+
|
|
950
|
+
managed = self._managed_binary()
|
|
951
|
+
if managed.is_file() and os.access(managed, os.X_OK):
|
|
952
|
+
self._resolved_command = (str(managed),)
|
|
953
|
+
# The managed prefix embeds the exact pinned version in its path
|
|
954
|
+
# (qmd-<QMD_VERSION>/) and install_managed verified the binary at
|
|
955
|
+
# provisioning time — the same assertion the npx selector makes.
|
|
956
|
+
# Skipping the per-process --version spawn saves a Node startup
|
|
957
|
+
# on every boot-path health/status call.
|
|
958
|
+
self._version_checked = True
|
|
959
|
+
return self._resolved_command
|
|
960
|
+
|
|
961
|
+
# Never a PATH global and never npx: those resolve through machine
|
|
962
|
+
# state the Runtime does not own (a stale global or npx cache built
|
|
963
|
+
# against another Node ABI produced exactly the dlopen failure the
|
|
964
|
+
# Codex black-box hit). A missing managed install is provisioned now
|
|
965
|
+
# into the instance-owned prefix; if that fails, retrieval fails as a
|
|
966
|
+
# Runtime failure — degrading to unmanaged binaries is never correct.
|
|
967
|
+
binary = self.install_managed()
|
|
968
|
+
self._resolved_command = (str(binary),)
|
|
969
|
+
self._version_checked = True
|
|
970
|
+
return self._resolved_command
|
|
971
|
+
|
|
972
|
+
def _managed_prefix(self) -> Path:
|
|
973
|
+
"""Install target for the managed QMD — instance-owned by default.
|
|
974
|
+
|
|
975
|
+
A shared install couples every instance to whichever Node built its
|
|
976
|
+
native modules; scoping the prefix to the instance runtime root keeps
|
|
977
|
+
upgrades, ABI compatibility, and removal per-instance. An explicit
|
|
978
|
+
EGREGORE_RUNTIME_ROOT keeps the historical shared layout for callers
|
|
979
|
+
that opt into it.
|
|
980
|
+
"""
|
|
981
|
+
runtime_root = self.environment.get("EGREGORE_RUNTIME_ROOT")
|
|
982
|
+
if runtime_root:
|
|
983
|
+
return Path(runtime_root).expanduser() / f"qmd-{QMD_VERSION}"
|
|
984
|
+
return self._runtime_root() / f"qmd-{QMD_VERSION}"
|
|
985
|
+
|
|
986
|
+
def _managed_binary(self) -> Path:
|
|
987
|
+
instance = self._managed_prefix() / "node_modules" / ".bin" / "qmd"
|
|
988
|
+
if instance.is_file():
|
|
989
|
+
return instance
|
|
990
|
+
# Existing installs provisioned the shared, version-addressed prefix;
|
|
991
|
+
# keep resolving it so an upgrade is adoption, never breakage.
|
|
992
|
+
legacy = Path.home() / ".egregore" / "runtime" / f"qmd-{QMD_VERSION}"
|
|
993
|
+
legacy_binary = legacy / "node_modules" / ".bin" / "qmd"
|
|
994
|
+
if legacy_binary.is_file():
|
|
995
|
+
return legacy_binary
|
|
996
|
+
return instance
|
|
997
|
+
|
|
998
|
+
def _persistent_enabled(self) -> bool:
|
|
999
|
+
return self.environment.get("EGREGORE_QMD_PERSISTENT", "1") != "0"
|
|
1000
|
+
|
|
1001
|
+
def _ensure_daemon(self) -> bool:
|
|
1002
|
+
if not self._persistent_enabled():
|
|
1003
|
+
return False
|
|
1004
|
+
with self._lifecycle_lock():
|
|
1005
|
+
if self._owned_runtime() is not None:
|
|
1006
|
+
self._daemon_available = True
|
|
1007
|
+
return True
|
|
1008
|
+
|
|
1009
|
+
stale = self._read_runtime_metadata()
|
|
1010
|
+
if stale and self._static_runtime_matches(stale):
|
|
1011
|
+
# Signal only a process whose complete QMD command identity is
|
|
1012
|
+
# still ours. Foreign listeners and recycled PIDs are untouched.
|
|
1013
|
+
if (
|
|
1014
|
+
self._process_matches(stale.pid, stale.port)
|
|
1015
|
+
and self._process_start_identity(stale.pid)
|
|
1016
|
+
== stale.process_started_at
|
|
1017
|
+
):
|
|
1018
|
+
self._run(("mcp", "stop"), timeout=20)
|
|
1019
|
+
self._cleanup_stale_runtime_files()
|
|
1020
|
+
|
|
1021
|
+
preferred = self._preferred_port()
|
|
1022
|
+
port = preferred if self._port_available(preferred) else self._allocate_port()
|
|
1023
|
+
started = self._run(
|
|
1024
|
+
("mcp", "--http", "--daemon", "--port", str(port)),
|
|
1025
|
+
timeout=20,
|
|
1026
|
+
)
|
|
1027
|
+
if started.returncode != 0:
|
|
1028
|
+
# A bind race must not cause adoption of the process that won.
|
|
1029
|
+
port = self._allocate_port()
|
|
1030
|
+
started = self._run(
|
|
1031
|
+
("mcp", "--http", "--daemon", "--port", str(port)),
|
|
1032
|
+
timeout=20,
|
|
1033
|
+
)
|
|
1034
|
+
if started.returncode != 0:
|
|
1035
|
+
return False
|
|
1036
|
+
pid = self._started_pid(started.stdout)
|
|
1037
|
+
if pid is None:
|
|
1038
|
+
return False
|
|
1039
|
+
for _ in range(100):
|
|
1040
|
+
if self._process_matches(pid, port) and self._daemon_health(port):
|
|
1041
|
+
self._write_runtime_metadata(pid=pid, port=port)
|
|
1042
|
+
self._daemon_available = True
|
|
1043
|
+
return True
|
|
1044
|
+
time.sleep(0.1)
|
|
1045
|
+
return False
|
|
1046
|
+
|
|
1047
|
+
def _daemon_health(self, port: int) -> bool:
|
|
1048
|
+
try:
|
|
1049
|
+
with urllib.request.urlopen(
|
|
1050
|
+
f"http://localhost:{port}/health", timeout=0.3
|
|
1051
|
+
) as response:
|
|
1052
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
1053
|
+
return (
|
|
1054
|
+
response.status == 200
|
|
1055
|
+
and isinstance(payload, dict)
|
|
1056
|
+
and payload.get("status") == "ok"
|
|
1057
|
+
)
|
|
1058
|
+
except (OSError, ValueError, urllib.error.URLError):
|
|
1059
|
+
return False
|
|
1060
|
+
|
|
1061
|
+
def _preferred_port(self) -> int:
|
|
1062
|
+
default_port = 20_000 + int(self._instance_hash()[:8], 16) % 20_000
|
|
1063
|
+
if default_port == 8181:
|
|
1064
|
+
default_port += 1
|
|
1065
|
+
raw = self.environment.get("EGREGORE_QMD_PORT", str(default_port))
|
|
1066
|
+
try:
|
|
1067
|
+
port = int(raw)
|
|
1068
|
+
except ValueError as exc:
|
|
1069
|
+
raise QmdAdapterError(f"invalid EGREGORE_QMD_PORT: {raw!r}") from exc
|
|
1070
|
+
if not 1 <= port <= 65535 or port == 8181:
|
|
1071
|
+
raise QmdAdapterError(f"invalid EGREGORE_QMD_PORT: {raw!r}")
|
|
1072
|
+
return port
|
|
1073
|
+
|
|
1074
|
+
def _daemon_url(self) -> str:
|
|
1075
|
+
runtime = self._owned_runtime()
|
|
1076
|
+
if runtime is None:
|
|
1077
|
+
raise QmdAdapterError("instance-owned QMD worker is unavailable")
|
|
1078
|
+
return runtime.endpoint
|
|
1079
|
+
|
|
1080
|
+
def _read_runtime_metadata(self) -> QmdRuntimeMetadata | None:
|
|
1081
|
+
try:
|
|
1082
|
+
value = json.loads(self._runtime_state_path().read_text(encoding="utf-8"))
|
|
1083
|
+
return QmdRuntimeMetadata.from_mapping(value)
|
|
1084
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
1085
|
+
return None
|
|
1086
|
+
|
|
1087
|
+
def _static_runtime_matches(self, runtime: QmdRuntimeMetadata) -> bool:
|
|
1088
|
+
return (
|
|
1089
|
+
runtime.schema_version == RUNTIME_SCHEMA_VERSION
|
|
1090
|
+
and runtime.instance_id == self._instance_hash()
|
|
1091
|
+
and runtime.qmd_version == QMD_VERSION
|
|
1092
|
+
and runtime.index_spec_version == INDEX_SPEC_VERSION
|
|
1093
|
+
and runtime.index_name == self.index_name
|
|
1094
|
+
and Path(runtime.index_path).resolve() == self._index_path()
|
|
1095
|
+
and runtime.collection == self.collection
|
|
1096
|
+
and Path(runtime.memory_root).resolve() == self.memory_root
|
|
1097
|
+
and runtime.endpoint == f"http://localhost:{runtime.port}"
|
|
1098
|
+
and runtime.port != 8181
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
def _owned_runtime(self) -> QmdRuntimeMetadata | None:
|
|
1102
|
+
runtime = self._read_runtime_metadata()
|
|
1103
|
+
if runtime is None or not self._static_runtime_matches(runtime):
|
|
1104
|
+
return None
|
|
1105
|
+
if not self._process_matches(runtime.pid, runtime.port):
|
|
1106
|
+
return None
|
|
1107
|
+
if self._process_start_identity(runtime.pid) != runtime.process_started_at:
|
|
1108
|
+
return None
|
|
1109
|
+
if not self._daemon_health(runtime.port):
|
|
1110
|
+
return None
|
|
1111
|
+
return runtime
|
|
1112
|
+
|
|
1113
|
+
def _process_matches(self, pid: int, port: int) -> bool:
|
|
1114
|
+
if not self._pid_alive(pid):
|
|
1115
|
+
return False
|
|
1116
|
+
completed = self._run_raw(("ps", "-ww", "-p", str(pid), "-o", "command="), timeout=5)
|
|
1117
|
+
if completed.returncode != 0:
|
|
1118
|
+
return False
|
|
1119
|
+
command = completed.stdout.strip()
|
|
1120
|
+
return (
|
|
1121
|
+
"qmd" in command.lower()
|
|
1122
|
+
and "mcp" in command
|
|
1123
|
+
and f"--index {self.index_name}" in command
|
|
1124
|
+
and f"--port {port}" in command
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
def _process_start_identity(self, pid: int) -> str | None:
|
|
1128
|
+
completed = self._run_raw(
|
|
1129
|
+
("ps", "-ww", "-p", str(pid), "-o", "lstart="), timeout=5
|
|
1130
|
+
)
|
|
1131
|
+
if completed.returncode != 0 or not completed.stdout.strip():
|
|
1132
|
+
return None
|
|
1133
|
+
return " ".join(completed.stdout.split())
|
|
1134
|
+
|
|
1135
|
+
@staticmethod
|
|
1136
|
+
def _pid_alive(pid: int) -> bool:
|
|
1137
|
+
try:
|
|
1138
|
+
os.kill(pid, 0)
|
|
1139
|
+
return True
|
|
1140
|
+
except (OSError, ValueError):
|
|
1141
|
+
return False
|
|
1142
|
+
|
|
1143
|
+
@staticmethod
|
|
1144
|
+
def _port_available(port: int) -> bool:
|
|
1145
|
+
if port == 8181:
|
|
1146
|
+
return False
|
|
1147
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
1148
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
1149
|
+
try:
|
|
1150
|
+
probe.bind(("127.0.0.1", port))
|
|
1151
|
+
return True
|
|
1152
|
+
except OSError:
|
|
1153
|
+
return False
|
|
1154
|
+
|
|
1155
|
+
def _allocate_port(self) -> int:
|
|
1156
|
+
for _ in range(20):
|
|
1157
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
1158
|
+
probe.bind(("127.0.0.1", 0))
|
|
1159
|
+
port = int(probe.getsockname()[1])
|
|
1160
|
+
if port != 8181 and self._port_available(port):
|
|
1161
|
+
return port
|
|
1162
|
+
raise QmdAdapterError("could not allocate an instance-owned QMD port")
|
|
1163
|
+
|
|
1164
|
+
def _started_pid(self, output: str) -> int | None:
|
|
1165
|
+
if match := re.search(r"\bPID\s+(\d+)\b", output):
|
|
1166
|
+
return int(match.group(1))
|
|
1167
|
+
candidates = (
|
|
1168
|
+
self._runtime_root() / "cache" / "qmd" / f"mcp-{self.index_name}.pid",
|
|
1169
|
+
self._runtime_root() / "cache" / "qmd" / "mcp.pid",
|
|
1170
|
+
)
|
|
1171
|
+
for candidate in candidates:
|
|
1172
|
+
try:
|
|
1173
|
+
return int(candidate.read_text(encoding="utf-8").strip())
|
|
1174
|
+
except (OSError, ValueError):
|
|
1175
|
+
continue
|
|
1176
|
+
return None
|
|
1177
|
+
|
|
1178
|
+
def _write_runtime_metadata(self, *, pid: int, port: int) -> None:
|
|
1179
|
+
process_started_at = self._process_start_identity(pid)
|
|
1180
|
+
if process_started_at is None:
|
|
1181
|
+
raise QmdAdapterError("could not verify the QMD worker process birth identity")
|
|
1182
|
+
runtime = QmdRuntimeMetadata(
|
|
1183
|
+
schema_version=RUNTIME_SCHEMA_VERSION,
|
|
1184
|
+
instance_id=self._instance_hash(),
|
|
1185
|
+
generation=uuid.uuid4().hex,
|
|
1186
|
+
pid=pid,
|
|
1187
|
+
port=port,
|
|
1188
|
+
endpoint=f"http://localhost:{port}",
|
|
1189
|
+
qmd_version=QMD_VERSION,
|
|
1190
|
+
index_spec_version=INDEX_SPEC_VERSION,
|
|
1191
|
+
index_name=self.index_name,
|
|
1192
|
+
index_path=str(self._index_path()),
|
|
1193
|
+
collection=self.collection,
|
|
1194
|
+
memory_root=str(self.memory_root),
|
|
1195
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
1196
|
+
process_started_at=process_started_at,
|
|
1197
|
+
)
|
|
1198
|
+
self._atomic_json(self._runtime_state_path(), runtime.to_dict())
|
|
1199
|
+
|
|
1200
|
+
def _cleanup_stale_runtime_files(self) -> None:
|
|
1201
|
+
self._runtime_state_path().unlink(missing_ok=True)
|
|
1202
|
+
for candidate in (
|
|
1203
|
+
self._runtime_root() / "cache" / "qmd" / f"mcp-{self.index_name}.pid",
|
|
1204
|
+
self._runtime_root() / "cache" / "qmd" / "mcp.pid",
|
|
1205
|
+
):
|
|
1206
|
+
candidate.unlink(missing_ok=True)
|
|
1207
|
+
|
|
1208
|
+
# -- Canonical path, metadata, and revision helpers --------------------
|
|
1209
|
+
|
|
1210
|
+
def _canonical_path(self, raw: str) -> str | None:
|
|
1211
|
+
raw = raw.strip().strip("`")
|
|
1212
|
+
uri_prefix = f"qmd://{self.collection}/"
|
|
1213
|
+
if raw.startswith(uri_prefix):
|
|
1214
|
+
relative = urllib.parse.unquote(raw[len(uri_prefix) :])
|
|
1215
|
+
else:
|
|
1216
|
+
candidate = Path(raw)
|
|
1217
|
+
if not candidate.is_absolute():
|
|
1218
|
+
candidate = (self.repository_root / candidate).resolve()
|
|
1219
|
+
else:
|
|
1220
|
+
candidate = candidate.resolve()
|
|
1221
|
+
try:
|
|
1222
|
+
relative = str(candidate.relative_to(self.memory_root))
|
|
1223
|
+
except ValueError:
|
|
1224
|
+
return None
|
|
1225
|
+
relative = relative.replace("\\", "/").lstrip("/")
|
|
1226
|
+
if not relative or ".." in Path(relative).parts:
|
|
1227
|
+
return None
|
|
1228
|
+
return f"memory/{relative}"
|
|
1229
|
+
|
|
1230
|
+
def _source_path(self, canonical_path: str) -> Path:
|
|
1231
|
+
prefix = "memory/"
|
|
1232
|
+
if not canonical_path.startswith(prefix):
|
|
1233
|
+
raise QmdAdapterError(f"source is outside canonical memory: {canonical_path}")
|
|
1234
|
+
source = (self.memory_root / canonical_path[len(prefix) :]).resolve()
|
|
1235
|
+
try:
|
|
1236
|
+
source.relative_to(self.memory_root)
|
|
1237
|
+
except ValueError as exc:
|
|
1238
|
+
raise QmdAdapterError(f"source is outside canonical memory: {canonical_path}") from exc
|
|
1239
|
+
return source
|
|
1240
|
+
|
|
1241
|
+
def _metadata(self, canonical_path: str) -> _DocumentMetadata:
|
|
1242
|
+
source = self._source_path(canonical_path)
|
|
1243
|
+
fallback_id = canonical_artifact_id("", canonical_path)
|
|
1244
|
+
if not source.is_file():
|
|
1245
|
+
return _DocumentMetadata(artifact_id=fallback_id)
|
|
1246
|
+
body = source.read_text(encoding="utf-8", errors="replace")
|
|
1247
|
+
try:
|
|
1248
|
+
frontmatter, markdown_body = split_frontmatter(body)
|
|
1249
|
+
except ArtifactSchemaError:
|
|
1250
|
+
frontmatter, markdown_body = {}, body
|
|
1251
|
+
title = next(
|
|
1252
|
+
(
|
|
1253
|
+
str(frontmatter[field]).strip()
|
|
1254
|
+
for field in ("title", "topic", "name")
|
|
1255
|
+
if frontmatter.get(field)
|
|
1256
|
+
),
|
|
1257
|
+
None,
|
|
1258
|
+
)
|
|
1259
|
+
if title is None:
|
|
1260
|
+
heading = re.search(r"^#\s+(.+?)\s*$", markdown_body, re.MULTILINE)
|
|
1261
|
+
if heading:
|
|
1262
|
+
title = heading.group(1).strip()
|
|
1263
|
+
return _DocumentMetadata(
|
|
1264
|
+
artifact_id=canonical_artifact_id(body, canonical_path),
|
|
1265
|
+
title=title,
|
|
1266
|
+
artifact_type=(
|
|
1267
|
+
str(frontmatter.get("artifact_type") or frontmatter.get("type"))
|
|
1268
|
+
if frontmatter.get("artifact_type") or frontmatter.get("type")
|
|
1269
|
+
else None
|
|
1270
|
+
),
|
|
1271
|
+
workstream=(
|
|
1272
|
+
str(frontmatter.get("workstream") or frontmatter.get("topic"))
|
|
1273
|
+
if frontmatter.get("workstream") or frontmatter.get("topic")
|
|
1274
|
+
else None
|
|
1275
|
+
),
|
|
1276
|
+
revision=str(frontmatter.get("revision") or self._source_revision()),
|
|
1277
|
+
content_hash=hashlib.sha256(body.encode("utf-8")).hexdigest(),
|
|
1278
|
+
observed_at=self._observed_at(frontmatter, canonical_path),
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
@staticmethod
|
|
1282
|
+
def _observed_at(
|
|
1283
|
+
frontmatter: Mapping[str, Any], canonical_path: str
|
|
1284
|
+
) -> datetime | None:
|
|
1285
|
+
for field in (
|
|
1286
|
+
"occurred_at",
|
|
1287
|
+
"created_at",
|
|
1288
|
+
"updated_at",
|
|
1289
|
+
"date",
|
|
1290
|
+
"created",
|
|
1291
|
+
"started",
|
|
1292
|
+
"ingested_at",
|
|
1293
|
+
):
|
|
1294
|
+
raw = frontmatter.get(field)
|
|
1295
|
+
if raw in (None, ""):
|
|
1296
|
+
continue
|
|
1297
|
+
value = str(raw).strip()
|
|
1298
|
+
try:
|
|
1299
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1300
|
+
except ValueError:
|
|
1301
|
+
continue
|
|
1302
|
+
return (
|
|
1303
|
+
parsed.replace(tzinfo=UTC)
|
|
1304
|
+
if parsed.tzinfo is None
|
|
1305
|
+
else parsed.astimezone(UTC)
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
match = re.search(r"(?:^|/)(20\d{2})-(\d{2})(?:/|-)(\d{2})(?:[-/]|$)", canonical_path)
|
|
1309
|
+
if match:
|
|
1310
|
+
try:
|
|
1311
|
+
return datetime(
|
|
1312
|
+
int(match.group(1)),
|
|
1313
|
+
int(match.group(2)),
|
|
1314
|
+
int(match.group(3)),
|
|
1315
|
+
tzinfo=UTC,
|
|
1316
|
+
)
|
|
1317
|
+
except ValueError:
|
|
1318
|
+
return None
|
|
1319
|
+
return None
|
|
1320
|
+
|
|
1321
|
+
@staticmethod
|
|
1322
|
+
def _task_names_document(task: str, metadata: _DocumentMetadata) -> bool:
|
|
1323
|
+
if not metadata.title:
|
|
1324
|
+
return False
|
|
1325
|
+
|
|
1326
|
+
def normalized(value: str) -> str:
|
|
1327
|
+
return " ".join(re.findall(r"[a-z0-9]+", value.lower()))
|
|
1328
|
+
|
|
1329
|
+
title = normalized(metadata.title)
|
|
1330
|
+
return len(title) >= 8 and title in normalized(task)
|
|
1331
|
+
|
|
1332
|
+
def _scope_allows(self, canonical_path: str, request: RetrievalRequest) -> bool:
|
|
1333
|
+
# Fail closed: a request that carries no authorized scope gets no
|
|
1334
|
+
# hits. Observe always sets scopes; a direct caller without them is
|
|
1335
|
+
# unauthorized by construction, not universally entitled.
|
|
1336
|
+
if not request.authorized_scopes:
|
|
1337
|
+
return False
|
|
1338
|
+
for raw_scope in request.authorized_scopes:
|
|
1339
|
+
scope = raw_scope.strip().replace("\\", "/").rstrip("/")
|
|
1340
|
+
if scope == "memory":
|
|
1341
|
+
return True
|
|
1342
|
+
if not scope.startswith("memory/"):
|
|
1343
|
+
scope = f"memory/{scope.lstrip('/')}"
|
|
1344
|
+
if canonical_path == scope or canonical_path.startswith(f"{scope}/"):
|
|
1345
|
+
return True
|
|
1346
|
+
return False
|
|
1347
|
+
|
|
1348
|
+
def _source_revision(self) -> str:
|
|
1349
|
+
if self._source_revision_cache is not None:
|
|
1350
|
+
return self._source_revision_cache
|
|
1351
|
+
completed = self._run_raw(
|
|
1352
|
+
("git", "-C", str(self.memory_root), "rev-parse", "HEAD"), timeout=10
|
|
1353
|
+
)
|
|
1354
|
+
if completed.returncode == 0 and completed.stdout.strip():
|
|
1355
|
+
head = completed.stdout.strip()
|
|
1356
|
+
changes = self._run_raw(
|
|
1357
|
+
(
|
|
1358
|
+
"git", "-C", str(self.memory_root), "status", "--porcelain=v1",
|
|
1359
|
+
"--untracked-files=all", "--", "*.md",
|
|
1360
|
+
),
|
|
1361
|
+
timeout=30,
|
|
1362
|
+
)
|
|
1363
|
+
if changes.returncode == 0 and not changes.stdout.strip():
|
|
1364
|
+
revision = f"git:{head}"
|
|
1365
|
+
else:
|
|
1366
|
+
revision = f"git:{head}+dirty:{self._markdown_manifest_hash()}"
|
|
1367
|
+
else:
|
|
1368
|
+
revision = f"filesystem:{self._markdown_manifest_hash()}"
|
|
1369
|
+
self._source_revision_cache = revision
|
|
1370
|
+
return revision
|
|
1371
|
+
|
|
1372
|
+
def _markdown_manifest_hash(self) -> str:
|
|
1373
|
+
digest = hashlib.sha256()
|
|
1374
|
+
for source in sorted(self.memory_root.rglob("*.md")):
|
|
1375
|
+
if not source.is_file():
|
|
1376
|
+
continue
|
|
1377
|
+
relative = source.relative_to(self.memory_root).as_posix()
|
|
1378
|
+
digest.update(relative.encode("utf-8"))
|
|
1379
|
+
digest.update(b"\0")
|
|
1380
|
+
try:
|
|
1381
|
+
digest.update(source.read_bytes())
|
|
1382
|
+
except OSError:
|
|
1383
|
+
digest.update(b"<unreadable>")
|
|
1384
|
+
digest.update(b"\0")
|
|
1385
|
+
return digest.hexdigest()[:20]
|
|
1386
|
+
|
|
1387
|
+
def _index_revision(self) -> str:
|
|
1388
|
+
index = self._index_path()
|
|
1389
|
+
stat_material = "missing"
|
|
1390
|
+
try:
|
|
1391
|
+
stat = index.stat()
|
|
1392
|
+
stat_material = f"{stat.st_mtime_ns}:{stat.st_size}"
|
|
1393
|
+
except OSError:
|
|
1394
|
+
pass
|
|
1395
|
+
material = (
|
|
1396
|
+
f"{INDEX_SPEC_VERSION}:{self.collection}:"
|
|
1397
|
+
f"{self._indexed_source_revision()}:{stat_material}"
|
|
1398
|
+
)
|
|
1399
|
+
return "qmd:" + hashlib.sha256(material.encode()).hexdigest()[:20]
|
|
1400
|
+
|
|
1401
|
+
def _index_path(self) -> Path:
|
|
1402
|
+
override = self.environment.get("EGREGORE_QMD_INDEX_PATH")
|
|
1403
|
+
if override:
|
|
1404
|
+
return Path(override).expanduser().resolve()
|
|
1405
|
+
return self._runtime_root() / "index.sqlite"
|
|
1406
|
+
|
|
1407
|
+
def _models_path(self) -> Path:
|
|
1408
|
+
return self._runtime_root() / "cache" / "qmd" / "models"
|
|
1409
|
+
|
|
1410
|
+
def _index_state_path(self) -> Path:
|
|
1411
|
+
safe_collection = re.sub(r"[^A-Za-z0-9_.-]", "-", self.collection)
|
|
1412
|
+
return self._index_path().parent / f"egregore-index-{safe_collection}.json"
|
|
1413
|
+
|
|
1414
|
+
def _index_state_current(self) -> bool:
|
|
1415
|
+
if not self._index_path().is_file():
|
|
1416
|
+
return False
|
|
1417
|
+
try:
|
|
1418
|
+
state = json.loads(self._index_state_path().read_text(encoding="utf-8"))
|
|
1419
|
+
except (OSError, ValueError, TypeError):
|
|
1420
|
+
return False
|
|
1421
|
+
return state == {
|
|
1422
|
+
"adapter_version": QMD_VERSION,
|
|
1423
|
+
"collection": self.collection,
|
|
1424
|
+
"index_name": self.index_name,
|
|
1425
|
+
"index_path": str(self._index_path()),
|
|
1426
|
+
"instance_id": self._instance_hash(),
|
|
1427
|
+
"index_spec_version": INDEX_SPEC_VERSION,
|
|
1428
|
+
"memory_root": str(self.memory_root),
|
|
1429
|
+
"source_revision": self._source_revision(),
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
def _index_state_usable(self) -> bool:
|
|
1433
|
+
"""The BM25 index exists for this exact identity, revision aside.
|
|
1434
|
+
|
|
1435
|
+
A canonical write leaves the index trailing the source revision, not
|
|
1436
|
+
unusable — retrieval refreshes it inline before querying. Usability
|
|
1437
|
+
breaks only on a missing index or a different index identity.
|
|
1438
|
+
"""
|
|
1439
|
+
if not self._index_path().is_file():
|
|
1440
|
+
return False
|
|
1441
|
+
try:
|
|
1442
|
+
state = json.loads(self._index_state_path().read_text(encoding="utf-8"))
|
|
1443
|
+
except (OSError, ValueError, TypeError):
|
|
1444
|
+
return False
|
|
1445
|
+
expected = {
|
|
1446
|
+
"adapter_version": QMD_VERSION,
|
|
1447
|
+
"collection": self.collection,
|
|
1448
|
+
"index_name": self.index_name,
|
|
1449
|
+
"index_path": str(self._index_path()),
|
|
1450
|
+
"instance_id": self._instance_hash(),
|
|
1451
|
+
"index_spec_version": INDEX_SPEC_VERSION,
|
|
1452
|
+
"memory_root": str(self.memory_root),
|
|
1453
|
+
}
|
|
1454
|
+
return isinstance(state, dict) and all(
|
|
1455
|
+
state.get(key) == value for key, value in expected.items()
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
def _indexed_source_revision(self) -> str:
|
|
1459
|
+
try:
|
|
1460
|
+
state = json.loads(self._index_state_path().read_text(encoding="utf-8"))
|
|
1461
|
+
revision = state.get("source_revision")
|
|
1462
|
+
return str(revision) if revision else "unknown"
|
|
1463
|
+
except (OSError, ValueError, TypeError):
|
|
1464
|
+
return "unknown"
|
|
1465
|
+
|
|
1466
|
+
def _write_index_state(self) -> None:
|
|
1467
|
+
state_path = self._index_state_path()
|
|
1468
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1469
|
+
state = {
|
|
1470
|
+
"adapter_version": QMD_VERSION,
|
|
1471
|
+
"collection": self.collection,
|
|
1472
|
+
"index_name": self.index_name,
|
|
1473
|
+
"index_path": str(self._index_path()),
|
|
1474
|
+
"instance_id": self._instance_hash(),
|
|
1475
|
+
"index_spec_version": INDEX_SPEC_VERSION,
|
|
1476
|
+
"memory_root": str(self.memory_root),
|
|
1477
|
+
"source_revision": self._source_revision(),
|
|
1478
|
+
}
|
|
1479
|
+
self._atomic_json(state_path, state)
|
|
1480
|
+
|
|
1481
|
+
def _embedding_model_available(self) -> bool:
|
|
1482
|
+
models = self._models_path()
|
|
1483
|
+
return any(models.glob("*embeddinggemma*")) if models.is_dir() else False
|
|
1484
|
+
|
|
1485
|
+
@staticmethod
|
|
1486
|
+
def _status_counts(status: str) -> dict[str, int]:
|
|
1487
|
+
def count(pattern: str) -> int:
|
|
1488
|
+
match = re.search(pattern, status, re.IGNORECASE)
|
|
1489
|
+
return int(match.group(1)) if match else 0
|
|
1490
|
+
|
|
1491
|
+
return {
|
|
1492
|
+
"documents": count(r"Total:\s*(\d+)\s+files?\s+indexed"),
|
|
1493
|
+
"vectors": count(r"Vectors:\s*(\d+)\s+embedded"),
|
|
1494
|
+
"pending": count(r"Pending:\s*(\d+)"),
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
def _semantic_state_path(self) -> Path:
|
|
1498
|
+
return self._runtime_root() / "semantic-state.json"
|
|
1499
|
+
|
|
1500
|
+
def _semantic_state_current(self) -> bool:
|
|
1501
|
+
if not self._index_state_current():
|
|
1502
|
+
return False
|
|
1503
|
+
try:
|
|
1504
|
+
state = json.loads(self._semantic_state_path().read_text(encoding="utf-8"))
|
|
1505
|
+
except (OSError, ValueError, TypeError):
|
|
1506
|
+
return False
|
|
1507
|
+
expected = {
|
|
1508
|
+
"collection": self.collection,
|
|
1509
|
+
"index_name": self.index_name,
|
|
1510
|
+
"index_spec_version": INDEX_SPEC_VERSION,
|
|
1511
|
+
"source_revision": self._source_revision(),
|
|
1512
|
+
}
|
|
1513
|
+
return all(state.get(key) == value for key, value in expected.items())
|
|
1514
|
+
|
|
1515
|
+
def _semantic_state_usable(self) -> bool:
|
|
1516
|
+
"""A complete vector build exists for this exact index identity.
|
|
1517
|
+
|
|
1518
|
+
Unlike currentness, usability survives canonical writes: the last
|
|
1519
|
+
complete vectors keep serving hybrid queries alongside current BM25
|
|
1520
|
+
while a newer embedding builds. Only an absent index, an absent
|
|
1521
|
+
receipt, or a different index identity makes vectors unusable.
|
|
1522
|
+
"""
|
|
1523
|
+
if not self._index_path().is_file():
|
|
1524
|
+
return False
|
|
1525
|
+
try:
|
|
1526
|
+
state = json.loads(self._semantic_state_path().read_text(encoding="utf-8"))
|
|
1527
|
+
except (OSError, ValueError, TypeError):
|
|
1528
|
+
return False
|
|
1529
|
+
identity = {
|
|
1530
|
+
"collection": self.collection,
|
|
1531
|
+
"index_name": self.index_name,
|
|
1532
|
+
"index_spec_version": INDEX_SPEC_VERSION,
|
|
1533
|
+
}
|
|
1534
|
+
return all(
|
|
1535
|
+
state.get(key) == value for key, value in identity.items()
|
|
1536
|
+
) and bool(state.get("source_revision"))
|
|
1537
|
+
|
|
1538
|
+
def _semantic_source_revision(self) -> str | None:
|
|
1539
|
+
"""The canonical revision the last complete vector build represents."""
|
|
1540
|
+
try:
|
|
1541
|
+
state = json.loads(self._semantic_state_path().read_text(encoding="utf-8"))
|
|
1542
|
+
except (OSError, ValueError, TypeError):
|
|
1543
|
+
return None
|
|
1544
|
+
revision = state.get("source_revision")
|
|
1545
|
+
return str(revision) if revision else None
|
|
1546
|
+
|
|
1547
|
+
def _write_semantic_state(
|
|
1548
|
+
self,
|
|
1549
|
+
*,
|
|
1550
|
+
source_revision: str,
|
|
1551
|
+
duration_seconds: float | None = None,
|
|
1552
|
+
) -> None:
|
|
1553
|
+
self._atomic_json(
|
|
1554
|
+
self._semantic_state_path(),
|
|
1555
|
+
{
|
|
1556
|
+
"schema_version": "egregore-qmd-semantic/v1",
|
|
1557
|
+
"collection": self.collection,
|
|
1558
|
+
"index_name": self.index_name,
|
|
1559
|
+
"index_spec_version": INDEX_SPEC_VERSION,
|
|
1560
|
+
"source_revision": source_revision,
|
|
1561
|
+
"completed_at": datetime.now(UTC).isoformat(),
|
|
1562
|
+
"duration_seconds": round(duration_seconds, 3)
|
|
1563
|
+
if duration_seconds is not None
|
|
1564
|
+
else None,
|
|
1565
|
+
},
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
|
|
1569
|
+
if self._live_lock(self._embed_lock_path()):
|
|
1570
|
+
return "running"
|
|
1571
|
+
if semantic_ready:
|
|
1572
|
+
return "ready"
|
|
1573
|
+
if not self._embedding_model_available():
|
|
1574
|
+
return "missing_model"
|
|
1575
|
+
counts = self._status_counts(status)
|
|
1576
|
+
if counts["pending"] > 0 or counts["documents"] > 0:
|
|
1577
|
+
return "pending"
|
|
1578
|
+
return "not_built"
|
|
1579
|
+
|
|
1580
|
+
def _embed_lock_path(self) -> Path:
|
|
1581
|
+
safe_collection = re.sub(r"[^A-Za-z0-9_.-]", "-", self.collection)
|
|
1582
|
+
cache = self._index_path().parent
|
|
1583
|
+
return cache / f"egregore-embed-{safe_collection}.json"
|
|
1584
|
+
|
|
1585
|
+
def _live_lock(self, lock: Path) -> bool:
|
|
1586
|
+
# Liveness is process identity, not revision equality: a worker whose
|
|
1587
|
+
# target revision was outrun by a canonical write is still embedding
|
|
1588
|
+
# and converges through its own retry loop. Treating it as dead would
|
|
1589
|
+
# spawn a second concurrent embedder against the same SQLite index.
|
|
1590
|
+
try:
|
|
1591
|
+
data = json.loads(lock.read_text(encoding="utf-8"))
|
|
1592
|
+
pid = int(data["pid"])
|
|
1593
|
+
index_spec_version = str(data["index_spec_version"])
|
|
1594
|
+
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
|
|
1595
|
+
return False
|
|
1596
|
+
if index_spec_version != INDEX_SPEC_VERSION or not self._pid_alive(pid):
|
|
1597
|
+
lock.unlink(missing_ok=True)
|
|
1598
|
+
return False
|
|
1599
|
+
return True
|
|
1600
|
+
|
|
1601
|
+
@staticmethod
|
|
1602
|
+
def _atomic_json(path: Path, value: Mapping[str, Any]) -> None:
|
|
1603
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
1604
|
+
os.chmod(path.parent, 0o700)
|
|
1605
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
1606
|
+
temporary.write_text(json.dumps(value, sort_keys=True) + "\n", encoding="utf-8")
|
|
1607
|
+
os.chmod(temporary, 0o600)
|
|
1608
|
+
temporary.replace(path)
|
|
1609
|
+
os.chmod(path, 0o600)
|
|
1610
|
+
|
|
1611
|
+
def _config_slug(self) -> str:
|
|
1612
|
+
try:
|
|
1613
|
+
config = json.loads((self.repository_root / "egregore.json").read_text())
|
|
1614
|
+
slug = str(config.get("slug") or "egregore").strip()
|
|
1615
|
+
return slug or "egregore"
|
|
1616
|
+
except (OSError, ValueError, TypeError):
|
|
1617
|
+
return "egregore"
|
|
1618
|
+
|
|
1619
|
+
def _instance_hash(self) -> str:
|
|
1620
|
+
# Worktrees for one Egregore share the same canonical memory checkout.
|
|
1621
|
+
# Namespace by that stable root so they reuse one disposable index and
|
|
1622
|
+
# daemon instead of renaming the collection on every branch switch.
|
|
1623
|
+
material = str(self.memory_root)
|
|
1624
|
+
return hashlib.sha256(material.encode()).hexdigest()[:16]
|
|
1625
|
+
|
|
1626
|
+
def _default_collection(self) -> str:
|
|
1627
|
+
slug = re.sub(r"[^A-Za-z0-9_.-]", "-", self._config_slug())
|
|
1628
|
+
return f"{slug}-{self.collection_purpose}-{self._instance_hash()[:12]}"
|
|
1629
|
+
|
|
1630
|
+
# -- Process and output helpers ----------------------------------------
|
|
1631
|
+
|
|
1632
|
+
def _run(self, arguments: Sequence[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
|
1633
|
+
self._ensure_runtime_layout()
|
|
1634
|
+
self._ensure_version()
|
|
1635
|
+
return self._run_raw(
|
|
1636
|
+
(*self._command(), "--index", self.index_name, *arguments),
|
|
1637
|
+
timeout=timeout,
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
def _run_raw(
|
|
1641
|
+
self, command: Sequence[str], *, timeout: int
|
|
1642
|
+
) -> subprocess.CompletedProcess[str]:
|
|
1643
|
+
try:
|
|
1644
|
+
return subprocess.run(
|
|
1645
|
+
command,
|
|
1646
|
+
cwd=self.repository_root,
|
|
1647
|
+
env=self.environment,
|
|
1648
|
+
text=True,
|
|
1649
|
+
capture_output=True,
|
|
1650
|
+
timeout=timeout,
|
|
1651
|
+
check=False,
|
|
1652
|
+
umask=0o077,
|
|
1653
|
+
)
|
|
1654
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
1655
|
+
return subprocess.CompletedProcess(command, 124, "", str(exc))
|
|
1656
|
+
|
|
1657
|
+
@staticmethod
|
|
1658
|
+
def _parse_cli_results(output: str) -> list[dict[str, Any]]:
|
|
1659
|
+
start = output.find("[")
|
|
1660
|
+
end = output.rfind("]")
|
|
1661
|
+
if start == -1 or end < start:
|
|
1662
|
+
if "No results" in output or not output.strip():
|
|
1663
|
+
return []
|
|
1664
|
+
raise QmdAdapterError("typed qmd query returned non-JSON output")
|
|
1665
|
+
try:
|
|
1666
|
+
payload = json.loads(output[start : end + 1])
|
|
1667
|
+
except json.JSONDecodeError as exc:
|
|
1668
|
+
raise QmdAdapterError("typed qmd query returned invalid JSON") from exc
|
|
1669
|
+
if not isinstance(payload, list):
|
|
1670
|
+
raise QmdAdapterError("typed qmd query returned an invalid result shape")
|
|
1671
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
1672
|
+
|
|
1673
|
+
@staticmethod
|
|
1674
|
+
def _passage(raw: Mapping[str, Any]) -> str | None:
|
|
1675
|
+
snippet = raw.get("snippet")
|
|
1676
|
+
if not isinstance(snippet, str) or not snippet.strip():
|
|
1677
|
+
return None
|
|
1678
|
+
return re.sub(r"^\s*\d+:\s?", "", snippet.strip(), flags=re.MULTILINE)
|
|
1679
|
+
|
|
1680
|
+
@staticmethod
|
|
1681
|
+
def _single_line(value: str) -> str:
|
|
1682
|
+
return " ".join(value.splitlines()).strip()
|
|
1683
|
+
|
|
1684
|
+
@staticmethod
|
|
1685
|
+
def _failure_message(
|
|
1686
|
+
operation: str, completed: subprocess.CompletedProcess[str]
|
|
1687
|
+
) -> str:
|
|
1688
|
+
detail = completed.stderr.strip() or completed.stdout.strip() or "unknown failure"
|
|
1689
|
+
return f"{operation} failed ({completed.returncode}): {detail[:400]}"
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
__all__ = [
|
|
1693
|
+
"EMBEDDING_MODEL",
|
|
1694
|
+
"INDEX_SPEC_VERSION",
|
|
1695
|
+
"QMD_VERSION",
|
|
1696
|
+
"RERANKING_ENABLED",
|
|
1697
|
+
"RUNTIME_SCHEMA_VERSION",
|
|
1698
|
+
"QmdAdapterError",
|
|
1699
|
+
"QmdLocalRetriever",
|
|
1700
|
+
"QmdRuntimeMetadata",
|
|
1701
|
+
]
|